Merge branch 'shiy' into awen
# Conflicts: # package-lock.json # package.json
This commit is contained in:
+26
-42
@@ -3,10 +3,16 @@ import { type MetaFunction } from "@remix-run/node";
|
||||
import { useLoaderData } from "@remix-run/react";
|
||||
import { Card } from "~/components/ui/Card";
|
||||
import { Button } from "~/components/ui/Button";
|
||||
import { StatusBadge, links as statusBadgeLinks } from "~/components/ui/StatusBadge";
|
||||
import { FileTag, links as fileTagLinks } from "~/components/ui/FileTag";
|
||||
import { FileTypeTag, links as fileTypeTagLinks } from "~/components/ui/FileTypeTag";
|
||||
import homeStyles from "~/styles/pages/home.css?url";
|
||||
|
||||
export const links = () => [
|
||||
{ rel: "stylesheet", href: homeStyles }
|
||||
{ rel: "stylesheet", href: homeStyles },
|
||||
...statusBadgeLinks(),
|
||||
...fileTagLinks(),
|
||||
...fileTypeTagLinks()
|
||||
];
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
@@ -174,11 +180,28 @@ export default function Index() {
|
||||
{recentFiles.map((file: RecentFile) => (
|
||||
<div key={file.id} className="doc-item">
|
||||
<div className="doc-info">
|
||||
<i className={`doc-icon ${file.name.endsWith('.pdf') ? 'ri-file-pdf-line' : 'ri-file-word-line'}`}></i>
|
||||
<FileTag
|
||||
extension={file.name.endsWith('.pdf') ? 'pdf' : 'docx'}
|
||||
showIcon={true}
|
||||
showText={false}
|
||||
showBackground={false}
|
||||
size="lg"
|
||||
className="mr-2"
|
||||
/>
|
||||
<div>
|
||||
<div className="doc-name">{file.name}</div>
|
||||
<div className="doc-meta">
|
||||
{file.type} · {file.updatedAt}
|
||||
<FileTypeTag
|
||||
type={file.type === "合同文档" ? "sales-contract" :
|
||||
file.type === "专卖许可证" ? "license" :
|
||||
file.type === "行政处罚决定书" ? "punishment" : "agreement"}
|
||||
text={file.type}
|
||||
size="sm"
|
||||
showIcon={false}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-gray-500">·</span>
|
||||
<span className="ml-2 text-gray-500">{file.updatedAt}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -240,42 +263,3 @@ function ShortcutItem({ icon, label, to }: ShortcutItemProps) {
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// 状态标签组件
|
||||
interface StatusBadgeProps {
|
||||
status: string;
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: StatusBadgeProps) {
|
||||
const statusMap: Record<string, { label: string, className: string, icon: string }> = {
|
||||
pass: {
|
||||
label: '通过',
|
||||
className: 'status-badge status-success',
|
||||
icon: 'ri-checkbox-circle-line'
|
||||
},
|
||||
warning: {
|
||||
label: '警告',
|
||||
className: 'status-badge status-warning',
|
||||
icon: 'ri-alert-line'
|
||||
},
|
||||
fail: {
|
||||
label: '不通过',
|
||||
className: 'status-badge status-error',
|
||||
icon: 'ri-close-circle-line'
|
||||
},
|
||||
pending: {
|
||||
label: '待确认',
|
||||
className: 'status-badge status-processing',
|
||||
icon: 'ri-time-line'
|
||||
}
|
||||
};
|
||||
|
||||
const { label, className, icon } = statusMap[status] || statusMap.pending;
|
||||
|
||||
return (
|
||||
<span className={className}>
|
||||
<i className={`${icon} mr-1`}></i>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -13,9 +13,6 @@ export const links = () => [
|
||||
{ rel: "stylesheet", href: configListsStyles }
|
||||
];
|
||||
|
||||
// export const handle = {
|
||||
// breadcrumb: "系统配置管理"
|
||||
// };
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
|
||||
@@ -11,9 +11,8 @@ export const links = () => [
|
||||
];
|
||||
|
||||
export const handle = {
|
||||
breadcrumb: ({ location }: { location: Location }) => {
|
||||
const hasId = new URLSearchParams(location.search).has("id");
|
||||
return hasId ? "编辑配置" : "新增配置";
|
||||
breadcrumb: (data:LoaderData) => {
|
||||
return data.isEdit ? "编辑配置" : "新增配置";
|
||||
}
|
||||
};
|
||||
|
||||
@@ -159,7 +158,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
}
|
||||
}
|
||||
|
||||
return json<LoaderData>({
|
||||
return Response.json({
|
||||
config,
|
||||
isEdit: !!config
|
||||
});
|
||||
@@ -439,19 +438,10 @@ export default function ConfigNew() {
|
||||
<i className="ri-arrow-left-line mr-1"></i>
|
||||
返回
|
||||
</Button>
|
||||
<Form method="post" className="inline">
|
||||
{config?.id && <input type="hidden" name="id" value={config.id} />}
|
||||
<input type="hidden" name="configName" value={config?.configName || ''} />
|
||||
<input type="hidden" name="module" value={selectedModule} />
|
||||
<input type="hidden" name="environment" value={selectedEnvironment} />
|
||||
<input type="hidden" name="configData" value={configDataValue} />
|
||||
<input type="hidden" name="isActive" value={config?.isActive !== false ? "true" : "false"} />
|
||||
<input type="hidden" name="remarks" value={config?.remarks || ''} />
|
||||
<Button type="primary" disabled={isSubmitting}>
|
||||
<i className="ri-save-line mr-1"></i>
|
||||
{isSubmitting ? '保存中...' : '保存'}
|
||||
</Button>
|
||||
</Form>
|
||||
<Button type="primary" disabled={isSubmitting} form="configForm">
|
||||
<i className="ri-save-line mr-1"></i>
|
||||
{isSubmitting ? '保存中...' : '保存'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,700 @@
|
||||
import { useState } from "react";
|
||||
import { useSearchParams, Link } from "@remix-run/react";
|
||||
import { type MetaFunction, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { Card } from "~/components/ui/Card";
|
||||
import { Button } from "~/components/ui/Button";
|
||||
import { Table } from "~/components/ui/Table";
|
||||
import { Pagination } from "~/components/ui/Pagination";
|
||||
import { StatusBadge } from "~/components/ui/StatusBadge";
|
||||
import { FileTypeTag } from "~/components/ui/FileTypeTag";
|
||||
import { FileTag } from "~/components/ui/FileTag";
|
||||
import { FilterPanel, FilterSelect, SearchFilter, DateRangeFilter } from "~/components/ui/FilterPanel";
|
||||
import documentsIndexStyles from "~/styles/pages/documents_index.css?url";
|
||||
|
||||
// 导入样式
|
||||
export function links() {
|
||||
return [
|
||||
{ rel: "stylesheet", href: documentsIndexStyles }
|
||||
];
|
||||
}
|
||||
|
||||
// 元数据
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
{ title: "文档列表 - 中国烟草AI合同及卷宗审核系统" },
|
||||
{ name: "description", content: "查看和管理系统中的所有文档,包括合同、许可证和行政处罚决定书等" },
|
||||
];
|
||||
};
|
||||
|
||||
interface DocumentItem {
|
||||
id: string;
|
||||
name: string;
|
||||
documentNumber: string;
|
||||
type: string;
|
||||
typeName: string;
|
||||
size: number;
|
||||
status: string;
|
||||
issues: number | null;
|
||||
uploadTime: string;
|
||||
fileType: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
// 数据加载器
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
// 获取URL查询参数
|
||||
const url = new URL(request.url);
|
||||
const search = url.searchParams.get("search") || "";
|
||||
const documentType = url.searchParams.get("documentType") || "";
|
||||
const status = url.searchParams.get("status") || "";
|
||||
const documentNumber = url.searchParams.get("documentNumber") || "";
|
||||
const dateFrom = url.searchParams.get("dateFrom") || "";
|
||||
const dateTo = url.searchParams.get("dateTo") || "";
|
||||
const page = parseInt(url.searchParams.get("page") || "1", 10);
|
||||
const pageSize = parseInt(url.searchParams.get("pageSize") || "20", 10);
|
||||
|
||||
// 在实际应用中,这里会调用API获取数据
|
||||
// const response = await fetch(`/api/documents?search=${search}&...`);
|
||||
// const data = await response.json();
|
||||
|
||||
// 使用模拟数据
|
||||
const mockData = {
|
||||
documents: [
|
||||
{
|
||||
id: "1",
|
||||
name: "2023年度烟草销售框架合同.pdf",
|
||||
documentNumber: "XS20230001",
|
||||
type: "sales-contract",
|
||||
typeName: "销售合同",
|
||||
size: 2.5 * 1024 * 1024, // 2.5MB
|
||||
status: "pass",
|
||||
issues: 0,
|
||||
uploadTime: "2023-10-15 15:30",
|
||||
fileType: "pdf"
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "设备采购合同-打印机.docx",
|
||||
documentNumber: "CG20230052",
|
||||
type: "purchase-contract",
|
||||
typeName: "采购合同",
|
||||
size: 1.2 * 1024 * 1024, // 1.2MB
|
||||
status: "warning",
|
||||
issues: 3,
|
||||
uploadTime: "2023-10-14 09:15",
|
||||
fileType: "docx"
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "烟草零售许可证.pdf",
|
||||
documentNumber: "ZM2023100345",
|
||||
type: "license",
|
||||
typeName: "专卖许可证",
|
||||
size: 0.8 * 1024 * 1024, // 0.8MB
|
||||
status: "pending",
|
||||
issues: null,
|
||||
uploadTime: "2023-10-13 14:20",
|
||||
fileType: "pdf"
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
name: "非法售烟行政处罚决定书.docx",
|
||||
documentNumber: "CF20230087",
|
||||
type: "punishment",
|
||||
typeName: "行政处罚决定书",
|
||||
size: 1.5 * 1024 * 1024, // 1.5MB
|
||||
status: "processing",
|
||||
issues: null,
|
||||
uploadTime: "2023-10-10 16:45",
|
||||
fileType: "docx"
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
name: "烟草种植承包协议-2023.pdf",
|
||||
documentNumber: "CB20230024",
|
||||
type: "agreement",
|
||||
typeName: "承包协议",
|
||||
size: 3.2 * 1024 * 1024, // 3.2MB
|
||||
status: "fail",
|
||||
issues: 8,
|
||||
uploadTime: "2023-10-09 10:30",
|
||||
fileType: "pdf",
|
||||
tags: ["测试"]
|
||||
},
|
||||
],
|
||||
total: 156,
|
||||
page,
|
||||
pageSize
|
||||
};
|
||||
|
||||
// 返回数据
|
||||
return Response.json(mockData);
|
||||
};
|
||||
|
||||
// 处理表单提交和删除等操作
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const formData = await request.formData();
|
||||
const action = formData.get("_action");
|
||||
|
||||
// 在实际应用中,这里会根据action类型调用相应的API
|
||||
// 例如删除文档,批量删除,等等
|
||||
|
||||
if (action === "delete") {
|
||||
const id = formData.get("id");
|
||||
// await fetch(`/api/documents/${id}`, { method: "DELETE" });
|
||||
return Response.json({ success: true, message: "文档已成功删除" });
|
||||
}
|
||||
|
||||
if (action === "batchDelete") {
|
||||
const ids = formData.getAll("ids");
|
||||
// await fetch(`/api/documents/batch-delete`, {
|
||||
// method: "POST",
|
||||
// body: JSON.stringify({ ids }),
|
||||
// headers: { "Content-Type": "application/json" }
|
||||
// });
|
||||
return Response.json({ success: true, message: `已成功删除${ids.length}个文档` });
|
||||
}
|
||||
|
||||
// 未知操作
|
||||
return Response.json({ success: false, message: "未知操作" }, { status: 400 });
|
||||
};
|
||||
|
||||
// 文档类型选项
|
||||
const documentTypeOptions = [
|
||||
{ value: "sales-contract", label: "销售合同" },
|
||||
{ value: "purchase-contract", label: "采购合同" },
|
||||
{ value: "license", label: "专卖许可证" },
|
||||
{ value: "punishment", label: "行政处罚决定书" },
|
||||
{ value: "agreement", label: "承包协议" },
|
||||
];
|
||||
|
||||
// 文档状态选项
|
||||
const documentStatusOptions = [
|
||||
{ value: "pending", label: "待审核" },
|
||||
{ value: "processing", label: "审核中" },
|
||||
{ value: "pass", label: "通过" },
|
||||
{ value: "warning", label: "警告" },
|
||||
{ value: "fail", label: "不通过" },
|
||||
];
|
||||
|
||||
// 格式化文件大小
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes === 0) return "0 Bytes";
|
||||
const k = 1024;
|
||||
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
||||
};
|
||||
|
||||
// 获取文档类型标签背景颜色
|
||||
// 此函数已不再需要,改用 FileTypeTag 组件
|
||||
// const getDocumentTypeTagColor = (type: string): string => {
|
||||
// const colorMap: Record<string, string> = {
|
||||
// "sales-contract": "blue",
|
||||
// "purchase-contract": "green",
|
||||
// "license": "purple",
|
||||
// "punishment": "yellow",
|
||||
// "agreement": "orange",
|
||||
// "default": "gray"
|
||||
// };
|
||||
// return colorMap[type] || colorMap.default;
|
||||
// };
|
||||
|
||||
export default function DocumentsIndex() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
|
||||
|
||||
// 从URL获取当前筛选条件
|
||||
const search = searchParams.get("search") || "";
|
||||
const documentType = searchParams.get("documentType") || "";
|
||||
const status = searchParams.get("status") || "";
|
||||
const documentNumber = searchParams.get("documentNumber") || "";
|
||||
const dateFrom = searchParams.get("dateFrom") || "";
|
||||
const dateTo = searchParams.get("dateTo") || "";
|
||||
const currentPage = parseInt(searchParams.get("page") || "1", 10);
|
||||
const pageSize = parseInt(searchParams.get("pageSize") || "20", 10);
|
||||
|
||||
// API 返回的模拟数据
|
||||
const mockData = {
|
||||
documents: [
|
||||
{
|
||||
id: "1",
|
||||
name: "2023年度烟草销售框架合同.pdf",
|
||||
documentNumber: "XS20230001",
|
||||
type: "sales-contract",
|
||||
typeName: "销售合同",
|
||||
size: 2.5 * 1024 * 1024, // 2.5MB
|
||||
status: "pass",
|
||||
issues: 0,
|
||||
uploadTime: "2023-10-15 15:30",
|
||||
fileType: "pdf"
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "设备采购合同-打印机.docx",
|
||||
documentNumber: "CG20230052",
|
||||
type: "purchase-contract",
|
||||
typeName: "采购合同",
|
||||
size: 1.2 * 1024 * 1024, // 1.2MB
|
||||
status: "warning",
|
||||
issues: 3,
|
||||
uploadTime: "2023-10-14 09:15",
|
||||
fileType: "docx"
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "烟草零售许可证.pdf",
|
||||
documentNumber: "ZM2023100345",
|
||||
type: "license",
|
||||
typeName: "专卖许可证",
|
||||
size: 0.8 * 1024 * 1024, // 0.8MB
|
||||
status: "pending",
|
||||
issues: null,
|
||||
uploadTime: "2023-10-13 14:20",
|
||||
fileType: "pdf"
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
name: "非法售烟行政处罚决定书.docx",
|
||||
documentNumber: "CF20230087",
|
||||
type: "punishment",
|
||||
typeName: "行政处罚决定书",
|
||||
size: 1.5 * 1024 * 1024, // 1.5MB
|
||||
status: "processing",
|
||||
issues: null,
|
||||
uploadTime: "2023-10-10 16:45",
|
||||
fileType: "docx"
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
name: "烟草种植承包协议-2023.pdf",
|
||||
documentNumber: "CB20230024",
|
||||
type: "agreement",
|
||||
typeName: "承包协议",
|
||||
size: 3.2 * 1024 * 1024, // 3.2MB
|
||||
status: "fail",
|
||||
issues: 8,
|
||||
uploadTime: "2023-10-09 10:30",
|
||||
fileType: "pdf",
|
||||
tags: ["测试"]
|
||||
},
|
||||
],
|
||||
total: 156,
|
||||
page: currentPage,
|
||||
pageSize
|
||||
};
|
||||
|
||||
// 分页处理函数
|
||||
const handlePageChange = (page: number) => {
|
||||
searchParams.set("page", page.toString());
|
||||
setSearchParams(searchParams);
|
||||
};
|
||||
|
||||
// 每页条数变更处理函数
|
||||
const handlePageSizeChange = (size: number) => {
|
||||
searchParams.set("pageSize", size.toString());
|
||||
searchParams.set("page", "1"); // 重置到第一页
|
||||
setSearchParams(searchParams);
|
||||
};
|
||||
|
||||
// 处理文档名称搜索
|
||||
const handleNameSearch = (value: string) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
if (value) {
|
||||
params.set("search", value);
|
||||
} else {
|
||||
params.delete("search");
|
||||
}
|
||||
params.set("page", "1"); // 重置页码
|
||||
setSearchParams(params);
|
||||
};
|
||||
|
||||
// 处理文档编号变更
|
||||
const handleDocumentNumberChange = (value: string) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
if (value) {
|
||||
params.set("documentNumber", value);
|
||||
} else {
|
||||
params.delete("documentNumber");
|
||||
}
|
||||
params.set("page", "1"); // 重置页码
|
||||
setSearchParams(params);
|
||||
};
|
||||
|
||||
// 处理文档类型变更
|
||||
const handleDocumentTypeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
if (e.target.value) {
|
||||
params.set("documentType", e.target.value);
|
||||
} else {
|
||||
params.delete("documentType");
|
||||
}
|
||||
params.set("page", "1"); // 重置页码
|
||||
setSearchParams(params);
|
||||
};
|
||||
|
||||
// 处理状态变更
|
||||
const handleStatusChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
if (e.target.value) {
|
||||
params.set("status", e.target.value);
|
||||
} else {
|
||||
params.delete("status");
|
||||
}
|
||||
params.set("page", "1"); // 重置页码
|
||||
setSearchParams(params);
|
||||
};
|
||||
|
||||
// 处理日期范围变更
|
||||
const handleDateChange = (field: 'dateFrom' | 'dateTo', value: string) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
if (value) {
|
||||
params.set(field, value);
|
||||
} else {
|
||||
params.delete(field);
|
||||
}
|
||||
params.set("page", "1"); // 重置页码
|
||||
setSearchParams(params);
|
||||
};
|
||||
|
||||
// 重置搜索条件
|
||||
const handleReset = () => {
|
||||
setSearchParams(new URLSearchParams({
|
||||
page: "1",
|
||||
pageSize: pageSize.toString()
|
||||
}));
|
||||
};
|
||||
|
||||
// 行选择变更处理
|
||||
const handleRowSelectionChange = (id: string) => {
|
||||
if (selectedRowKeys.includes(id)) {
|
||||
setSelectedRowKeys(selectedRowKeys.filter(key => key !== id));
|
||||
} else {
|
||||
setSelectedRowKeys([...selectedRowKeys, id]);
|
||||
}
|
||||
};
|
||||
|
||||
// 全选处理
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedRowKeys(mockData.documents.map(doc => doc.id));
|
||||
} else {
|
||||
setSelectedRowKeys([]);
|
||||
}
|
||||
};
|
||||
|
||||
// 删除确认
|
||||
const confirmDelete = (id: string, name: string) => {
|
||||
if (window.confirm(`确认删除文档 "${name}"?`)) {
|
||||
// 在实际应用中这里会提交表单到action处理
|
||||
console.log('删除文档:', id, name);
|
||||
|
||||
// 更新选中行
|
||||
setSelectedRowKeys(selectedRowKeys.filter(key => key !== id));
|
||||
}
|
||||
};
|
||||
|
||||
// 批量删除确认
|
||||
const confirmBatchDelete = () => {
|
||||
if (selectedRowKeys.length === 0) {
|
||||
alert('请至少选择一个文档');
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.confirm(`确认删除选中的 ${selectedRowKeys.length} 个文档?`)) {
|
||||
// 在实际应用中这里会提交表单到action处理
|
||||
console.log('批量删除文档IDs:', selectedRowKeys);
|
||||
|
||||
// 清空选中行
|
||||
setSelectedRowKeys([]);
|
||||
}
|
||||
};
|
||||
|
||||
// 表格列定义
|
||||
const columns = [
|
||||
{
|
||||
title: (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedRowKeys.length === mockData.documents.length}
|
||||
onChange={(e) => handleSelectAll(e.target.checked)}
|
||||
/>
|
||||
),
|
||||
key: "selection",
|
||||
width: "50px",
|
||||
render: (_: unknown, record: DocumentItem) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedRowKeys.includes(record.id)}
|
||||
onChange={() => handleRowSelectionChange(record.id)}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: "文档名称",
|
||||
key: "name",
|
||||
render: (_: unknown, record: DocumentItem) => (
|
||||
<div className="flex items-center m-1">
|
||||
<FileTag
|
||||
extension={record.fileType}
|
||||
showIcon={true}
|
||||
showText={false}
|
||||
showBackground={false}
|
||||
size="lg"
|
||||
className="mr-2"
|
||||
/>
|
||||
<div>
|
||||
<span className="file-name" title={record.name}>{record.name}</span>
|
||||
<div className="mt-2 flex inline-block">
|
||||
<FileTypeTag
|
||||
type={record.type}
|
||||
text={record.typeName}
|
||||
size="sm"
|
||||
showIcon={false}
|
||||
/>
|
||||
{record.tags && record.tags.map((tag: string) => (
|
||||
<span key={tag} className="ml-2 text-xs bg-gray-100 text-gray-500 px-1 rounded">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: "文档编号",
|
||||
key: "documentNumber",
|
||||
render: (_: unknown, record: DocumentItem) => (
|
||||
<span className="document-number">{record.documentNumber}</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: "文件大小",
|
||||
key: "size",
|
||||
render: (_: unknown, record: DocumentItem) => formatFileSize(record.size)
|
||||
},
|
||||
{
|
||||
title: "审核状态",
|
||||
key: "status",
|
||||
render: (_: unknown, record: DocumentItem) => (
|
||||
<StatusBadge status={record.status} showIcon={false} />
|
||||
)
|
||||
},
|
||||
{
|
||||
title: "问题数量",
|
||||
key: "issues",
|
||||
render: (_: unknown, record: DocumentItem) => (
|
||||
record.issues === null ? "-" : record.issues
|
||||
)
|
||||
},
|
||||
{
|
||||
title: "上传时间",
|
||||
key: "uploadTime",
|
||||
render: (_: unknown, record: DocumentItem) => record.uploadTime
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "actions",
|
||||
width: "280px",
|
||||
render: (_: unknown, record: DocumentItem) => (
|
||||
<div className="operations-cell">
|
||||
{record.status === "pending" ? (
|
||||
<Link
|
||||
to={`/documents/${record.id}/review`}
|
||||
className="mr-1 hover:underline"
|
||||
>
|
||||
<i className="ri-play-circle-line"></i>
|
||||
开始审核
|
||||
</Link>
|
||||
) : record.status === "processing" ? (
|
||||
<Link
|
||||
to={`/documents/${record.id}/progress`}
|
||||
className="mr-1 hover:underline"
|
||||
>
|
||||
<i className="ri-eye-line"></i>
|
||||
查看进度
|
||||
</Link>
|
||||
) : (
|
||||
<Link
|
||||
to={`/documents/${record.id}`}
|
||||
className="mr-1 hover:underline"
|
||||
>
|
||||
<i className="ri-eye-line"></i>
|
||||
查看
|
||||
</Link>
|
||||
)}
|
||||
<Link
|
||||
to={`/documents/${record.id}/edit`}
|
||||
className="mr-1 text-gray-500 hover:underline hover:text-gray-700"
|
||||
>
|
||||
<i className="ri-edit-line"></i>
|
||||
修改
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className="mr-1 text-gray-500 hover:underline hover:text-gray-700"
|
||||
onClick={() => alert(`下载文档: ${record.name}`)}
|
||||
>
|
||||
<i className="ri-download-line"></i>
|
||||
下载
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="text-error hover:underline hover:text-red-700"
|
||||
onClick={() => confirmDelete(record.id, record.name)}
|
||||
>
|
||||
<i className="ri-delete-bin-line"></i>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="documents-page">
|
||||
{/* 页面头部 */}
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-medium">文档列表</h2>
|
||||
<div>
|
||||
<Button
|
||||
type="primary"
|
||||
icon="ri-upload-line"
|
||||
to="/documents/upload"
|
||||
className="hover:text-white"
|
||||
>
|
||||
上传文档
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 搜索筛选区 */}
|
||||
<FilterPanel
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
type="default"
|
||||
icon="ri-refresh-line"
|
||||
onClick={handleReset}
|
||||
className="mr-2"
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon="ri-search-line"
|
||||
onClick={() => {
|
||||
// 保持当前筛选条件,刷新数据
|
||||
// 在实际应用中,这里可能需要触发某些操作
|
||||
}}
|
||||
>
|
||||
搜索
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
noActionDivider={true}
|
||||
>
|
||||
<SearchFilter
|
||||
label="文档名称"
|
||||
placeholder="请输入文档名称"
|
||||
value={search}
|
||||
onSearch={handleNameSearch}
|
||||
instantSearch={true}
|
||||
className="mr-2 w-50"
|
||||
/>
|
||||
|
||||
<SearchFilter
|
||||
label="文档编号"
|
||||
placeholder="请输入文档编号"
|
||||
value={documentNumber}
|
||||
onSearch={handleDocumentNumberChange}
|
||||
instantSearch={true}
|
||||
className="mr-2 w-50"
|
||||
/>
|
||||
|
||||
<FilterSelect
|
||||
label="文档类型"
|
||||
name="documentType"
|
||||
value={documentType}
|
||||
options={documentTypeOptions}
|
||||
onChange={handleDocumentTypeChange}
|
||||
className="mr-2 w-30"
|
||||
/>
|
||||
|
||||
<FilterSelect
|
||||
label="审核状态"
|
||||
name="status"
|
||||
value={status}
|
||||
options={documentStatusOptions}
|
||||
onChange={handleStatusChange}
|
||||
className="mr-2 w-50"
|
||||
/>
|
||||
|
||||
<DateRangeFilter
|
||||
label="上传时间"
|
||||
startDate={dateFrom}
|
||||
endDate={dateTo}
|
||||
onStartDateChange={(value) => handleDateChange('dateFrom', value)}
|
||||
onEndDateChange={(value) => handleDateChange('dateTo', value)}
|
||||
className="flex-1"
|
||||
simple={true}
|
||||
/>
|
||||
</FilterPanel>
|
||||
|
||||
|
||||
{/* 数据表格 */}
|
||||
<Card>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<Button
|
||||
type="default"
|
||||
icon="ri-delete-bin-line"
|
||||
onClick={confirmBatchDelete}
|
||||
className="mr-2"
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
>
|
||||
批量删除
|
||||
</Button>
|
||||
<Button
|
||||
type="default"
|
||||
icon="ri-download-line"
|
||||
>
|
||||
导出列表
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-sm text-secondary">
|
||||
共 <span className="font-medium text-primary">{mockData.total}</span> 条记录
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={mockData.documents}
|
||||
rowKey="id"
|
||||
emptyText="暂无数据"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 分页 */}
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
total={mockData.total}
|
||||
pageSize={pageSize}
|
||||
onChange={handlePageChange}
|
||||
onPageSizeChange={handlePageSizeChange}
|
||||
pageSizeOptions={[10, 20, 50, 100]}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 错误边界处理
|
||||
export function ErrorBoundary() {
|
||||
return (
|
||||
<div className="error-container">
|
||||
<h1 className="text-xl font-bold text-red-500">出错了</h1>
|
||||
<p>加载文档列表时出现错误。请刷新页面或联系系统管理员。</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Outlet } from "react-router-dom";
|
||||
import {type MetaFunction} from "@remix-run/node";
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
{title: "文档列表 - 中国烟草AI合同及卷宗审核系统"},
|
||||
{name: "documents", content: "文档列表,新增,修改"}
|
||||
]
|
||||
}
|
||||
|
||||
export const handle = {
|
||||
breadcrumb: "文档列表"
|
||||
}
|
||||
|
||||
/**
|
||||
* 文档列表路由布局
|
||||
*/
|
||||
export default function DocumentsLayout() {
|
||||
return (
|
||||
<Outlet />
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,632 @@
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import { type ActionFunctionArgs, type MetaFunction, json } from "@remix-run/node";
|
||||
import { Form, useActionData, useNavigation, useSubmit } from "@remix-run/react";
|
||||
import { Card } from "~/components/ui/Card";
|
||||
import { Button } from "~/components/ui/Button";
|
||||
import { Alert } from "~/components/ui/Alert";
|
||||
import { UploadArea, type UploadAreaRef } from "~/components/ui/UploadArea";
|
||||
import { FileProgress } from "~/components/ui/FileProgress";
|
||||
import { FileTag } from "~/components/ui/FileTag";
|
||||
import documentUploadStyles from "~/styles/pages/document-upload.css?url";
|
||||
|
||||
export const links = () => [
|
||||
{ rel: "stylesheet", href: documentUploadStyles }
|
||||
];
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
{ title: "上传文档 - 中国烟草AI合同及卷宗审核系统" },
|
||||
{ name: "description", content: "上传文档进行AI审核" }
|
||||
];
|
||||
};
|
||||
|
||||
export const handle = {
|
||||
breadcrumb: "上传文档"
|
||||
};
|
||||
|
||||
// 模拟API支持的文件类型
|
||||
const SUPPORTED_FILE_TYPES = [
|
||||
{ id: "1", name: "销售合同" },
|
||||
{ id: "2", name: "采购合同" },
|
||||
{ id: "3", name: "专卖许可证" },
|
||||
{ id: "4", name: "行政处罚决定书" },
|
||||
{ id: "5", name: "承包协议" }
|
||||
];
|
||||
|
||||
// 模拟API支持的存储类型
|
||||
const STORAGE_TYPES = [
|
||||
{ id: "minio", name: "MinIO对象存储" },
|
||||
{ id: "local", name: "本地文件系统" },
|
||||
{ id: "s3", name: "Amazon S3" }
|
||||
];
|
||||
|
||||
// 文件上传完成后的操作选项
|
||||
const AFTER_UPLOAD_OPTIONS = [
|
||||
{ id: "list", name: "返回文档列表" },
|
||||
{ id: "stay", name: "留在当前页面" },
|
||||
{ id: "audit", name: "立即开始审核" }
|
||||
];
|
||||
|
||||
// 定义接口
|
||||
interface UploadedFile {
|
||||
id: string;
|
||||
name: string;
|
||||
size: number;
|
||||
status: "waiting" | "uploading" | "success" | "error";
|
||||
progress: number;
|
||||
error?: string;
|
||||
newName?: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
interface ActionData {
|
||||
success?: boolean;
|
||||
error?: string;
|
||||
files?: UploadedFile[];
|
||||
}
|
||||
|
||||
// Action函数处理表单提交
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
// 在实际应用中,这里应该处理文件上传逻辑
|
||||
// 例如使用FormData API获取文件并调用后端API
|
||||
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
const docType = formData.get("docType") as string;
|
||||
const docNumber = formData.get("docNumber") as string;
|
||||
const docRemark = formData.get("docRemark") as string;
|
||||
const isTestDocument = formData.get("isTestDocument") === "true";
|
||||
const storageType = formData.get("storageType") as string;
|
||||
const afterUpload = formData.get("afterUpload") as string;
|
||||
|
||||
// 在真实情况下,这里将处理文件上传
|
||||
// 由于Remix在服务器端不直接处理文件,我们将在客户端处理文件上传
|
||||
// 然后将文件信息发送给服务器
|
||||
|
||||
// 模拟处理过程
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
return json<ActionData>({
|
||||
success: true,
|
||||
files: [] // 服务器处理的文件列表将返回这里
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Upload error:", error);
|
||||
return json<ActionData>(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "文件上传过程中发生错误"
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 格式化文件大小
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return "0 Bytes";
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ["Bytes", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
||||
}
|
||||
|
||||
// 获取文件扩展名
|
||||
function getFileExtension(filename: string): string {
|
||||
return filename.split('.').pop()?.toLowerCase() || "";
|
||||
}
|
||||
|
||||
// 检查文件类型是否支持
|
||||
function isFileTypeSupported(filename: string): boolean {
|
||||
const ext = getFileExtension(filename);
|
||||
return ["pdf", "doc", "docx", "txt"].includes(ext);
|
||||
}
|
||||
|
||||
export default function DocumentUpload() {
|
||||
const actionData = useActionData<typeof action>();
|
||||
const navigation = useNavigation();
|
||||
const submit = useSubmit();
|
||||
const uploading = navigation.state === "submitting";
|
||||
|
||||
const [files, setFiles] = useState<UploadedFile[]>([]);
|
||||
const [showAdvancedOptions, setShowAdvancedOptions] = useState(false);
|
||||
const [isTestDocument, setIsTestDocument] = useState(false);
|
||||
const [uploadComplete, setUploadComplete] = useState(false);
|
||||
const [selectedFileIds, setSelectedFileIds] = useState<string[]>([]);
|
||||
|
||||
const uploadAreaRef = useRef<UploadAreaRef>(null);
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
|
||||
// 处理文件选择
|
||||
const handleFilesSelected = useCallback((fileList: FileList) => {
|
||||
const newFiles: UploadedFile[] = [];
|
||||
|
||||
Array.from(fileList).forEach(file => {
|
||||
// 检查文件类型
|
||||
if (!isFileTypeSupported(file.name)) {
|
||||
alert(`不支持的文件类型: ${file.name}\n请上传PDF、DOC、DOCX或TXT格式文件`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查文件大小
|
||||
if (file.size > 50 * 1024 * 1024) { // 50MB
|
||||
alert(`文件过大: ${file.name}\n文件大小不能超过50MB`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否已添加
|
||||
const isDuplicate = files.some(f => f.name === file.name && f.size === file.size);
|
||||
if (isDuplicate) {
|
||||
alert(`文件已添加: ${file.name}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 添加新文件
|
||||
newFiles.push({
|
||||
id: `file-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
status: "waiting",
|
||||
progress: 0,
|
||||
type: getFileExtension(file.name)
|
||||
});
|
||||
});
|
||||
|
||||
setFiles(prev => [...prev, ...newFiles]);
|
||||
// 重置文件输入,允许再次选择相同文件
|
||||
uploadAreaRef.current?.resetFileInput();
|
||||
}, [files]);
|
||||
|
||||
// 移除文件
|
||||
const removeFile = useCallback((fileId: string) => {
|
||||
setFiles(prev => prev.filter(file => file.id !== fileId));
|
||||
setSelectedFileIds(prev => prev.filter(id => id !== fileId));
|
||||
}, []);
|
||||
|
||||
// 批量删除文件
|
||||
const removeSelectedFiles = useCallback(() => {
|
||||
if (selectedFileIds.length === 0) return;
|
||||
|
||||
if (confirm(`确定要删除选中的 ${selectedFileIds.length} 个文件吗?`)) {
|
||||
setFiles(prev => prev.filter(file => !selectedFileIds.includes(file.id)));
|
||||
setSelectedFileIds([]);
|
||||
}
|
||||
}, [selectedFileIds]);
|
||||
|
||||
// 清空文件列表
|
||||
const clearAllFiles = useCallback(() => {
|
||||
if (files.length === 0) return;
|
||||
|
||||
if (confirm('确定要清空文件列表吗?')) {
|
||||
setFiles([]);
|
||||
setSelectedFileIds([]);
|
||||
}
|
||||
}, [files.length]);
|
||||
|
||||
// 切换文件选择
|
||||
const toggleFileSelection = useCallback((fileId: string, selected: boolean) => {
|
||||
if (selected) {
|
||||
setSelectedFileIds(prev => [...prev, fileId]);
|
||||
} else {
|
||||
setSelectedFileIds(prev => prev.filter(id => id !== fileId));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 更新文件名
|
||||
const updateFileName = useCallback((fileId: string, newName: string) => {
|
||||
setFiles(prev =>
|
||||
prev.map(file =>
|
||||
file.id === fileId
|
||||
? { ...file, newName: newName + '.' + getFileExtension(file.name) }
|
||||
: file
|
||||
)
|
||||
);
|
||||
}, []);
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = useCallback((event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
const form = event.currentTarget;
|
||||
const docType = form.docType.value;
|
||||
|
||||
// 表单验证
|
||||
if (!docType) {
|
||||
alert('请选择文档类型');
|
||||
return;
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
alert('请至少上传一个文档');
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建FormData对象
|
||||
const formData = new FormData(form);
|
||||
formData.append("isTestDocument", isTestDocument.toString());
|
||||
|
||||
// 在实际应用中,这里应该处理文件上传
|
||||
// 如果Remix不能直接处理文件上传,可以考虑使用预签名URL或其他方法
|
||||
// 这里我们模拟文件上传进度
|
||||
simulateUpload();
|
||||
|
||||
// 提交表单
|
||||
submit(formData, { method: "post", encType: "multipart/form-data" });
|
||||
}, [files.length, isTestDocument, submit]);
|
||||
|
||||
// 模拟文件上传进度
|
||||
const simulateUpload = useCallback(() => {
|
||||
const updatedFiles = [...files];
|
||||
|
||||
// 设置所有文件为上传中状态
|
||||
updatedFiles.forEach(file => {
|
||||
file.status = "uploading";
|
||||
file.progress = 0;
|
||||
});
|
||||
|
||||
setFiles(updatedFiles);
|
||||
|
||||
// 模拟进度更新
|
||||
const interval = setInterval(() => {
|
||||
setFiles(prevFiles => {
|
||||
const newFiles = [...prevFiles];
|
||||
let allComplete = true;
|
||||
|
||||
newFiles.forEach(file => {
|
||||
if (file.status === "uploading") {
|
||||
// 增加进度
|
||||
file.progress += Math.random() * 10;
|
||||
|
||||
if (file.progress >= 100) {
|
||||
file.progress = 100;
|
||||
|
||||
// 模拟有10%概率上传失败
|
||||
if (Math.random() > 0.9) {
|
||||
file.status = "error";
|
||||
file.error = "上传失败,请重试";
|
||||
} else {
|
||||
file.status = "success";
|
||||
}
|
||||
} else {
|
||||
allComplete = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 如果所有文件都完成了,停止定时器
|
||||
if (allComplete) {
|
||||
clearInterval(interval);
|
||||
setTimeout(() => {
|
||||
// 检查是否有文件上传错误
|
||||
const hasErrors = newFiles.some(file => file.status === "error");
|
||||
if (!hasErrors) {
|
||||
setUploadComplete(true);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
return newFiles;
|
||||
});
|
||||
}, 200);
|
||||
}, [files]);
|
||||
|
||||
// 重新上传文件
|
||||
const retryUpload = useCallback((fileId: string) => {
|
||||
setFiles(prev =>
|
||||
prev.map(file =>
|
||||
file.id === fileId
|
||||
? { ...file, status: "uploading", progress: 0, error: undefined }
|
||||
: file
|
||||
)
|
||||
);
|
||||
|
||||
// 模拟重新上传
|
||||
setTimeout(() => {
|
||||
setFiles(prev =>
|
||||
prev.map(file => {
|
||||
if (file.id === fileId) {
|
||||
const success = Math.random() > 0.1;
|
||||
return {
|
||||
...file,
|
||||
status: success ? "success" : "error",
|
||||
progress: 100,
|
||||
error: success ? undefined : "上传失败,请重试"
|
||||
};
|
||||
}
|
||||
return file;
|
||||
})
|
||||
);
|
||||
}, 2000);
|
||||
}, []);
|
||||
|
||||
// 重置表单,继续上传
|
||||
const resetForm = useCallback(() => {
|
||||
setFiles([]);
|
||||
setUploadComplete(false);
|
||||
setSelectedFileIds([]);
|
||||
formRef.current?.reset();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="document-upload-page">
|
||||
<div className="page-header">
|
||||
<h2 className="page-title">上传文档</h2>
|
||||
<div>
|
||||
<Button to="/documents" type="default" className="mr-2">
|
||||
<i className="ri-arrow-left-line"></i> 返回
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={files.length === 0 || uploading}
|
||||
onClick={() => formRef.current?.requestSubmit()}
|
||||
>
|
||||
<i className="ri-upload-2-line"></i> 开始上传
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
{!uploadComplete ? (
|
||||
<Form ref={formRef} method="post" onSubmit={handleSubmit} encType="multipart/form-data">
|
||||
<div className="form-grid">
|
||||
<div className="form-group">
|
||||
<label className="form-label" htmlFor="docType">
|
||||
文档类型 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="docType"
|
||||
name="docType"
|
||||
className="form-select w-full"
|
||||
required
|
||||
>
|
||||
<option value="">请选择文档类型</option>
|
||||
{SUPPORTED_FILE_TYPES.map(type => (
|
||||
<option key={type.id} value={type.id}>
|
||||
{type.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="form-tip">不同文档类型应用不同的评查规则</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label" htmlFor="docNumber">
|
||||
文档编号
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="docNumber"
|
||||
name="docNumber"
|
||||
className="form-input w-full"
|
||||
placeholder="请输入合同编号、许可证号等"
|
||||
/>
|
||||
<div className="form-tip">如无编号可留空,系统将自动识别</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label" htmlFor="docRemark">
|
||||
备注信息
|
||||
</label>
|
||||
<textarea
|
||||
id="docRemark"
|
||||
name="docRemark"
|
||||
className="form-textarea w-full"
|
||||
placeholder="可输入文档的相关描述或备注信息"
|
||||
rows={2}
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">
|
||||
上传文档 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
|
||||
<UploadArea
|
||||
ref={uploadAreaRef}
|
||||
onFilesSelected={handleFilesSelected}
|
||||
accept=".pdf,.doc,.docx,.txt"
|
||||
multiple={true}
|
||||
icon="ri-upload-cloud-line"
|
||||
mainText="拖拽文件到此处或点击上传"
|
||||
tipText="支持 PDF、DOC、DOCX、TXT 格式文档,单个文件大小不超过50MB"
|
||||
disabled={uploading}
|
||||
/>
|
||||
|
||||
<div className="switch-container">
|
||||
<label className="switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isTestDocument}
|
||||
onChange={e => setIsTestDocument(e.target.checked)}
|
||||
/>
|
||||
<span className="slider"></span>
|
||||
</label>
|
||||
<span>标记为测试文档(不计入正式统计)</span>
|
||||
</div>
|
||||
|
||||
{files.length > 0 && (
|
||||
<div className="batch-actions">
|
||||
<div>
|
||||
<span className="text-sm">已选择 {selectedFileIds.length} 个文件</span>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
type="default"
|
||||
size="small"
|
||||
className="mr-2"
|
||||
onClick={removeSelectedFiles}
|
||||
disabled={selectedFileIds.length === 0 || uploading}
|
||||
>
|
||||
<i className="ri-delete-bin-line"></i> 删除选中
|
||||
</Button>
|
||||
<Button
|
||||
type="default"
|
||||
size="small"
|
||||
onClick={clearAllFiles}
|
||||
disabled={files.length === 0 || uploading}
|
||||
>
|
||||
<i className="ri-close-circle-line"></i> 清空列表
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="file-list">
|
||||
{files.map(file => (
|
||||
<div key={file.id} className="file-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedFileIds.includes(file.id)}
|
||||
onChange={e => toggleFileSelection(file.id, e.target.checked)}
|
||||
disabled={uploading || file.status === "uploading"}
|
||||
className="mr-3"
|
||||
/>
|
||||
|
||||
<FileTag
|
||||
extension={getFileExtension(file.name)}
|
||||
size="lg"
|
||||
className="mr-3"
|
||||
/>
|
||||
|
||||
<div className="file-info">
|
||||
<div className="file-name flex items-center">
|
||||
<span>{file.newName || file.name}</span>
|
||||
{file.status !== "uploading" && (
|
||||
<button
|
||||
type="button"
|
||||
className="ml-2 text-primary text-sm"
|
||||
onClick={() => {
|
||||
const fileName = file.name;
|
||||
const nameWithoutExt = fileName.substring(0, fileName.lastIndexOf('.'));
|
||||
const newName = prompt('编辑文件名', nameWithoutExt);
|
||||
if (newName) {
|
||||
updateFileName(file.id, newName);
|
||||
}
|
||||
}}
|
||||
disabled={uploading}
|
||||
>
|
||||
<i className="ri-edit-line"></i>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="file-meta">
|
||||
<span className="file-size">{formatFileSize(file.size)}</span>
|
||||
<span className={`file-status ${file.status === "error" ? "text-red-500" : ""}`}>
|
||||
{file.status === "waiting" && "等待上传"}
|
||||
{file.status === "uploading" && "上传中..."}
|
||||
{file.status === "success" && "上传成功"}
|
||||
{file.status === "error" && (
|
||||
<>
|
||||
{file.error}
|
||||
<button
|
||||
type="button"
|
||||
className="ml-2 text-primary text-xs"
|
||||
onClick={() => retryUpload(file.id)}
|
||||
>
|
||||
<i className="ri-refresh-line"></i> 重试
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="progress-bar">
|
||||
<div className="progress-bar-inner" style={{ width: `${file.progress}%` }}></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="file-actions">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
className="text-red-500"
|
||||
onClick={() => removeFile(file.id)}
|
||||
disabled={uploading || file.status === "uploading"}
|
||||
title="删除文件"
|
||||
>
|
||||
<i className="ri-delete-bin-line"></i>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="advanced-options">
|
||||
<div
|
||||
className={`advanced-options-toggle ${showAdvancedOptions ? 'open' : ''}`}
|
||||
onClick={() => setShowAdvancedOptions(!showAdvancedOptions)}
|
||||
>
|
||||
<span>高级上传设置</span>
|
||||
<i className="ri-arrow-down-s-line"></i>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="advanced-options-content"
|
||||
style={{ display: showAdvancedOptions ? 'block' : 'none' }}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="form-group">
|
||||
<label className="form-label" htmlFor="storageType">存储方式</label>
|
||||
<select
|
||||
id="storageType"
|
||||
name="storageType"
|
||||
className="form-select w-full"
|
||||
defaultValue="minio"
|
||||
>
|
||||
{STORAGE_TYPES.map(type => (
|
||||
<option key={type.id} value={type.id}>
|
||||
{type.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="form-tip">选择文档的存储位置</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label" htmlFor="afterUpload">上传后操作</label>
|
||||
<select
|
||||
id="afterUpload"
|
||||
name="afterUpload"
|
||||
className="form-select w-full"
|
||||
defaultValue="list"
|
||||
>
|
||||
{AFTER_UPLOAD_OPTIONS.map(option => (
|
||||
<option key={option.id} value={option.id}>
|
||||
{option.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="form-tip">上传完成后自动执行的操作</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
) : (
|
||||
<div className="upload-complete-actions" style={{ display: "block" }}>
|
||||
<Alert type="success" className="mb-4">
|
||||
所有文件上传成功!
|
||||
</Alert>
|
||||
|
||||
<div>
|
||||
<Button type="default" className="mr-2" onClick={resetForm}>
|
||||
<i className="ri-add-line"></i> 继续上传
|
||||
</Button>
|
||||
<Button to="/documents" type="default" className="mr-2">
|
||||
<i className="ri-list-check-line"></i> 查看文档列表
|
||||
</Button>
|
||||
<Button to="/documents/1?action=audit" type="primary">
|
||||
<i className="ri-play-circle-line"></i> 开始审核
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Outlet } from "@remix-run/react";
|
||||
|
||||
export default function Files() {
|
||||
return <Outlet />;
|
||||
}
|
||||
@@ -0,0 +1,697 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { MetaFunction, ActionFunctionArgs } from "@remix-run/node";
|
||||
import { Card } from "~/components/ui/Card";
|
||||
import { Button } from "~/components/ui/Button";
|
||||
import { Table } from "~/components/ui/Table";
|
||||
import { UploadArea, UploadAreaRef } from "~/components/ui/UploadArea";
|
||||
import { FileProgress} from "~/components/ui/FileProgress";
|
||||
import { ProcessingSteps, Step } from "~/components/ui/ProcessingSteps";
|
||||
import uploadStyles from "~/styles/pages/files_upload.css?url";
|
||||
|
||||
export function links() {
|
||||
return [
|
||||
{ rel: "stylesheet", href: uploadStyles }
|
||||
];
|
||||
}
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
{ title: "待审核文件上传 - 中国烟草AI合同及卷宗审核系统" },
|
||||
{
|
||||
name: "description",
|
||||
content: "上传待审核的合同文件、专卖许可证申请、行政处罚决定书等文档,进行AI智能审核"
|
||||
},
|
||||
{
|
||||
name: "keywords",
|
||||
content: "文件上传,合同审核,专卖许可证,行政处罚,AI审核,中国烟草"
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
// 文件类型定义
|
||||
export enum FileType {
|
||||
CONTRACT = "contract",
|
||||
LICENSE = "license",
|
||||
PUNISHMENT = "punishment",
|
||||
OTHER = "other"
|
||||
}
|
||||
|
||||
// 文件类型标签映射
|
||||
export const FILE_TYPE_LABELS: Record<FileType, string> = {
|
||||
[FileType.CONTRACT]: "合同文档",
|
||||
[FileType.LICENSE]: "专卖许可证",
|
||||
[FileType.PUNISHMENT]: "行政处罚决定书",
|
||||
[FileType.OTHER]: "其他文档"
|
||||
};
|
||||
|
||||
// 优先级定义
|
||||
export enum Priority {
|
||||
NORMAL = "normal",
|
||||
HIGH = "high",
|
||||
URGENT = "urgent"
|
||||
}
|
||||
|
||||
// 优先级标签映射
|
||||
export const PRIORITY_LABELS: Record<Priority, string> = {
|
||||
[Priority.NORMAL]: "普通",
|
||||
[Priority.HIGH]: "优先",
|
||||
[Priority.URGENT]: "紧急"
|
||||
};
|
||||
|
||||
// 处理状态定义
|
||||
export enum ProcessingStatus {
|
||||
WAITING = "waiting",
|
||||
PROCESSING = "processing",
|
||||
SUCCESS = "success",
|
||||
ERROR = "error"
|
||||
}
|
||||
|
||||
// 上传的文件信息接口
|
||||
export interface UploadedFile {
|
||||
id: string;
|
||||
name: string;
|
||||
size: number;
|
||||
type: string;
|
||||
fileType: FileType;
|
||||
priority: Priority;
|
||||
status: ProcessingStatus;
|
||||
uploadTime: string;
|
||||
processingInfo?: {
|
||||
progress: number;
|
||||
currentStep?: number;
|
||||
};
|
||||
}
|
||||
|
||||
// action处理文件上传请求
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
|
||||
// 由于无法直接从Remix的action中处理文件上传,
|
||||
// 实际环境中应使用FormData将文件发送到后端API
|
||||
// 这里我们模拟处理过程,创建一个响应对象
|
||||
|
||||
const fileType = formData.get("fileType") as FileType;
|
||||
const priority = formData.get("priority") as Priority;
|
||||
|
||||
if (!fileType) {
|
||||
return Response.json(
|
||||
{ success: false, error: "请选择文件类型" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 模拟文件上传成功响应
|
||||
return Response.json({
|
||||
success: true,
|
||||
message: "文件上传请求已接收",
|
||||
fileId: `file_${Date.now()}`,
|
||||
fileType,
|
||||
priority: priority || Priority.NORMAL,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("文件上传失败:", error);
|
||||
return Response.json(
|
||||
{ success: false, error: "文件上传失败,请重试" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 上传队列中的文件列表模拟数据
|
||||
const MOCK_QUEUE_FILES: UploadedFile[] = [
|
||||
{
|
||||
id: "1",
|
||||
name: "烟草产品销售合同(2023版).pdf",
|
||||
size: 5.2 * 1024 * 1024, // 5.2MB
|
||||
type: "application/pdf",
|
||||
fileType: FileType.CONTRACT,
|
||||
priority: Priority.NORMAL,
|
||||
status: ProcessingStatus.PROCESSING,
|
||||
uploadTime: "2023-10-25 14:25:18",
|
||||
processingInfo: {
|
||||
progress: 60,
|
||||
currentStep: 2
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "专卖许可证申请表.docx",
|
||||
size: 2.8 * 1024 * 1024, // 2.8MB
|
||||
type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
fileType: FileType.LICENSE,
|
||||
priority: Priority.HIGH,
|
||||
status: ProcessingStatus.WAITING,
|
||||
uploadTime: "2023-10-25 14:28:45"
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "XX公司违规处罚决定书.pdf",
|
||||
size: 3.1 * 1024 * 1024, // 3.1MB
|
||||
type: "application/pdf",
|
||||
fileType: FileType.PUNISHMENT,
|
||||
priority: Priority.NORMAL,
|
||||
status: ProcessingStatus.SUCCESS,
|
||||
uploadTime: "2023-10-25 14:15:30"
|
||||
}
|
||||
];
|
||||
|
||||
// 文件上传页面组件
|
||||
export default function FilesUpload() {
|
||||
// 状态管理
|
||||
const [fileType, setFileType] = useState<FileType | "">("");
|
||||
const [priority, setPriority] = useState<Priority>(Priority.NORMAL);
|
||||
const [currentFile, setCurrentFile] = useState<File | null>(null);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [uploadSpeed, setUploadSpeed] = useState("0KB/s");
|
||||
const [uploadStage, setUploadStage] = useState<"idle" | "uploading" | "processing" | "completed">("idle");
|
||||
const [processingSteps, setProcessingSteps] = useState<Step[]>([
|
||||
{ title: "文件上传", description: "等待上传文件到服务器...", status: "waiting" },
|
||||
{ title: "文档转换拆分", description: "转换文档格式,拆分文档内容", status: "waiting" },
|
||||
{ title: "评查点抽取", description: "DeepSeek 抽取中", status: "waiting" },
|
||||
{ title: "评查点审核", description: "DeepSeek 评查中", status: "waiting" },
|
||||
{ title: "审核准备", description: "文档已准备就绪,等待审核", status: "waiting" }
|
||||
]);
|
||||
|
||||
// 队列文件状态
|
||||
const [queueFiles, setQueueFiles] = useState<UploadedFile[]>(MOCK_QUEUE_FILES);
|
||||
|
||||
// 上传完成后的文件信息
|
||||
const [completedFile, setCompletedFile] = useState<UploadedFile | null>(null);
|
||||
|
||||
// 计时器引用
|
||||
const progressIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const processingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// UploadArea组件引用
|
||||
const uploadAreaRef = useRef<UploadAreaRef>(null);
|
||||
|
||||
// 清理定时器
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (progressIntervalRef.current) {
|
||||
clearInterval(progressIntervalRef.current);
|
||||
}
|
||||
if (processingIntervalRef.current) {
|
||||
clearInterval(processingIntervalRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 处理文件选择
|
||||
const handleFilesSelected = useCallback((selectedFiles: FileList) => {
|
||||
console.log("selectedFiles", selectedFiles);
|
||||
if (selectedFiles.length === 0) return;
|
||||
|
||||
if (!fileType) {
|
||||
alert("请先选择文件类型");
|
||||
// 重置文件输入框
|
||||
if (uploadAreaRef.current) {
|
||||
uploadAreaRef.current.resetFileInput();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setCurrentFile(selectedFiles[0]);
|
||||
startUpload(selectedFiles[0]);
|
||||
}, [fileType, currentFile]);
|
||||
|
||||
// 开始上传文件
|
||||
const startUpload = (file: File) => {
|
||||
setUploadStage("uploading");
|
||||
setUploadProgress(0);
|
||||
|
||||
// 更新步骤状态
|
||||
const updatedSteps = [...processingSteps];
|
||||
updatedSteps[0].status = "active";
|
||||
updatedSteps[0].description = `正在上传文件"${file.name}"到服务器...`;
|
||||
setProcessingSteps(updatedSteps);
|
||||
|
||||
// 模拟上传进度
|
||||
if (progressIntervalRef.current) {
|
||||
clearInterval(progressIntervalRef.current);
|
||||
}
|
||||
|
||||
progressIntervalRef.current = setInterval(() => {
|
||||
setUploadProgress(prev => {
|
||||
const newProgress = prev + 5;
|
||||
// 根据文件大小调整上传速度
|
||||
const speedFactor = Math.min(file.size / (1024 * 1024) + 1, 5);
|
||||
setUploadSpeed(`${Math.floor(Math.random() * 100 * speedFactor) + 50}KB/s`);
|
||||
|
||||
if (newProgress >= 100) {
|
||||
if (progressIntervalRef.current) {
|
||||
clearInterval(progressIntervalRef.current);
|
||||
setUploadSpeed("完成");
|
||||
|
||||
// 完成上传后开始处理流程
|
||||
startProcessing(file);
|
||||
}
|
||||
return 100;
|
||||
}
|
||||
|
||||
return newProgress;
|
||||
});
|
||||
}, 200);
|
||||
};
|
||||
|
||||
// 开始处理文件
|
||||
const startProcessing = (file: File) => {
|
||||
setUploadStage("processing");
|
||||
|
||||
// 更新步骤状态 - 将第一步标记为完成
|
||||
const updatedSteps = [...processingSteps];
|
||||
updatedSteps[0].status = "done";
|
||||
updatedSteps[0].description = "文件上传已完成";
|
||||
setProcessingSteps(updatedSteps);
|
||||
|
||||
let currentStepIndex = 1;
|
||||
|
||||
if (processingIntervalRef.current) {
|
||||
clearInterval(processingIntervalRef.current);
|
||||
}
|
||||
|
||||
processingIntervalRef.current = setInterval(() => {
|
||||
if (currentStepIndex >= processingSteps.length) {
|
||||
if (processingIntervalRef.current) {
|
||||
clearInterval(processingIntervalRef.current);
|
||||
completeProcessing(file);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 更新当前步骤为活动状态
|
||||
const updatedSteps = [...processingSteps];
|
||||
updatedSteps[currentStepIndex].status = "active";
|
||||
setProcessingSteps(updatedSteps);
|
||||
|
||||
// 2.5秒后标记当前步骤为完成,进入下一步骤
|
||||
setTimeout(() => {
|
||||
const nextUpdatedSteps = [...processingSteps];
|
||||
nextUpdatedSteps[currentStepIndex].status = "done";
|
||||
|
||||
// 更新完成状态的描述
|
||||
switch(currentStepIndex) {
|
||||
case 1:
|
||||
nextUpdatedSteps[currentStepIndex].description = "转换文档格式并拆分文档完成";
|
||||
break;
|
||||
case 2:
|
||||
nextUpdatedSteps[currentStepIndex].description = "DeepSeek 抽取已完成";
|
||||
break;
|
||||
case 3:
|
||||
nextUpdatedSteps[currentStepIndex].description = "DeepSeek 评查已完成";
|
||||
break;
|
||||
case 4:
|
||||
nextUpdatedSteps[currentStepIndex].description = "审核准备已就绪";
|
||||
break;
|
||||
}
|
||||
|
||||
setProcessingSteps(nextUpdatedSteps);
|
||||
currentStepIndex++;
|
||||
|
||||
// 如果这是最后一个步骤,确保完成
|
||||
// if (currentStepIndex >= processingSteps.length) {
|
||||
// setTimeout(() => {
|
||||
// completeProcessing();
|
||||
// }, 1000);
|
||||
// }
|
||||
}, 2000);
|
||||
|
||||
}, 2500);
|
||||
};
|
||||
|
||||
// 完成处理流程
|
||||
const completeProcessing = (file: File) => {
|
||||
// 设置当前状态为已完成
|
||||
setUploadStage("completed");
|
||||
|
||||
// 创建完成的文件对象
|
||||
if (file) {
|
||||
console.log("创建完成的文件对象...");
|
||||
const newFile: UploadedFile = {
|
||||
id: `file_${Date.now()}`,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
fileType: fileType as FileType,
|
||||
priority,
|
||||
status: ProcessingStatus.SUCCESS,
|
||||
uploadTime: getCurrentTime()
|
||||
};
|
||||
|
||||
setCompletedFile(newFile);
|
||||
console.log("完成文件设置:", newFile);
|
||||
|
||||
// 添加到队列中
|
||||
setQueueFiles(prev => [newFile, ...prev]);
|
||||
} else {
|
||||
console.log("没有当前文件");
|
||||
}
|
||||
};
|
||||
|
||||
// 重置上传状态
|
||||
const resetUpload = () => {
|
||||
setUploadStage("idle");
|
||||
setUploadProgress(0);
|
||||
setUploadSpeed("0KB/s");
|
||||
setCurrentFile(null);
|
||||
setCompletedFile(null);
|
||||
|
||||
// 重置步骤状态
|
||||
const resetSteps = processingSteps.map(step => ({
|
||||
...step,
|
||||
status: "waiting" as Step["status"]
|
||||
}));
|
||||
resetSteps[0].description = "等待上传文件到服务器...";
|
||||
resetSteps[1].description = "转换文档格式,拆分文档内容";
|
||||
resetSteps[2].description = "DeepSeek 抽取中";
|
||||
resetSteps[3].description = "DeepSeek 评查中";
|
||||
resetSteps[4].description = "文档已准备就绪,等待审核";
|
||||
|
||||
setProcessingSteps(resetSteps);
|
||||
};
|
||||
|
||||
// 获取当前时间字符串
|
||||
const getCurrentTime = () => {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(now.getDate()).padStart(2, '0');
|
||||
const hours = String(now.getHours()).padStart(2, '0');
|
||||
const minutes = String(now.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(now.getSeconds()).padStart(2, '0');
|
||||
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||
};
|
||||
|
||||
// 格式化文件大小显示
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
// 表格列定义
|
||||
const columns = [
|
||||
{
|
||||
title: "文件名",
|
||||
key: "name",
|
||||
width: "40%",
|
||||
render: (_: unknown, record: UploadedFile) => (
|
||||
<div className="flex items-center">
|
||||
<i className={`${record.type.includes('pdf') ? 'ri-file-pdf-line text-red-500' : 'ri-file-word-2-line text-blue-500'} mr-2 text-lg`}></i>
|
||||
<span className="truncate">{record.name}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: "文件类型",
|
||||
key: "fileType",
|
||||
width: "15%",
|
||||
render: (_: unknown, record: UploadedFile) => {
|
||||
let typeClass = "";
|
||||
let typeIcon = "";
|
||||
|
||||
switch(record.fileType) {
|
||||
case FileType.CONTRACT:
|
||||
typeClass = "file-type-contract";
|
||||
typeIcon = "ri-file-list-3-line";
|
||||
break;
|
||||
case FileType.LICENSE:
|
||||
typeClass = "file-type-license";
|
||||
typeIcon = "ri-vip-crown-line";
|
||||
break;
|
||||
case FileType.PUNISHMENT:
|
||||
typeClass = "file-type-punishment";
|
||||
typeIcon = "ri-scales-line";
|
||||
break;
|
||||
default:
|
||||
typeClass = "file-type-contract";
|
||||
typeIcon = "ri-file-list-3-line";
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={`file-type-badge ${typeClass}`}>
|
||||
<i className={typeIcon}></i>
|
||||
{FILE_TYPE_LABELS[record.fileType]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
title: "大小",
|
||||
key: "size",
|
||||
width: "15%",
|
||||
render: (_: unknown, record: UploadedFile) => formatFileSize(record.size)
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
key: "status",
|
||||
width: "15%",
|
||||
render: (_: unknown, record: UploadedFile) => {
|
||||
let statusClass = "";
|
||||
let statusIcon = "";
|
||||
let statusText = "";
|
||||
|
||||
switch(record.status) {
|
||||
case ProcessingStatus.WAITING:
|
||||
statusClass = "status-waiting";
|
||||
statusIcon = "ri-time-line";
|
||||
statusText = "等待中";
|
||||
break;
|
||||
case ProcessingStatus.PROCESSING:
|
||||
statusClass = "status-processing";
|
||||
statusIcon = "ri-loader-4-line";
|
||||
statusText = "解析中";
|
||||
break;
|
||||
case ProcessingStatus.SUCCESS:
|
||||
statusClass = "status-success";
|
||||
statusIcon = "ri-checkbox-circle-line";
|
||||
statusText = "已完成";
|
||||
break;
|
||||
case ProcessingStatus.ERROR:
|
||||
statusClass = "status-error";
|
||||
statusIcon = "ri-error-warning-line";
|
||||
statusText = "失败";
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={`status-badge ${statusClass}`}>
|
||||
<i className={`${statusIcon} mr-1`}></i>
|
||||
{statusText}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "operation",
|
||||
width: "15%",
|
||||
render: (_: unknown, record: UploadedFile) => (
|
||||
<Button
|
||||
className={record.status !== ProcessingStatus.SUCCESS ? "" : "hover:border-green-700 hover:text-green-700"}
|
||||
type="default"
|
||||
size="small"
|
||||
disabled={record.status !== ProcessingStatus.SUCCESS}
|
||||
icon="ri-eye-line"
|
||||
onClick={() => alert(`查看文件详情: ${record.name}`)}
|
||||
>
|
||||
查看
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="file-upload-page">
|
||||
{/* 页面头部 */}
|
||||
<div className="page-header">
|
||||
<h2 className="page-title">待审核文件上传</h2>
|
||||
</div>
|
||||
|
||||
{/* 文件类型选择 */}
|
||||
<Card title={<h3>选择文件类型</h3>} className="mb-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="form-group">
|
||||
<label htmlFor="file-type-select" className="form-label">文件类型 <span className="text-red-500">*</span></label>
|
||||
<select
|
||||
id="file-type-select"
|
||||
className="form-select"
|
||||
value={fileType}
|
||||
onChange={(e) => setFileType(e.target.value as FileType)}
|
||||
disabled={uploadStage !== "idle"}
|
||||
>
|
||||
<option value="">请选择文件类型</option>
|
||||
<option value={FileType.CONTRACT}>合同文档</option>
|
||||
<option value={FileType.LICENSE}>专卖许可证</option>
|
||||
<option value={FileType.PUNISHMENT}>行政处罚决定书</option>
|
||||
<option value={FileType.OTHER}>其他文档</option>
|
||||
</select>
|
||||
<div className="form-tip">不同类型的文档将应用不同的审核规则</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="priority-select" className="form-label">审核优先级</label>
|
||||
<select
|
||||
id="priority-select"
|
||||
className="form-select"
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(e.target.value as Priority)}
|
||||
disabled={uploadStage !== "idle"}
|
||||
>
|
||||
<option value={Priority.NORMAL}>普通</option>
|
||||
<option value={Priority.HIGH}>优先</option>
|
||||
<option value={Priority.URGENT}>紧急</option>
|
||||
</select>
|
||||
<div className="form-tip">优先级影响文档在队列中的处理顺序</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 文件上传区域 */}
|
||||
<Card title={<h3>文件上传</h3>} className="mb-4">
|
||||
{/* 初始上传区域 */}
|
||||
{uploadStage === "idle" && (
|
||||
<UploadArea
|
||||
ref={uploadAreaRef}
|
||||
onFilesSelected={handleFilesSelected}
|
||||
multiple={false}
|
||||
accept=".pdf,.doc,.docx,.xls,.xlsx,.jpg,.jpeg,.png"
|
||||
tipText="支持单个或批量上传,文件格式:PDF、Word、Excel、图片"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 上传进度显示 */}
|
||||
{uploadStage !== "completed" && currentFile && (
|
||||
<FileProgress
|
||||
fileName={currentFile.name}
|
||||
fileSize={formatFileSize(currentFile.size)}
|
||||
progress={uploadProgress}
|
||||
speed={uploadSpeed}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 处理步骤显示 */}
|
||||
{(uploadStage === "processing" || uploadStage === "completed") && (
|
||||
<div className="mt-4 mb-4">
|
||||
<ProcessingSteps steps={processingSteps} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 完成后的文件信息 */}
|
||||
{uploadStage === "completed" && completedFile && (
|
||||
<div className="mt-6">
|
||||
<div className="bg-green-50 p-4 rounded-md mb-4 border border-green-100">
|
||||
<div className="flex items-center text-green-800 mb-2">
|
||||
<i className="ri-checkbox-circle-line text-xl mr-2"></i>
|
||||
<span className="font-medium">评查成功</span>
|
||||
</div>
|
||||
<p className="text-sm text-green-700">文件已成功上传并评查完成,请查看结果</p>
|
||||
</div>
|
||||
|
||||
<div className="file-info-grid">
|
||||
<div>
|
||||
<h4 className="font-medium mb-3">文件信息</h4>
|
||||
<ul className="file-info-list">
|
||||
<li className="file-info-item">
|
||||
<span className="file-info-label">文件名:</span>
|
||||
<span className="file-info-value">{completedFile.name}</span>
|
||||
</li>
|
||||
<li className="file-info-item">
|
||||
<span className="file-info-label">文件大小:</span>
|
||||
<span className="file-info-value">{formatFileSize(completedFile.size)}</span>
|
||||
</li>
|
||||
<li className="file-info-item">
|
||||
<span className="file-info-label">上传时间:</span>
|
||||
<span className="file-info-value">{completedFile.uploadTime}</span>
|
||||
</li>
|
||||
<li className="file-info-item">
|
||||
<span className="file-info-label">文件类型:</span>
|
||||
<span className="file-info-value">{FILE_TYPE_LABELS[completedFile.fileType]}</span>
|
||||
</li>
|
||||
<li className="file-info-item">
|
||||
<span className="file-info-label">审核规则:</span>
|
||||
<span className="file-info-value">系统自动选择</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-medium mb-3">解析结果预览</h4>
|
||||
<div className="bg-gray-50 p-3 rounded-md border border-gray-200">
|
||||
<div className="mb-2">
|
||||
<span className="text-gray-500 text-sm">合同编号:</span>
|
||||
<span className="pulse-animation">XS-2023-1025-001</span>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<span className="text-gray-500 text-sm">合同名称:</span>
|
||||
<span className="pulse-animation">烟草制品销售合同</span>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<span className="text-gray-500 text-sm">签约日期:</span>
|
||||
<span className="pulse-animation">2023年10月20日</span>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<span className="text-gray-500 text-sm">合同金额:</span>
|
||||
<span className="pulse-animation">¥ 1,580,000.00</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500 text-sm">当事人:</span>
|
||||
<span className="pulse-animation">甲方:XX烟草公司,乙方:YY贸易有限公司</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-between">
|
||||
<Button
|
||||
type="default"
|
||||
icon="ri-refresh-line"
|
||||
onClick={resetUpload}
|
||||
>
|
||||
上传新文件
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon="ri-file-search-line"
|
||||
>
|
||||
查看详情并审核
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 上传队列 */}
|
||||
<Card
|
||||
title={
|
||||
<div className="flex justify-between items-center">
|
||||
<h3>上传队列</h3>
|
||||
<span className="text-gray-500 text-sm">共 {queueFiles.length} 个文件</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={queueFiles}
|
||||
rowKey="id"
|
||||
emptyText="暂无上传文件"
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorBoundary() {
|
||||
return (
|
||||
<div className="error-container p-6">
|
||||
<h1 className="text-xl font-bold text-red-500 mb-4">出错了</h1>
|
||||
<p className="mb-4">文件上传页面加载失败。请刷新页面或联系系统管理员。</p>
|
||||
<Button type="primary" to="/">返回首页</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,306 +0,0 @@
|
||||
import React from 'react';
|
||||
import { json, type MetaFunction } from '@remix-run/node';
|
||||
import { useLoaderData, useSearchParams, Form } from '@remix-run/react';
|
||||
import { Button } from '~/components/ui/Button';
|
||||
import { Card } from '~/components/ui/Card';
|
||||
import { Table } from '~/components/ui/Table';
|
||||
import { Breadcrumb } from '~/components/layout/Breadcrumb';
|
||||
import type { File } from '~/models/file';
|
||||
import { REVIEW_STATUS_LABELS, REVIEW_STATUS_COLORS } from '~/models/file';
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
{ title: "中国烟草AI合同及卷宗审核系统 - 文件列表" },
|
||||
{ name: "description", content: "评查文件列表" }
|
||||
];
|
||||
};
|
||||
|
||||
export const handle = {
|
||||
breadcrumb: '文件列表'
|
||||
};
|
||||
|
||||
interface LoaderData {
|
||||
files: File[];
|
||||
documentTypes: {
|
||||
id: string;
|
||||
name: string;
|
||||
}[];
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
export async function loader({ request }) {
|
||||
const url = new URL(request.url);
|
||||
const documentTypeId = url.searchParams.get("documentTypeId") || "";
|
||||
const reviewStatus = url.searchParams.get("reviewStatus") || "";
|
||||
const keyword = url.searchParams.get("keyword") || "";
|
||||
|
||||
// 模拟数据,实际项目中应从API获取
|
||||
const files: File[] = [
|
||||
{
|
||||
id: "1",
|
||||
fileName: "2023年度烟草专卖零售许可证.pdf",
|
||||
fileType: "application/pdf",
|
||||
documentTypeId: "2",
|
||||
documentTypeName: "专卖许可证",
|
||||
fileSize: 1024 * 1024 * 2.5, // 2.5MB
|
||||
uploaderId: "1",
|
||||
uploaderName: "张三",
|
||||
status: "completed",
|
||||
reviewStatus: "pass",
|
||||
createdAt: "2023-12-24 14:30",
|
||||
updatedAt: "2023-12-24 16:45"
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
fileName: "烟草制品购销合同(2023-12).docx",
|
||||
fileType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
documentTypeId: "1",
|
||||
documentTypeName: "合同文档",
|
||||
fileSize: 1024 * 1024 * 1.2, // 1.2MB
|
||||
uploaderId: "1",
|
||||
uploaderName: "张三",
|
||||
status: "completed",
|
||||
reviewStatus: "warning",
|
||||
createdAt: "2023-12-23 09:15",
|
||||
updatedAt: "2023-12-23 10:30"
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
fileName: "专卖管理处罚决定书(2023-145).pdf",
|
||||
fileType: "application/pdf",
|
||||
documentTypeId: "3",
|
||||
documentTypeName: "行政处罚决定书",
|
||||
fileSize: 1024 * 1024 * 3.1, // 3.1MB
|
||||
uploaderId: "2",
|
||||
uploaderName: "李四",
|
||||
status: "completed",
|
||||
reviewStatus: "fail",
|
||||
createdAt: "2023-12-22 16:45",
|
||||
updatedAt: "2023-12-22 18:20"
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
fileName: "2023年第四季度采购合同.docx",
|
||||
fileType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
documentTypeId: "1",
|
||||
documentTypeName: "合同文档",
|
||||
fileSize: 1024 * 1024 * 1.8, // 1.8MB
|
||||
uploaderId: "3",
|
||||
uploaderName: "王五",
|
||||
status: "completed",
|
||||
reviewStatus: "pass",
|
||||
createdAt: "2023-12-20 11:20",
|
||||
updatedAt: "2023-12-20 14:35"
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
fileName: "广告宣传协议书.pdf",
|
||||
fileType: "application/pdf",
|
||||
documentTypeId: "1",
|
||||
documentTypeName: "合同文档",
|
||||
fileSize: 1024 * 1024 * 0.9, // 0.9MB
|
||||
uploaderId: "2",
|
||||
uploaderName: "李四",
|
||||
status: "pending",
|
||||
reviewStatus: "pending",
|
||||
createdAt: "2023-12-18 15:30",
|
||||
updatedAt: "2023-12-18 15:30"
|
||||
}
|
||||
];
|
||||
|
||||
const documentTypes = [
|
||||
{ id: "1", name: "合同文档" },
|
||||
{ id: "2", name: "专卖许可证" },
|
||||
{ id: "3", name: "行政处罚决定书" },
|
||||
{ id: "4", name: "其他文档" }
|
||||
];
|
||||
|
||||
// 过滤数据
|
||||
let filteredFiles = [...files];
|
||||
|
||||
if (documentTypeId) {
|
||||
filteredFiles = filteredFiles.filter(file => file.documentTypeId === documentTypeId);
|
||||
}
|
||||
|
||||
if (reviewStatus) {
|
||||
filteredFiles = filteredFiles.filter(file => file.reviewStatus === reviewStatus);
|
||||
}
|
||||
|
||||
if (keyword) {
|
||||
const lowerKeyword = keyword.toLowerCase();
|
||||
filteredFiles = filteredFiles.filter(file =>
|
||||
file.fileName.toLowerCase().includes(lowerKeyword)
|
||||
);
|
||||
}
|
||||
|
||||
return json<LoaderData>({
|
||||
files: filteredFiles,
|
||||
documentTypes,
|
||||
totalCount: files.length
|
||||
});
|
||||
}
|
||||
|
||||
export default function FilesList() {
|
||||
const { files, documentTypes } = useLoaderData<typeof loader>();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
// 文件大小格式化
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
};
|
||||
|
||||
// 获取文件图标
|
||||
const getFileIcon = (fileType: string): string => {
|
||||
if (fileType.includes('pdf')) return 'ri-file-pdf-line';
|
||||
if (fileType.includes('word')) return 'ri-file-word-line';
|
||||
if (fileType.includes('excel') || fileType.includes('spreadsheet')) return 'ri-file-excel-line';
|
||||
if (fileType.includes('image')) return 'ri-file-image-line';
|
||||
return 'ri-file-text-line';
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Breadcrumb
|
||||
items={[
|
||||
{ title: '文件管理', to: '/files' },
|
||||
{ title: '文件列表', to: '/files' }
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<div className="flex items-center">
|
||||
<h2 className="text-xl font-medium">评查文件列表</h2>
|
||||
<div className="flex items-center ml-4 bg-white px-3 py-1 rounded-md">
|
||||
<i className="ri-file-list-3-line text-primary text-lg mr-1"></i>
|
||||
<span className="text-sm text-secondary">总文件数:</span>
|
||||
<span className="text-base font-bold text-primary ml-1">{files.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="primary" icon="ri-file-upload-line" to="/files/new">
|
||||
上传新文件
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="mb-5">
|
||||
<Form method="get" className="flex flex-wrap items-end gap-3">
|
||||
<div className="w-48">
|
||||
<label className="form-label">文件类型</label>
|
||||
<select
|
||||
name="documentTypeId"
|
||||
className="form-select w-full"
|
||||
defaultValue={searchParams.get('documentTypeId') || ''}
|
||||
>
|
||||
<option value="">全部</option>
|
||||
{documentTypes.map(type => (
|
||||
<option key={type.id} value={type.id}>{type.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="w-48">
|
||||
<label className="form-label">评查状态</label>
|
||||
<select
|
||||
name="reviewStatus"
|
||||
className="form-select w-full"
|
||||
defaultValue={searchParams.get('reviewStatus') || ''}
|
||||
>
|
||||
<option value="">全部</option>
|
||||
{Object.entries(REVIEW_STATUS_LABELS).map(([value, label]) => (
|
||||
<option key={value} value={value}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="w-64">
|
||||
<label className="form-label">文件名称</label>
|
||||
<div className="search-box">
|
||||
<input
|
||||
type="text"
|
||||
name="keyword"
|
||||
className="form-input"
|
||||
placeholder="搜索文件名称"
|
||||
defaultValue={searchParams.get('keyword') || ''}
|
||||
/>
|
||||
<button type="submit" className="ant-btn ant-btn-primary">
|
||||
<i className="ri-search-line"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="default" className="ml-2">重置</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Table
|
||||
columns={[
|
||||
{
|
||||
title: "文件名称",
|
||||
render: (_, record: File) => (
|
||||
<div className="flex items-center">
|
||||
<i className={`${getFileIcon(record.fileType)} text-lg text-gray-500 mr-2`}></i>
|
||||
<div>
|
||||
<div className="font-medium">{record.fileName}</div>
|
||||
<div className="text-xs text-gray-500">{formatFileSize(record.fileSize)}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{ title: "文档类型", dataIndex: "documentTypeName" },
|
||||
{
|
||||
title: "评查状态",
|
||||
dataIndex: "reviewStatus",
|
||||
render: (value) => (
|
||||
<span className={`status-badge status-${REVIEW_STATUS_COLORS[value]}`}>
|
||||
<i className={`ri-${value === 'pass' ? 'checkbox-circle' : value === 'warning' ? 'error-warning' : value === 'fail' ? 'close-circle' : 'time'}-line mr-1`}></i>
|
||||
{REVIEW_STATUS_LABELS[value]}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: "上传人",
|
||||
dataIndex: "uploaderName"
|
||||
},
|
||||
{
|
||||
title: "上传时间",
|
||||
dataIndex: "createdAt"
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
render: (_, record: File) => (
|
||||
<div className="space-x-2">
|
||||
<Button
|
||||
type="default"
|
||||
size="small"
|
||||
icon="ri-file-search-line"
|
||||
to={`/reviews/${record.id}`}
|
||||
>
|
||||
查看
|
||||
</Button>
|
||||
{record.status === 'pending' && (
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon="ri-play-circle-line"
|
||||
>
|
||||
开始评查
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="danger"
|
||||
size="small"
|
||||
icon="ri-delete-bin-line"
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
]}
|
||||
dataSource={files}
|
||||
rowKey="id"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { MetaFunction } from '@remix-run/node';
|
||||
import { Card } from '~/components/ui/Card';
|
||||
import { Button } from '~/components/ui/Button';
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
{ title: "文件上传 - 中国烟草AI合同及卷宗审核系统" },
|
||||
{ name: "description", content: "上传文件进行智能评查" }
|
||||
];
|
||||
};
|
||||
|
||||
export default function FilesNew() {
|
||||
return (
|
||||
<div className="p-6">
|
||||
{/* 页面标识 */}
|
||||
<div className="mb-4 p-3 bg-blue-100 border border-blue-300 rounded text-blue-800">
|
||||
<h3 className="font-bold text-lg">当前页面: 文件上传 (files/new.tsx)</h3>
|
||||
<p>如果你看到这个提示,说明你正在浏览文件上传页面,路由已成功加载。</p>
|
||||
<div className="mt-2">
|
||||
<a href="/" className="text-blue-600 hover:underline">返回首页</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card title="文件上传" icon="ri-upload-cloud-line" className="mt-6">
|
||||
<div className="flex flex-col items-center justify-center p-6 border-2 border-dashed border-gray-300 rounded-lg bg-gray-50">
|
||||
<i className="ri-upload-cloud-line text-5xl text-gray-400 mb-4"></i>
|
||||
<p className="text-lg mb-4">拖拽文件到此处或点击上传</p>
|
||||
<Button type="primary" icon="ri-upload-line">选择文件</Button>
|
||||
<p className="text-gray-500 mt-3">支持 PDF、DOC、DOCX、XLS、XLSX 等格式</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
import { MetaFunction } from "@remix-run/node";
|
||||
import { useSearchParams, useNavigate } from "@remix-run/react";
|
||||
import indexStyles from "~/styles/pages/prompts_index.css?url";
|
||||
import { Card } from "~/components/ui/Card";
|
||||
import { Button } from "~/components/ui/Button";
|
||||
import { FilterPanel, FilterSelect, SearchFilter } from "~/components/ui/FilterPanel";
|
||||
import { Table } from "~/components/ui/Table";
|
||||
import { Pagination } from "~/components/ui/Pagination";
|
||||
|
||||
// 定义提示词模板类型
|
||||
export interface PromptTemplate {
|
||||
id: string;
|
||||
template_name: string;
|
||||
template_type: 'Common' | 'Extraction' | 'Evaluation' | 'Summary';
|
||||
description: string;
|
||||
version: string;
|
||||
status: 'active' | 'inactive' | 'system';
|
||||
created_by: string;
|
||||
template_content: string;
|
||||
variables: string; // JSON字符串
|
||||
}
|
||||
|
||||
// 样式链接
|
||||
export function links() {
|
||||
return [{ rel: "stylesheet", href: indexStyles }];
|
||||
}
|
||||
|
||||
// 页面元数据
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
{ title: "提示词模板管理 - 中国烟草AI合同及卷宗审核系统" },
|
||||
{ name: "description", content: "管理提示词模板,包括创建、编辑和删除提示词模板" },
|
||||
];
|
||||
};
|
||||
|
||||
|
||||
// 模拟数据
|
||||
const MOCK_TEMPLATES: PromptTemplate[] = [
|
||||
{
|
||||
id: "1",
|
||||
template_name: "行政处罚-抽取通用模板",
|
||||
template_type: "Extraction",
|
||||
description: "本模板用于抽取行政处罚决定书编号等信息",
|
||||
version: "v1.0",
|
||||
status: "system",
|
||||
created_by: "system",
|
||||
template_content: `你是一个专业的文档信息抽取助手。请从以下{docType}文档中抽取关键信息:
|
||||
1. 处罚决定书编号
|
||||
2. 处罚对象名称
|
||||
3. 处罚事由
|
||||
4. 处罚依据
|
||||
5. 处罚内容
|
||||
6. 处罚金额
|
||||
7. 发文日期
|
||||
请将结果以JSON格式输出,包含以上字段。如果某个字段在文档中未找到,则该字段的值设为null。`,
|
||||
variables: JSON.stringify({ "docType": "文档类型" })
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
template_name: "销售合同-甲方信息评估",
|
||||
template_type: "Evaluation",
|
||||
description: "评估销售合同中甲方信息是否完整",
|
||||
version: "v1.2",
|
||||
status: "active",
|
||||
created_by: "admin",
|
||||
template_content: `你是一个专业的合同审核助手。请评估以下{docType}中甲方信息的完整性:
|
||||
请检查以下要素是否存在且完整:
|
||||
1. 甲方全称
|
||||
2. 注册地址
|
||||
3. 统一社会信用代码
|
||||
4. 法定代表人
|
||||
5. 联系方式
|
||||
请给出评估结果,并标明缺失或不完整的信息。`,
|
||||
variables: JSON.stringify({ "docType": "文档类型" })
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
template_name: "专卖许可证-摘要模板",
|
||||
template_type: "Summary",
|
||||
description: "生成专卖许可证申请文件的内容摘要",
|
||||
version: "v1.0",
|
||||
status: "active",
|
||||
created_by: "admin",
|
||||
template_content: `你是一个专业的文档摘要助手。请为以下{docType}生成一份简洁的摘要:
|
||||
摘要应包含以下要点:
|
||||
1. 申请人基本信息
|
||||
2. 许可证类型
|
||||
3. 申请事项
|
||||
4. 经营范围
|
||||
5. 申请日期
|
||||
请控制摘要在200字以内,保留关键信息。`,
|
||||
variables: JSON.stringify({ "docType": "文档类型" })
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
template_name: "采购合同-乙方资质抽取",
|
||||
template_type: "Extraction",
|
||||
description: "抽取采购合同中乙方的资质信息",
|
||||
version: "v1.1",
|
||||
status: "inactive",
|
||||
created_by: "zhangsan",
|
||||
template_content: `你是一个专业的合同信息抽取助手。请从以下{docType}中抽取乙方的资质信息:
|
||||
需要抽取的信息包括:
|
||||
1. 乙方全称
|
||||
2. 资质证书类型
|
||||
3. 资质证书编号
|
||||
4. 资质等级
|
||||
5. 证书有效期
|
||||
请将结果以JSON格式输出,包含以上字段。`,
|
||||
variables: JSON.stringify({ "docType": "文档类型" })
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
template_name: "合同通用-关键条款评估",
|
||||
template_type: "Evaluation",
|
||||
description: "评估合同中关键条款是否明确、合规",
|
||||
version: "v2.0",
|
||||
status: "active",
|
||||
created_by: "lisi",
|
||||
template_content: `你是一个专业的{industry}行业合同审核助手。请评估以下合同中的关键条款是否明确、合规:
|
||||
请重点关注以下条款:
|
||||
1. 合同标的
|
||||
2. 价格条款
|
||||
3. 付款条件
|
||||
4. 交付方式
|
||||
5. 违约责任
|
||||
6. 争议解决
|
||||
请对每一项给出评估结果,并指出不明确或存在风险的条款。`,
|
||||
variables: JSON.stringify({ "industry": "行业类型", "docType": "文档类型" })
|
||||
}
|
||||
];
|
||||
|
||||
// 数据加载器
|
||||
export async function loader() {
|
||||
try {
|
||||
// 实际应用中,这里应该调用API获取数据
|
||||
// const response = await fetch(`${process.env.API_BASE_URL}/api/prompt-templates`);
|
||||
// if (!response.ok) throw new Error(`获取提示词模板失败: ${response.status}`);
|
||||
// const templates = await response.json();
|
||||
|
||||
// 使用模拟数据
|
||||
const templates = MOCK_TEMPLATES;
|
||||
|
||||
return Response.json({
|
||||
templates,
|
||||
total: templates.length,
|
||||
pageSize: 10,
|
||||
currentPage: 1
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("加载提示词模板失败:", error);
|
||||
return Response.json(
|
||||
{
|
||||
templates: [],
|
||||
total: 0,
|
||||
pageSize: 10,
|
||||
currentPage: 1,
|
||||
error: "加载提示词模板失败"
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 页面组件
|
||||
export default function PromptsIndex() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
// 处理搜索名称
|
||||
const handleNameSearch = (value: string) => {
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
if (value) {
|
||||
newParams.set('name', value);
|
||||
} else {
|
||||
newParams.delete('name');
|
||||
}
|
||||
newParams.set('page', '1');
|
||||
setSearchParams(newParams);
|
||||
};
|
||||
|
||||
// 处理类型筛选变更
|
||||
const handleTypeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const { value } = e.target;
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
if (value) {
|
||||
newParams.set('type', value);
|
||||
} else {
|
||||
newParams.delete('type');
|
||||
}
|
||||
newParams.set('page', '1');
|
||||
setSearchParams(newParams);
|
||||
};
|
||||
|
||||
// 处理状态筛选变更
|
||||
const handleStatusChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const { value } = e.target;
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
if (value) {
|
||||
newParams.set('status', value);
|
||||
} else {
|
||||
newParams.delete('status');
|
||||
}
|
||||
newParams.set('page', '1');
|
||||
setSearchParams(newParams);
|
||||
};
|
||||
|
||||
// 处理重置筛选
|
||||
const handleReset = () => {
|
||||
setSearchParams(new URLSearchParams());
|
||||
};
|
||||
|
||||
// 查看模板详情
|
||||
const handleViewTemplate = (id: string) => {
|
||||
navigate(`/prompts/new?id=${id}&mode=view`);
|
||||
};
|
||||
|
||||
// 复制模板
|
||||
const handleCloneTemplate = (id: string) => {
|
||||
if (confirm('确定要复制该模板创建新模板吗?')) {
|
||||
navigate(`/prompts/new?id=${id}&mode=clone`);
|
||||
}
|
||||
};
|
||||
|
||||
// 编辑模板
|
||||
const handleEditTemplate = (id: string) => {
|
||||
navigate(`/prompts/new?id=${id}&mode=edit`);
|
||||
};
|
||||
|
||||
// 删除模板
|
||||
const handleDeleteTemplate = (id: string) => {
|
||||
if (confirm('确定要删除该模板吗?删除后无法恢复。')) {
|
||||
// 实际应该调用API删除数据
|
||||
console.log('删除模板ID:', id);
|
||||
alert('删除成功!');
|
||||
// 刷新页面
|
||||
window.location.reload();
|
||||
}
|
||||
};
|
||||
|
||||
// 定义表格列配置
|
||||
const columns = [
|
||||
{
|
||||
title: "模板名称",
|
||||
key: "template_name",
|
||||
width: "300px",
|
||||
render: (_: unknown, record: PromptTemplate) => (
|
||||
<div className="flex items-center">
|
||||
<i className="ri-file-list-line text-primary mr-2"></i>
|
||||
<span className="truncate">{record.template_name}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: "类型",
|
||||
key: "template_type",
|
||||
width: "100px",
|
||||
render: (_: unknown, record: PromptTemplate) => {
|
||||
let typeText = '';
|
||||
let typeClass = '';
|
||||
|
||||
switch (record.template_type) {
|
||||
case 'Extraction':
|
||||
typeText = '抽取';
|
||||
typeClass = 'type-extraction';
|
||||
break;
|
||||
case 'Evaluation':
|
||||
typeText = '评估';
|
||||
typeClass = 'type-evaluation';
|
||||
break;
|
||||
case 'Summary':
|
||||
typeText = '摘要';
|
||||
typeClass = 'type-summary';
|
||||
break;
|
||||
case 'Common':
|
||||
typeText = '通用';
|
||||
typeClass = 'type-common';
|
||||
break;
|
||||
}
|
||||
|
||||
return <span className={`type-badge ${typeClass}`}>{typeText}</span>;
|
||||
}
|
||||
},
|
||||
{
|
||||
title: "描述",
|
||||
key: "description",
|
||||
render: (_: unknown, record: PromptTemplate) => (
|
||||
<div className="text-secondary text-sm truncate max-w-xs" title={record.description}>
|
||||
{record.description}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: "版本",
|
||||
key: "version",
|
||||
width: "80px",
|
||||
render: (_: unknown, record: PromptTemplate) => record.version
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
key: "status",
|
||||
width: "80px",
|
||||
render: (_: unknown, record: PromptTemplate) => {
|
||||
let statusText = '';
|
||||
let statusClass = '';
|
||||
|
||||
switch (record.status) {
|
||||
case 'active':
|
||||
statusText = '启用';
|
||||
statusClass = 'status-active';
|
||||
break;
|
||||
case 'inactive':
|
||||
statusText = '停用';
|
||||
statusClass = 'status-inactive';
|
||||
break;
|
||||
case 'system':
|
||||
statusText = '系统预设';
|
||||
statusClass = 'status-system';
|
||||
break;
|
||||
}
|
||||
|
||||
return <span className={`status-badge ${statusClass}`}>{statusText}</span>;
|
||||
}
|
||||
},
|
||||
{
|
||||
title: "创建者",
|
||||
key: "created_by",
|
||||
width: "100px",
|
||||
render: (_: unknown, record: PromptTemplate) => record.created_by
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "operation",
|
||||
width: "150px",
|
||||
// align: "center",
|
||||
render: (_: unknown, record: PromptTemplate) => (
|
||||
<div>
|
||||
{record.status === 'system' ? (
|
||||
<>
|
||||
<button
|
||||
className="operation-btn text-primary"
|
||||
onClick={() => handleViewTemplate(record.id)}
|
||||
>
|
||||
<i className="ri-eye-line"></i> 查看
|
||||
</button>
|
||||
<button
|
||||
className="operation-btn text-primary"
|
||||
onClick={() => handleCloneTemplate(record.id)}
|
||||
>
|
||||
<i className="ri-file-copy-line"></i> 复制
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
className="operation-btn text-primary"
|
||||
onClick={() => handleEditTemplate(record.id)}
|
||||
>
|
||||
<i className="ri-edit-line"></i> 编辑
|
||||
</button>
|
||||
<button
|
||||
className="operation-btn text-error"
|
||||
onClick={() => handleDeleteTemplate(record.id)}
|
||||
>
|
||||
<i className="ri-delete-bin-line"></i> 删除
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="prompt-page">
|
||||
{/* 页面头部 */}
|
||||
<div className="page-header">
|
||||
<h2 className="page-title">提示词模板管理</h2>
|
||||
<div>
|
||||
<Button
|
||||
type="primary"
|
||||
icon="ri-add-line"
|
||||
onClick={() => navigate("/prompts/new")}
|
||||
>
|
||||
新增提示词模板
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 搜索栏 - 使用FilterPanel */}
|
||||
<FilterPanel
|
||||
className="mb-4"
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
type="default"
|
||||
icon="ri-refresh-line"
|
||||
onClick={handleReset}
|
||||
className="mr-2"
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon="ri-search-line"
|
||||
>
|
||||
搜索
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
noActionDivider={true}
|
||||
>
|
||||
<SearchFilter
|
||||
label="模板名称"
|
||||
placeholder="请输入模板名称"
|
||||
value={searchParams.get('name') || ''}
|
||||
onSearch={handleNameSearch}
|
||||
className="flex-1 min-w-[200px]"
|
||||
instantSearch={true}
|
||||
/>
|
||||
|
||||
<FilterSelect
|
||||
label="模板类型"
|
||||
name="type"
|
||||
value={searchParams.get('type') || ''}
|
||||
options={[
|
||||
// { value: "", label: "全部" },
|
||||
{ value: "Extraction", label: "抽取(Extraction)" },
|
||||
{ value: "Evaluation", label: "评估(Evaluation)" },
|
||||
{ value: "Summary", label: "摘要(Summary)" },
|
||||
{ value: "Common", label: "通用(Common)" }
|
||||
]}
|
||||
onChange={handleTypeChange}
|
||||
className="flex-1 min-w-[200px]"
|
||||
/>
|
||||
|
||||
<FilterSelect
|
||||
label="状态"
|
||||
name="status"
|
||||
value={searchParams.get('status') || ''}
|
||||
options={[
|
||||
// { value: "", label: "全部" },
|
||||
{ value: "active", label: "启用" },
|
||||
{ value: "inactive", label: "停用" },
|
||||
{ value: "system", label: "系统预设" }
|
||||
]}
|
||||
onChange={handleStatusChange}
|
||||
className="flex-1 min-w-[200px]"
|
||||
/>
|
||||
</FilterPanel>
|
||||
|
||||
{/* 数据表格 */}
|
||||
<Card bodyClassName="px-4 py-4">
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={MOCK_TEMPLATES}
|
||||
rowKey="id"
|
||||
emptyText="暂无提示词模板数据"
|
||||
/>
|
||||
|
||||
{/* 分页 */}
|
||||
<Pagination
|
||||
currentPage={1}
|
||||
total={MOCK_TEMPLATES.length}
|
||||
pageSize={10}
|
||||
onChange={(page) => {
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
newParams.set('page', page.toString());
|
||||
setSearchParams(newParams);
|
||||
}}
|
||||
showTotal={true}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,719 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { MetaFunction, LoaderFunctionArgs, ActionFunctionArgs } from "@remix-run/node";
|
||||
import { Link, useLoaderData, useSubmit, useNavigation } from "@remix-run/react";
|
||||
import { Button } from "~/components/ui/Button";
|
||||
import { PromptTemplate } from "./prompts._index";
|
||||
import newStyles from "~/styles/pages/prompts_new.css?url";
|
||||
|
||||
// 样式链接
|
||||
export function links() {
|
||||
return [{ rel: "stylesheet", href: newStyles }];
|
||||
}
|
||||
|
||||
// 页面元数据
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
{ title: "提示词模板编辑 - 中国烟草AI合同及卷宗审核系统" },
|
||||
{ name: "description", content: "创建或编辑提示词模板" },
|
||||
];
|
||||
};
|
||||
|
||||
// 面包屑导航
|
||||
export const handle = {
|
||||
breadcrumb: (data:LoaderData) => {
|
||||
if (data.mode === "edit") {
|
||||
return "编辑提示词模板";
|
||||
} else if (data.mode === "view") {
|
||||
return "查看提示词模板";
|
||||
} else {
|
||||
return "新增提示词模板";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
interface LoaderData {
|
||||
template: PromptTemplate;
|
||||
mode: string;
|
||||
}
|
||||
|
||||
// 从模拟数据中获取模板
|
||||
const getTemplateById = (id: string): PromptTemplate | undefined => {
|
||||
// 与prompts._index.tsx中的模拟数据保持一致
|
||||
const MOCK_TEMPLATES: PromptTemplate[] = [
|
||||
{
|
||||
id: "1",
|
||||
template_name: "行政处罚-抽取通用模板",
|
||||
template_type: "Extraction",
|
||||
description: "本模板用于抽取行政处罚决定书编号等信息",
|
||||
version: "v1.0",
|
||||
status: "system",
|
||||
created_by: "system",
|
||||
template_content: `你是一个专业的文档信息抽取助手。请从以下{docType}文档中抽取关键信息:
|
||||
1. 处罚决定书编号
|
||||
2. 处罚对象名称
|
||||
3. 处罚事由
|
||||
4. 处罚依据
|
||||
5. 处罚内容
|
||||
6. 处罚金额
|
||||
7. 发文日期
|
||||
请将结果以JSON格式输出,包含以上字段。如果某个字段在文档中未找到,则该字段的值设为null。`,
|
||||
variables: JSON.stringify({ "docType": "文档类型" })
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
template_name: "销售合同-甲方信息评估",
|
||||
template_type: "Evaluation",
|
||||
description: "评估销售合同中甲方信息是否完整",
|
||||
version: "v1.2",
|
||||
status: "active",
|
||||
created_by: "admin",
|
||||
template_content: `你是一个专业的合同审核助手。请评估以下{docType}中甲方信息的完整性:
|
||||
请检查以下要素是否存在且完整:
|
||||
1. 甲方全称
|
||||
2. 注册地址
|
||||
3. 统一社会信用代码
|
||||
4. 法定代表人
|
||||
5. 联系方式
|
||||
请给出评估结果,并标明缺失或不完整的信息。`,
|
||||
variables: JSON.stringify({ "docType": "文档类型" })
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
template_name: "专卖许可证-摘要模板",
|
||||
template_type: "Summary",
|
||||
description: "生成专卖许可证申请文件的内容摘要",
|
||||
version: "v1.0",
|
||||
status: "active",
|
||||
created_by: "admin",
|
||||
template_content: `你是一个专业的文档摘要助手。请为以下{docType}生成一份简洁的摘要:
|
||||
摘要应包含以下要点:
|
||||
1. 申请人基本信息
|
||||
2. 许可证类型
|
||||
3. 申请事项
|
||||
4. 经营范围
|
||||
5. 申请日期
|
||||
请控制摘要在200字以内,保留关键信息。`,
|
||||
variables: JSON.stringify({ "docType": "文档类型" })
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
template_name: "采购合同-乙方资质抽取",
|
||||
template_type: "Extraction",
|
||||
description: "抽取采购合同中乙方的资质信息",
|
||||
|
||||
version: "v1.1",
|
||||
status: "inactive",
|
||||
created_by: "zhangsan",
|
||||
template_content: `你是一个专业的合同信息抽取助手。请从以下{docType}中抽取乙方的资质信息:
|
||||
需要抽取的信息包括:
|
||||
1. 乙方全称
|
||||
2. 资质证书类型
|
||||
3. 资质证书编号
|
||||
4. 资质等级
|
||||
5. 证书有效期
|
||||
请将结果以JSON格式输出,包含以上字段。`,
|
||||
variables: JSON.stringify({ "docType": "文档类型" })
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
template_name: "合同通用-关键条款评估",
|
||||
template_type: "Evaluation",
|
||||
description: "评估合同中关键条款是否明确、合规",
|
||||
version: "v2.0",
|
||||
status: "active",
|
||||
created_by: "lisi",
|
||||
template_content: `你是一个专业的{industry}行业合同审核助手。请评估以下合同中的关键条款是否明确、合规:
|
||||
请重点关注以下条款:
|
||||
1. 合同标的
|
||||
2. 价格条款
|
||||
3. 付款条件
|
||||
4. 交付方式
|
||||
5. 违约责任
|
||||
6. 争议解决
|
||||
请对每一项给出评估结果,并指出不明确或存在风险的条款。`,
|
||||
variables: JSON.stringify({ "industry": "行业类型", "docType": "文档类型" })
|
||||
}
|
||||
];
|
||||
|
||||
return MOCK_TEMPLATES.find(t => t.id === id);
|
||||
};
|
||||
|
||||
// 加载函数
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const id = url.searchParams.get("id");
|
||||
const mode = url.searchParams.get("mode") || "create";
|
||||
|
||||
// 模板数据,如果是新建则为空
|
||||
let template = null;
|
||||
|
||||
if (id) {
|
||||
// 实际应用中,这里应该调用API获取数据
|
||||
// const response = await fetch(`${process.env.API_BASE_URL}/api/prompt-templates/${id}`);
|
||||
// if (!response.ok) throw new Error(`获取提示词模板失败: ${response.status}`);
|
||||
// template = await response.json();
|
||||
|
||||
// 使用模拟数据
|
||||
template = getTemplateById(id);
|
||||
if (!template) {
|
||||
throw new Error(`未找到ID为${id}的模板`);
|
||||
}
|
||||
}
|
||||
|
||||
return Response.json({
|
||||
template,
|
||||
mode
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("加载提示词模板失败:", error);
|
||||
return Response.json(
|
||||
{
|
||||
template: null,
|
||||
mode: "create",
|
||||
error: error instanceof Error ? error.message : "加载提示词模板失败"
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Action函数 - 处理表单提交
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
const templateData = Object.fromEntries(formData);
|
||||
|
||||
// 表单验证
|
||||
const errors: Record<string, string> = {};
|
||||
|
||||
if (!templateData.template_name) {
|
||||
errors.template_name = "模板名称不能为空";
|
||||
}
|
||||
|
||||
if (!templateData.template_type) {
|
||||
errors.template_type = "请选择模板类型";
|
||||
}
|
||||
|
||||
if (!templateData.template_content) {
|
||||
errors.template_content = "模板内容不能为空";
|
||||
}
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
return Response.json({ errors, success: false }, { status: 400 });
|
||||
}
|
||||
|
||||
// 实际应用中,这里应该调用API保存数据
|
||||
// const apiUrl = templateData.id
|
||||
// ? `${process.env.API_BASE_URL}/api/prompt-templates/${templateData.id}`
|
||||
// : `${process.env.API_BASE_URL}/api/prompt-templates`;
|
||||
//
|
||||
// const response = await fetch(apiUrl, {
|
||||
// method: templateData.id ? "PUT" : "POST",
|
||||
// headers: { "Content-Type": "application/json" },
|
||||
// body: JSON.stringify(templateData)
|
||||
// });
|
||||
//
|
||||
// if (!response.ok) throw new Error(`保存提示词模板失败: ${response.status}`);
|
||||
// const result = await response.json();
|
||||
|
||||
// 模拟API响应
|
||||
console.log("提交的模板数据:", templateData);
|
||||
|
||||
return Response.json({
|
||||
success: true,
|
||||
message: "提示词模板保存成功",
|
||||
template: {
|
||||
...templateData,
|
||||
id: templateData.id || Math.random().toString(36).substring(2, 10),
|
||||
created_by: "当前用户",
|
||||
created_at: new Date().toISOString()
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("保存提示词模板失败:", error);
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : "保存提示词模板失败"
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 提取变量函数
|
||||
const extractVariables = (content: string) => {
|
||||
const regex = /{([^{}]+)}/g;
|
||||
const variables: Record<string, string> = {};
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(content)) !== null) {
|
||||
const varName = match[1].trim();
|
||||
if (varName && !variables[varName]) {
|
||||
variables[varName] = varName;
|
||||
}
|
||||
}
|
||||
|
||||
return variables;
|
||||
};
|
||||
|
||||
// 页面组件
|
||||
export default function PromptsNew() {
|
||||
const { template, mode } = useLoaderData<typeof loader>();
|
||||
const submit = useSubmit();
|
||||
const navigation = useNavigation();
|
||||
const isSubmitting = navigation.state === "submitting";
|
||||
|
||||
// 表单状态
|
||||
const [formData, setFormData] = useState<Partial<PromptTemplate>>({
|
||||
id: "",
|
||||
template_name: "",
|
||||
template_type: "Common",
|
||||
description: "",
|
||||
version: "v1.0",
|
||||
status: "active",
|
||||
template_content: "",
|
||||
variables: "{}"
|
||||
});
|
||||
|
||||
// 模式状态
|
||||
const [isViewMode, setIsViewMode] = useState(false);
|
||||
const [pageTitle, setPageTitle] = useState("新增提示词模板");
|
||||
|
||||
// 变量相关状态
|
||||
const [detectedVariables, setDetectedVariables] = useState<Record<string, string>>({});
|
||||
const [exampleValues, setExampleValues] = useState<Record<string, string>>({});
|
||||
const [previewContent, setPreviewContent] = useState("");
|
||||
|
||||
// 初始化表单数据
|
||||
useEffect(() => {
|
||||
if (template) {
|
||||
const newFormData = {
|
||||
...template,
|
||||
// 如果是克隆模式,则清除ID并修改名称
|
||||
id: mode === "clone" ? "" : template.id,
|
||||
template_name: mode === "clone" ? `${template.template_name} (副本)` : template.template_name,
|
||||
// 如果是克隆模式,重置版本
|
||||
version: mode === "clone" ? "v1.0" : template.version
|
||||
};
|
||||
|
||||
setFormData(newFormData);
|
||||
|
||||
try {
|
||||
// 解析模板变量
|
||||
const vars = JSON.parse(template.variables);
|
||||
setExampleValues(vars);
|
||||
} catch (e) {
|
||||
console.error("解析变量失败:", e);
|
||||
}
|
||||
|
||||
// 检测模板内容中的变量
|
||||
if (template.template_content) {
|
||||
const vars = extractVariables(template.template_content);
|
||||
setDetectedVariables(vars);
|
||||
}
|
||||
}
|
||||
|
||||
// 设置页面模式
|
||||
setIsViewMode(mode === "view");
|
||||
|
||||
// 设置页面标题
|
||||
if (mode === "view") {
|
||||
setPageTitle("查看提示词模板");
|
||||
} else if (mode === "edit") {
|
||||
setPageTitle("编辑提示词模板");
|
||||
} else if (mode === "clone") {
|
||||
setPageTitle("复制创建提示词模板");
|
||||
} else {
|
||||
setPageTitle("新增提示词模板");
|
||||
}
|
||||
}, [template, mode]);
|
||||
|
||||
// 处理输入变化
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||
const { name, value } = e.target;
|
||||
|
||||
// 如果是模板内容,检测变量
|
||||
if (name === "template_content") {
|
||||
const vars = extractVariables(value);
|
||||
setDetectedVariables(vars);
|
||||
|
||||
// 更新变量JSON
|
||||
const varsJson = JSON.stringify(
|
||||
Object.keys(vars).reduce((acc, key) => {
|
||||
acc[key] = exampleValues[key] || key;
|
||||
return acc;
|
||||
}, {} as Record<string, string>)
|
||||
);
|
||||
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value,
|
||||
variables: varsJson
|
||||
}));
|
||||
} else {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// 处理状态切换
|
||||
const handleStatusToggle = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const status = e.target.checked ? "active" : "inactive";
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
status
|
||||
}));
|
||||
};
|
||||
|
||||
// 处理示例值变更
|
||||
const handleExampleValueChange = (varName: string, value: string) => {
|
||||
setExampleValues(prev => ({
|
||||
...prev,
|
||||
[varName]: value
|
||||
}));
|
||||
};
|
||||
|
||||
// 更新预览
|
||||
const updatePreview = () => {
|
||||
let content = formData.template_content || "";
|
||||
|
||||
// 替换变量
|
||||
Object.entries(detectedVariables).forEach(([key]) => {
|
||||
const exampleValue = exampleValues[key] || `[${key}]`;
|
||||
const regex = new RegExp(`{${key}}`, 'g');
|
||||
content = content.replace(regex, exampleValue);
|
||||
});
|
||||
|
||||
setPreviewContent(content);
|
||||
};
|
||||
|
||||
// 当检测到的变量变化时,更新变量JSON
|
||||
useEffect(() => {
|
||||
const varsJson = JSON.stringify(
|
||||
Object.keys(detectedVariables).reduce((acc, key) => {
|
||||
acc[key] = exampleValues[key] || key;
|
||||
return acc;
|
||||
}, {} as Record<string, string>)
|
||||
);
|
||||
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
variables: varsJson
|
||||
}));
|
||||
}, [detectedVariables, exampleValues]);
|
||||
|
||||
// 处理表单提交
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (isViewMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const formElement = e.target as HTMLFormElement;
|
||||
const submittingFormData = new FormData(formElement);
|
||||
|
||||
// 确保变量JSON被包含在提交中
|
||||
submittingFormData.set("variables", formData.variables || "{}");
|
||||
|
||||
submit(submittingFormData, { method: "post" });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="prompt-new-page">
|
||||
{/* 页面头部 */}
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-medium">{pageTitle}</h2>
|
||||
<div>
|
||||
<Link to="/prompts" className="mr-2">
|
||||
<Button type="default" icon="ri-arrow-left-line">
|
||||
返回列表
|
||||
</Button>
|
||||
</Link>
|
||||
{!isViewMode && (
|
||||
<Button
|
||||
type="primary"
|
||||
icon="ri-save-line"
|
||||
disabled={isSubmitting}
|
||||
onClick={() => document.getElementById("template-form")?.dispatchEvent(new Event("submit", { cancelable: true, bubbles: true }))}
|
||||
>
|
||||
{isSubmitting ? "保存中..." : "保存"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 查看模式提示 */}
|
||||
{isViewMode && (
|
||||
<div className="alert alert-info">
|
||||
<i className="ri-information-line"></i>
|
||||
<div>
|
||||
<div>您正在查看系统预设模板,此模板不可修改。如需基于此模板创建新模板,请点击"复制创建"按钮。</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 模板表单 */}
|
||||
<form id="template-form" method="post" onSubmit={handleSubmit}>
|
||||
{/* 模板ID - 隐藏字段 */}
|
||||
<input type="hidden" name="id" value={formData.id || ''} />
|
||||
|
||||
{/* 模板信息卡片 */}
|
||||
<div className="ant-card">
|
||||
<div className="ant-card-header">提示词模板信息</div>
|
||||
<div className="ant-card-body p-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* 模板名称 */}
|
||||
<div className="form-group mb-3">
|
||||
<label htmlFor="template-name" className="form-label mb-1">
|
||||
模板名称 <span className="text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className={`form-input py-1 ${isViewMode ? 'read-only-field' : ''}`}
|
||||
id="template-name"
|
||||
name="template_name"
|
||||
placeholder="请输入模板名称"
|
||||
value={formData.template_name || ''}
|
||||
onChange={handleInputChange}
|
||||
readOnly={isViewMode}
|
||||
required
|
||||
/>
|
||||
<div className="help-text text-xs">建议使用"文档类型-功能"的命名方式</div>
|
||||
</div>
|
||||
|
||||
{/* 模板类型 */}
|
||||
<div className="form-group mb-3">
|
||||
<label htmlFor="template-type" className="form-label mb-1">
|
||||
模板类型 <span className="text-error">*</span>
|
||||
</label>
|
||||
<select
|
||||
className={`form-select py-1 ${isViewMode ? 'read-only-field' : ''}`}
|
||||
id="template-type"
|
||||
name="template_type"
|
||||
value={formData.template_type || ''}
|
||||
onChange={handleInputChange}
|
||||
disabled={isViewMode}
|
||||
required
|
||||
>
|
||||
<option value="">请选择模板类型</option>
|
||||
<option value="Common">通用(Common) - 适用于多种场景的通用提示词</option>
|
||||
<option value="Extraction">抽取(Extraction) - 从文档抽取结构化信息</option>
|
||||
<option value="Evaluation">评估(Evaluation) - 对文档内容进行评估</option>
|
||||
<option value="Summary">摘要(Summary) - 生成文档内容摘要</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 模板描述 */}
|
||||
<div className="form-group mb-3">
|
||||
<label htmlFor="template-description" className="form-label mb-1">
|
||||
模板描述
|
||||
</label>
|
||||
<textarea
|
||||
className={`form-textarea py-1 ${isViewMode ? 'read-only-field' : ''}`}
|
||||
id="template-description"
|
||||
name="description"
|
||||
placeholder="请简要描述此模板的功能和用途"
|
||||
value={formData.description || ''}
|
||||
onChange={handleInputChange}
|
||||
readOnly={isViewMode}
|
||||
rows={2}
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* 模板状态 */}
|
||||
<div className="form-group mb-3">
|
||||
<label htmlFor="status-toggle" className="form-label mb-1">模板状态</label>
|
||||
<div className="flex items-center mt-1">
|
||||
<label className="switch mr-2" htmlFor="status-toggle">
|
||||
<span className="sr-only">切换模板状态</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="status-toggle"
|
||||
name="status"
|
||||
checked={formData.status === 'active'}
|
||||
onChange={handleStatusToggle}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
<span className="slider"></span>
|
||||
</label>
|
||||
<span id="status-text">{formData.status === 'active' ? '启用' : '停用'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 模板版本 */}
|
||||
<div className="form-group mb-3">
|
||||
<label htmlFor="template-version" className="form-label mb-1">模板版本</label>
|
||||
<input
|
||||
type="text"
|
||||
className={`form-input py-1 ${isViewMode ? 'read-only-field' : ''}`}
|
||||
id="template-version"
|
||||
name="version"
|
||||
placeholder="例如:v1.0"
|
||||
value={formData.version || 'v1.0'}
|
||||
onChange={handleInputChange}
|
||||
readOnly={isViewMode}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 模板内容卡片 */}
|
||||
<div className="ant-card">
|
||||
<div className="ant-card-header">提示词模板内容</div>
|
||||
<div className="ant-card-body">
|
||||
<div className="alert alert-warning mb-4">
|
||||
<i className="ri-information-line"></i>
|
||||
<div>
|
||||
<div>模板内容支持使用变量,变量格式为 {varName},在使用时会自动替换。系统将自动识别模板中的变量。</div>
|
||||
<div className="mt-1">例如:请从以下{docType}文档中抽取关键信息...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 模板内容 */}
|
||||
<div className="form-group">
|
||||
<label htmlFor="template-content" className="form-label">
|
||||
模板内容 <span className="text-error">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
className={`form-code-editor w-full ${isViewMode ? 'read-only-field' : ''}`}
|
||||
id="template-content"
|
||||
name="template_content"
|
||||
placeholder="在此输入提示词模板内容..."
|
||||
value={formData.template_content || ''}
|
||||
onChange={handleInputChange}
|
||||
readOnly={isViewMode}
|
||||
rows={15}
|
||||
required
|
||||
></textarea>
|
||||
<div className="help-text">提示词模板是AI完成特定任务的指令,请清晰描述任务需求和输出格式</div>
|
||||
</div>
|
||||
|
||||
{/* 变量识别区域 */}
|
||||
<div className="form-group mt-6">
|
||||
<label htmlFor="var-container" className="form-label">已识别的变量</label>
|
||||
<div className="alert alert-info mb-3">
|
||||
<i className="ri-lightbulb-line"></i>
|
||||
<div>
|
||||
<div>系统已自动识别出模板中的变量。变量以 {varName} 形式在模板中使用,无需手动定义。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="var-container" className="var-container" aria-labelledby="var-container-label">
|
||||
{Object.keys(detectedVariables).length > 0 ? (
|
||||
Object.keys(detectedVariables).map(varName => (
|
||||
<div
|
||||
className="var-badge"
|
||||
key={varName}
|
||||
data-var-name={varName}
|
||||
>
|
||||
{varName}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-secondary text-sm italic" id="no-vars-message">
|
||||
暂未识别到任何变量,请在模板内容中使用 {变量名} 格式添加变量
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<input type="hidden" id="variables-json" name="variables" value={formData.variables || '{}'} />
|
||||
</div>
|
||||
|
||||
{/* 模板效果预览 */}
|
||||
<div className="form-group mt-6">
|
||||
<label htmlFor="preview-content" className="form-label">模板效果预览</label>
|
||||
<div className="example-section">
|
||||
<div className="example-header">变量赋值示例:</div>
|
||||
<div id="example-vars" className="mb-3">
|
||||
{Object.keys(detectedVariables).length > 0 ? (
|
||||
Object.keys(detectedVariables).map(varName => (
|
||||
<div className="var-input-group" key={varName}>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={varName}
|
||||
readOnly
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className={`form-input ${isViewMode ? 'read-only-field' : ''}`}
|
||||
id={`example-${varName}`}
|
||||
placeholder={`示例值,如 ${varName}`}
|
||||
value={exampleValues[varName] || ''}
|
||||
onChange={(e) => handleExampleValueChange(varName, e.target.value)}
|
||||
readOnly={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-secondary text-sm italic">
|
||||
暂无变量,无需赋值
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="example-header">预览效果:</div>
|
||||
<div id="preview-content" className="bg-gray-50 p-4 rounded border border-gray-200">
|
||||
{previewContent ? (
|
||||
<pre style={{ whiteSpace: 'pre-wrap' }}>{previewContent}</pre>
|
||||
) : (
|
||||
<div className="text-gray-400 italic">根据变量值生成的模板预览将显示在这里...</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end mt-3">
|
||||
<button
|
||||
type="button"
|
||||
className="ant-btn ant-btn-default"
|
||||
onClick={updatePreview}
|
||||
>
|
||||
<i className="ri-eye-line"></i> 更新预览
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部按钮区域 */}
|
||||
<div className="flex justify-between mt-6">
|
||||
<div>
|
||||
{isViewMode && (
|
||||
<Link to={`/prompts/new?id=${formData.id}&mode=clone`}>
|
||||
<button type="button" className="ant-btn ant-btn-default">
|
||||
<i className="ri-file-copy-line"></i> 复制创建
|
||||
</button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Link to="/prompts" className="mr-2">
|
||||
<button type="button" className="ant-btn ant-btn-default">
|
||||
<i className="ri-close-line"></i> 取消
|
||||
</button>
|
||||
</Link>
|
||||
{!isViewMode && (
|
||||
<button
|
||||
type="submit"
|
||||
className="ant-btn ant-btn-primary"
|
||||
disabled={isSubmitting}
|
||||
id="save-btn-bottom"
|
||||
>
|
||||
<i className="ri-save-line"></i> {isSubmitting ? "保存中..." : "保存"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Outlet } from "@remix-run/react";
|
||||
|
||||
/**
|
||||
* 提示词模板管理 - 父级路由
|
||||
* 仅作为嵌套路由的容器,不包含具体内容
|
||||
*/
|
||||
export const handle = {
|
||||
breadcrumb: "提示词管理"
|
||||
}
|
||||
|
||||
export default function Prompts() {
|
||||
return <Outlet />;
|
||||
}
|
||||
@@ -1,414 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { json, type MetaFunction } from '@remix-run/node';
|
||||
import { useLoaderData, useParams } from '@remix-run/react';
|
||||
import { Button } from '~/components/ui/Button';
|
||||
import { Card } from '~/components/ui/Card';
|
||||
import { Breadcrumb } from '~/components/layout/Breadcrumb';
|
||||
import type { ReviewResult, RuleCheckResult } from '~/models/review';
|
||||
import type { File } from '~/models/file';
|
||||
import { RULE_CHECK_STATUS_LABELS, RULE_CHECK_STATUS_COLORS } from '~/models/review';
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
{ title: "中国烟草AI合同及卷宗审核系统 - 评查详情" },
|
||||
{ name: "description", content: "文件评查详情页面" }
|
||||
];
|
||||
};
|
||||
|
||||
export const handle = {
|
||||
breadcrumb: '评查详情'
|
||||
};
|
||||
|
||||
interface LoaderData {
|
||||
file: File;
|
||||
reviewResult: ReviewResult;
|
||||
reviewPoints: RuleCheckResult[];
|
||||
fileContent?: string; // 模拟文件内容
|
||||
}
|
||||
|
||||
export async function loader({ params }) {
|
||||
const { reviewId } = params;
|
||||
|
||||
// 模拟数据,实际项目中应从API获取
|
||||
const file: File = {
|
||||
id: "1",
|
||||
fileName: "2023年度烟草专卖零售许可证.pdf",
|
||||
fileType: "application/pdf",
|
||||
documentTypeId: "2",
|
||||
documentTypeName: "专卖许可证",
|
||||
fileSize: 1024 * 1024 * 2.5, // 2.5MB
|
||||
uploaderId: "1",
|
||||
uploaderName: "张三",
|
||||
status: "completed",
|
||||
reviewStatus: "pass",
|
||||
createdAt: "2023-12-24 14:30",
|
||||
updatedAt: "2023-12-24 16:45"
|
||||
};
|
||||
|
||||
const reviewResult: ReviewResult = {
|
||||
id: reviewId,
|
||||
fileId: "1",
|
||||
fileName: "2023年度烟草专卖零售许可证.pdf",
|
||||
totalPoints: 15,
|
||||
passPoints: 6,
|
||||
warningPoints: 7,
|
||||
errorPoints: 2,
|
||||
score: 80,
|
||||
reviewStatus: "warning",
|
||||
reviewedAt: "2023-12-24 16:45",
|
||||
reviewerId: "system",
|
||||
reviewerName: "AI系统",
|
||||
createdAt: "2023-12-24 14:35",
|
||||
updatedAt: "2023-12-24 16:45"
|
||||
};
|
||||
|
||||
const reviewPoints: RuleCheckResult[] = [
|
||||
{
|
||||
id: "1",
|
||||
reviewResultId: reviewId,
|
||||
ruleId: "1",
|
||||
ruleName: "合同主体信息完整性检查",
|
||||
status: "pass",
|
||||
location: "第1页 第3段",
|
||||
content: "甲方:XX烟草公司,地址:XX市XX区XX路XX号,法定代表人:张XX",
|
||||
suggestion: "主体信息完整,符合规范",
|
||||
manualReviewed: false,
|
||||
createdAt: "2023-12-24 14:40",
|
||||
updatedAt: "2023-12-24 14:40"
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
reviewResultId: reviewId,
|
||||
ruleId: "2",
|
||||
ruleName: "许可证编号格式检查",
|
||||
status: "warning",
|
||||
location: "第1页 第5段",
|
||||
content: "许可证编号:(2023)12345",
|
||||
suggestion: "许可证编号格式不完全符合规范,建议修改为'烟零许(2023)12345号'",
|
||||
manualReviewed: true,
|
||||
createdAt: "2023-12-24 14:40",
|
||||
updatedAt: "2023-12-24 15:20"
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
reviewResultId: reviewId,
|
||||
ruleId: "3",
|
||||
ruleName: "许可证有效期检查",
|
||||
status: "fail",
|
||||
location: "第1页 第8段",
|
||||
content: "有效期:自2023年1月1日",
|
||||
suggestion: "许可证缺少有效期截止日期,必须明确注明有效期限",
|
||||
manualReviewed: false,
|
||||
createdAt: "2023-12-24 14:40",
|
||||
updatedAt: "2023-12-24 14:40"
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
reviewResultId: reviewId,
|
||||
ruleId: "4",
|
||||
ruleName: "经营场所信息检查",
|
||||
status: "pass",
|
||||
location: "第1页 第12段",
|
||||
content: "经营场所:XX市XX区XX街XX号,面积:120平方米",
|
||||
suggestion: "经营场所信息完整",
|
||||
manualReviewed: false,
|
||||
createdAt: "2023-12-24 14:40",
|
||||
updatedAt: "2023-12-24 14:40"
|
||||
}
|
||||
];
|
||||
|
||||
// 模拟文件内容,实际项目中应从API获取或使用专用组件展示
|
||||
const fileContent = `烟草专卖零售许可证
|
||||
发证机关:XX市烟草专卖局
|
||||
发证日期:2023年1月1日
|
||||
|
||||
甲方:XX烟草公司,地址:XX市XX区XX路XX号,法定代表人:张XX
|
||||
|
||||
零售单位名称:XX便利店
|
||||
许可证编号:(2023)12345
|
||||
法定代表人/负责人:李XX
|
||||
经营者类型:个体工商户
|
||||
有效期:自2023年1月1日
|
||||
联系电话:123-4567890
|
||||
|
||||
经营场所:XX市XX区XX街XX号,面积:120平方米
|
||||
零售烟草制品品种:卷烟、雪茄烟
|
||||
|
||||
特别说明:本许可证不得伪造、变造、转让、涂改。
|
||||
`;
|
||||
|
||||
return json<LoaderData>({
|
||||
file,
|
||||
reviewResult,
|
||||
reviewPoints,
|
||||
fileContent
|
||||
});
|
||||
}
|
||||
|
||||
export default function ReviewDetail() {
|
||||
const { file, reviewResult, reviewPoints, fileContent } = useLoaderData<typeof loader>();
|
||||
const [activeTab, setActiveTab] = useState('tab-preview');
|
||||
const [selectedPoint, setSelectedPoint] = useState<string | null>(null);
|
||||
|
||||
const handleTabChange = (tabId: string) => {
|
||||
setActiveTab(tabId);
|
||||
};
|
||||
|
||||
const handlePointSelect = (pointId: string) => {
|
||||
setSelectedPoint(pointId === selectedPoint ? null : pointId);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Breadcrumb
|
||||
items={[
|
||||
{ title: '评查结果', to: '/reviews' },
|
||||
{ title: '评查详情', to: `/reviews/${reviewResult.id}` }
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<div className="flex items-center">
|
||||
<h2 className="text-xl font-medium">{file.fileName}</h2>
|
||||
<span className={`ml-3 status-badge status-${reviewResult.reviewStatus === 'pass' ? 'success' : reviewResult.reviewStatus === 'warning' ? 'warning' : 'error'}`}>
|
||||
<i className={`ri-${reviewResult.reviewStatus === 'pass' ? 'checkbox-circle' : reviewResult.reviewStatus === 'warning' ? 'error-warning' : 'close-circle'}-line mr-1`}></i>
|
||||
{reviewResult.reviewStatus === 'pass' ? '通过' : reviewResult.reviewStatus === 'warning' ? '警告' : '不通过'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-x-2">
|
||||
<Button type="default" icon="ri-download-line">
|
||||
导出评查报告
|
||||
</Button>
|
||||
<Button type="primary" icon="ri-check-double-line">
|
||||
完成评查
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tab-container">
|
||||
<div className="tab-nav">
|
||||
<div
|
||||
className={`tab-nav-item ${activeTab === 'tab-preview' ? 'active' : ''}`}
|
||||
onClick={() => handleTabChange('tab-preview')}
|
||||
>
|
||||
<i className="ri-file-text-line"></i> 评查结果
|
||||
</div>
|
||||
<div
|
||||
className={`tab-nav-item ${activeTab === 'tab-suggestion' ? 'active' : ''}`}
|
||||
onClick={() => handleTabChange('tab-suggestion')}
|
||||
>
|
||||
<i className="ri-lightbulb-line"></i> AI智能分析
|
||||
</div>
|
||||
<div
|
||||
className={`tab-nav-item ${activeTab === 'tab-fileinfo' ? 'active' : ''}`}
|
||||
onClick={() => handleTabChange('tab-fileinfo')}
|
||||
>
|
||||
<i className="ri-information-line"></i> 文件信息
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tab-content">
|
||||
<div className={`tab-pane ${activeTab === 'tab-preview' ? 'active' : ''}`}>
|
||||
<div className="flex flex-col lg:flex-row lg:h-[calc(100vh-250px)]">
|
||||
{/* 文件内容预览 */}
|
||||
<div className="w-full lg:w-2/3 h-full mb-4 lg:mb-0 lg:pr-4">
|
||||
<div className="bg-white p-4 rounded-md shadow-sm h-full overflow-y-auto">
|
||||
<pre className="whitespace-pre-wrap font-sans text-gray-800">
|
||||
{fileContent}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 评查点列表 */}
|
||||
<div className="w-full lg:w-1/3 h-full lg:pl-4">
|
||||
<div className="review-points-panel h-full flex flex-col">
|
||||
<div className="review-panel-header py-2 px-4 flex items-center bg-primary-light">
|
||||
<i className="ri-file-list-check-line text-primary mr-2"></i>
|
||||
<span className="font-medium text-primary">评查结果</span>
|
||||
</div>
|
||||
|
||||
{/* 评查统计 */}
|
||||
<div className="review-statistics bg-white border-b border-gray-100 py-3 px-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center">
|
||||
<div className="w-7 h-7 bg-gray-100 rounded-md flex items-center justify-center">
|
||||
<span className="text-sm font-semibold text-gray-600">{reviewResult.totalPoints}</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 ml-1">总计</span>
|
||||
</div>
|
||||
<div className="h-8 border-r border-gray-200"></div>
|
||||
<div className="flex items-center">
|
||||
<div className="w-7 h-7 bg-green-50 rounded-md flex items-center justify-center">
|
||||
<span className="text-sm font-semibold text-success">{reviewResult.passPoints}</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 ml-1">通过</span>
|
||||
</div>
|
||||
<div className="h-8 border-r border-gray-200"></div>
|
||||
<div className="flex items-center">
|
||||
<div className="w-7 h-7 bg-yellow-50 rounded-md flex items-center justify-center">
|
||||
<span className="text-sm font-semibold text-warning">{reviewResult.warningPoints}</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 ml-1">警告</span>
|
||||
</div>
|
||||
<div className="h-8 border-r border-gray-200"></div>
|
||||
<div className="flex items-center">
|
||||
<div className="w-7 h-7 bg-red-50 rounded-md flex items-center justify-center">
|
||||
<span className="text-sm font-semibold text-error">{reviewResult.errorPoints}</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 ml-1">错误</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 评查点列表 */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{reviewPoints.map(point => (
|
||||
<div
|
||||
key={point.id}
|
||||
className={`review-point-item ${selectedPoint === point.id ? 'bg-gray-50' : ''}`}
|
||||
onClick={() => handlePointSelect(point.id)}
|
||||
>
|
||||
<div className="review-point-header">
|
||||
<div className="review-point-title">{point.ruleName}</div>
|
||||
<span className={`status-badge status-${RULE_CHECK_STATUS_COLORS[point.status]}`}>
|
||||
<i className={`ri-${point.status === 'pass' ? 'checkbox-circle' : point.status === 'warning' ? 'error-warning' : 'close-circle'}-line mr-1`}></i>
|
||||
{RULE_CHECK_STATUS_LABELS[point.status]}
|
||||
</span>
|
||||
</div>
|
||||
<div className="review-point-location">
|
||||
<i className="ri-file-list-line mr-1"></i>
|
||||
<span>{point.location}</span>
|
||||
</div>
|
||||
|
||||
{selectedPoint === point.id && (
|
||||
<div className="mt-2 pt-2 border-t border-gray-100">
|
||||
<div className="text-xs text-gray-600 mb-1">
|
||||
<span className="font-medium">内容:</span>
|
||||
<span>{point.content}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">
|
||||
<span className="font-medium">建议:</span>
|
||||
<span>{point.suggestion}</span>
|
||||
</div>
|
||||
<div className="mt-2 flex justify-between">
|
||||
<div className="text-xs text-gray-500">
|
||||
{point.manualReviewed &&
|
||||
<span><i className="ri-user-line mr-1"></i>人工审核</span>
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
<Button type="default" size="small">
|
||||
<i className="ri-edit-line mr-1"></i>修改
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`tab-pane ${activeTab === 'tab-suggestion' ? 'active' : ''}`}>
|
||||
<Card>
|
||||
<div className="text-lg font-medium mb-4 text-gray-800">AI智能分析意见</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<div className="font-medium text-gray-700 mb-2">总体评价</div>
|
||||
<div className="p-3 bg-gray-50 rounded-md text-gray-600">
|
||||
该烟草专卖零售许可证文档基本符合规范要求,但存在部分问题需要修改。主要包括许可证编号格式不标准和缺少有效期截止日期两个问题,其中缺少有效期截止日期属于严重问题,必须补充完善。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<div className="font-medium text-gray-700 mb-2">主要问题</div>
|
||||
<ul className="list-disc pl-5 space-y-2 text-gray-600">
|
||||
<li><span className="text-warning font-medium">许可证编号格式不规范</span> - 当前格式为"(2023)12345",应修改为"烟零许(2023)12345号"</li>
|
||||
<li><span className="text-error font-medium">缺少许可证有效期截止日期</span> - 仅注明了起始日期"自2023年1月1日",缺少截止日期</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="font-medium text-gray-700 mb-2">修改建议</div>
|
||||
<ul className="list-decimal pl-5 space-y-2 text-gray-600">
|
||||
<li>规范许可证编号格式,在前面添加"烟零许",在后面添加"号"</li>
|
||||
<li>补充完善许可证有效期,明确注明截止日期,如"自2023年1月1日至2023年12月31日"</li>
|
||||
<li>建议对零售烟草制品品种部分进行细化描述,列明具体品牌类别</li>
|
||||
</ul>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className={`tab-pane ${activeTab === 'tab-fileinfo' ? 'active' : ''}`}>
|
||||
<Card>
|
||||
<div className="text-lg font-medium mb-4 text-gray-800">文件基本信息</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<table className="w-full">
|
||||
<tbody>
|
||||
<tr className="border-b border-gray-100">
|
||||
<td className="py-2 text-gray-500 w-1/3">文件名称</td>
|
||||
<td className="py-2 text-gray-800">{file.fileName}</td>
|
||||
</tr>
|
||||
<tr className="border-b border-gray-100">
|
||||
<td className="py-2 text-gray-500">文件类型</td>
|
||||
<td className="py-2 text-gray-800">{file.documentTypeName}</td>
|
||||
</tr>
|
||||
<tr className="border-b border-gray-100">
|
||||
<td className="py-2 text-gray-500">文件大小</td>
|
||||
<td className="py-2 text-gray-800">{(file.fileSize / (1024 * 1024)).toFixed(2)} MB</td>
|
||||
</tr>
|
||||
<tr className="border-b border-gray-100">
|
||||
<td className="py-2 text-gray-500">上传人</td>
|
||||
<td className="py-2 text-gray-800">{file.uploaderName}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 text-gray-500">上传时间</td>
|
||||
<td className="py-2 text-gray-800">{file.createdAt}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div>
|
||||
<table className="w-full">
|
||||
<tbody>
|
||||
<tr className="border-b border-gray-100">
|
||||
<td className="py-2 text-gray-500 w-1/3">评查状态</td>
|
||||
<td className="py-2 text-gray-800">
|
||||
<span className={`status-badge status-${reviewResult.reviewStatus === 'pass' ? 'success' : reviewResult.reviewStatus === 'warning' ? 'warning' : 'error'}`}>
|
||||
<i className={`ri-${reviewResult.reviewStatus === 'pass' ? 'checkbox-circle' : reviewResult.reviewStatus === 'warning' ? 'error-warning' : 'close-circle'}-line mr-1`}></i>
|
||||
{reviewResult.reviewStatus === 'pass' ? '通过' : reviewResult.reviewStatus === 'warning' ? '警告' : '不通过'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr className="border-b border-gray-100">
|
||||
<td className="py-2 text-gray-500">评查得分</td>
|
||||
<td className="py-2 text-gray-800">{reviewResult.score} 分</td>
|
||||
</tr>
|
||||
<tr className="border-b border-gray-100">
|
||||
<td className="py-2 text-gray-500">评查完成时间</td>
|
||||
<td className="py-2 text-gray-800">{reviewResult.reviewedAt}</td>
|
||||
</tr>
|
||||
<tr className="border-b border-gray-100">
|
||||
<td className="py-2 text-gray-500">评查人</td>
|
||||
<td className="py-2 text-gray-800">{reviewResult.reviewerName}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 text-gray-500">评查总点数</td>
|
||||
<td className="py-2 text-gray-800">{reviewResult.totalPoints} 项</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,328 +0,0 @@
|
||||
import React from 'react';
|
||||
import { json, type MetaFunction } from '@remix-run/node';
|
||||
import { useLoaderData, Link } from '@remix-run/react';
|
||||
import { Button } from '~/components/ui/Button';
|
||||
import { Card } from '~/components/ui/Card';
|
||||
import { Table } from '~/components/ui/Table';
|
||||
import type { ReviewResult } from '~/models/review';
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
{ title: "中国烟草AI合同及卷宗审核系统 - 评查结果" },
|
||||
{ name: "description", content: "文件评查结果列表" }
|
||||
];
|
||||
};
|
||||
|
||||
export const handle = {
|
||||
breadcrumb: '评查结果'
|
||||
};
|
||||
|
||||
interface LoaderData {
|
||||
reviews: ReviewResult[];
|
||||
totalCount: number;
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export async function loader({ request }) {
|
||||
// 解析查询参数
|
||||
const url = new URL(request.url);
|
||||
const keyword = url.searchParams.get("keyword") || "";
|
||||
const status = url.searchParams.get("status") || "";
|
||||
const startDate = url.searchParams.get("startDate") || "";
|
||||
const endDate = url.searchParams.get("endDate") || "";
|
||||
const page = parseInt(url.searchParams.get("page") || "1", 10);
|
||||
const pageSize = parseInt(url.searchParams.get("pageSize") || "10", 10);
|
||||
|
||||
// 模拟数据,实际项目中应从API获取
|
||||
const reviews: ReviewResult[] = [
|
||||
{
|
||||
id: "1",
|
||||
fileId: "1",
|
||||
fileName: "2023年度烟草专卖零售许可证.pdf",
|
||||
totalPoints: 15,
|
||||
passPoints: 11,
|
||||
warningPoints: 3,
|
||||
errorPoints: 1,
|
||||
score: 85,
|
||||
reviewStatus: "warning",
|
||||
reviewedAt: "2023-12-24 16:45",
|
||||
reviewerId: "system",
|
||||
reviewerName: "AI系统",
|
||||
createdAt: "2023-12-24 14:35",
|
||||
updatedAt: "2023-12-24 16:45"
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
fileId: "2",
|
||||
fileName: "烟草零售合同协议书.docx",
|
||||
totalPoints: 20,
|
||||
passPoints: 18,
|
||||
warningPoints: 2,
|
||||
errorPoints: 0,
|
||||
score: 92,
|
||||
reviewStatus: "pass",
|
||||
reviewedAt: "2023-12-23 10:30",
|
||||
reviewerId: "user1",
|
||||
reviewerName: "李四",
|
||||
createdAt: "2023-12-23 09:15",
|
||||
updatedAt: "2023-12-23 10:30"
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
fileId: "3",
|
||||
fileName: "烟草采购清单2023.xlsx",
|
||||
totalPoints: 12,
|
||||
passPoints: 5,
|
||||
warningPoints: 3,
|
||||
errorPoints: 4,
|
||||
score: 60,
|
||||
reviewStatus: "fail",
|
||||
reviewedAt: "2023-12-22 18:20",
|
||||
reviewerId: "system",
|
||||
reviewerName: "AI系统",
|
||||
createdAt: "2023-12-22 17:45",
|
||||
updatedAt: "2023-12-22 18:20"
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
fileId: "4",
|
||||
fileName: "2023年第三季度烟草销售报告.pdf",
|
||||
totalPoints: 18,
|
||||
passPoints: 16,
|
||||
warningPoints: 2,
|
||||
errorPoints: 0,
|
||||
score: 94,
|
||||
reviewStatus: "pass",
|
||||
reviewedAt: "2023-12-21 14:10",
|
||||
reviewerId: "user2",
|
||||
reviewerName: "王五",
|
||||
createdAt: "2023-12-21 13:30",
|
||||
updatedAt: "2023-12-21 14:10"
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
fileId: "5",
|
||||
fileName: "烟草品牌授权书.pdf",
|
||||
totalPoints: 10,
|
||||
passPoints: 6,
|
||||
warningPoints: 3,
|
||||
errorPoints: 1,
|
||||
score: 75,
|
||||
reviewStatus: "warning",
|
||||
reviewedAt: "2023-12-20 11:25",
|
||||
reviewerId: "system",
|
||||
reviewerName: "AI系统",
|
||||
createdAt: "2023-12-20 10:50",
|
||||
updatedAt: "2023-12-20 11:25"
|
||||
}
|
||||
];
|
||||
|
||||
// 根据查询条件过滤结果
|
||||
let filteredReviews = [...reviews];
|
||||
|
||||
if (keyword) {
|
||||
filteredReviews = filteredReviews.filter(review =>
|
||||
review.fileName.toLowerCase().includes(keyword.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
if (status) {
|
||||
filteredReviews = filteredReviews.filter(review =>
|
||||
review.reviewStatus === status
|
||||
);
|
||||
}
|
||||
|
||||
if (startDate) {
|
||||
const start = new Date(startDate);
|
||||
filteredReviews = filteredReviews.filter(review =>
|
||||
new Date(review.reviewedAt) >= start
|
||||
);
|
||||
}
|
||||
|
||||
if (endDate) {
|
||||
const end = new Date(endDate);
|
||||
end.setHours(23, 59, 59, 999);
|
||||
filteredReviews = filteredReviews.filter(review =>
|
||||
new Date(review.reviewedAt) <= end
|
||||
);
|
||||
}
|
||||
|
||||
// 分页
|
||||
const totalCount = filteredReviews.length;
|
||||
const totalPages = Math.ceil(totalCount / pageSize);
|
||||
const startIndex = (page - 1) * pageSize;
|
||||
const pagedReviews = filteredReviews.slice(startIndex, startIndex + pageSize);
|
||||
|
||||
return json<LoaderData>({
|
||||
reviews: pagedReviews,
|
||||
totalCount,
|
||||
currentPage: page,
|
||||
totalPages
|
||||
});
|
||||
}
|
||||
|
||||
export default function ReviewsList() {
|
||||
const { reviews, totalCount, currentPage, totalPages } = useLoaderData<typeof loader>();
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: "文件名称",
|
||||
key: "fileName",
|
||||
render: (review: ReviewResult) => (
|
||||
<Link
|
||||
to={`/reviews/${review.id}`}
|
||||
className="text-primary hover:text-primary-dark transition-colors"
|
||||
>
|
||||
{review.fileName}
|
||||
</Link>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: "评查状态",
|
||||
key: "reviewStatus",
|
||||
render: (review: ReviewResult) => (
|
||||
<span className={`status-badge status-${review.reviewStatus === 'pass' ? 'success' : review.reviewStatus === 'warning' ? 'warning' : 'error'}`}>
|
||||
<i className={`ri-${review.reviewStatus === 'pass' ? 'checkbox-circle' : review.reviewStatus === 'warning' ? 'error-warning' : 'close-circle'}-line mr-1`}></i>
|
||||
{review.reviewStatus === 'pass' ? '通过' : review.reviewStatus === 'warning' ? '警告' : '不通过'}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: "评查得分",
|
||||
key: "score",
|
||||
render: (review: ReviewResult) => (
|
||||
<span className={`font-medium ${review.score >= 90 ? 'text-success' : review.score >= 70 ? 'text-warning' : 'text-error'}`}>
|
||||
{review.score}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: "评查点",
|
||||
key: "points",
|
||||
render: (review: ReviewResult) => (
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-xs px-2 py-1 bg-gray-100 rounded-full">{review.totalPoints}项</span>
|
||||
<span className="text-xs px-2 py-1 bg-green-50 text-success rounded-full">{review.passPoints}</span>
|
||||
<span className="text-xs px-2 py-1 bg-yellow-50 text-warning rounded-full">{review.warningPoints}</span>
|
||||
<span className="text-xs px-2 py-1 bg-red-50 text-error rounded-full">{review.errorPoints}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: "评查时间",
|
||||
key: "reviewedAt",
|
||||
render: (review: ReviewResult) => review.reviewedAt
|
||||
},
|
||||
{
|
||||
title: "评查人",
|
||||
key: "reviewerName",
|
||||
render: (review: ReviewResult) => (
|
||||
<span className="flex items-center">
|
||||
<i className={`ri-${review.reviewerId === 'system' ? 'robot-line' : 'user-line'} mr-1 ${review.reviewerId === 'system' ? 'text-primary' : 'text-gray-600'}`}></i>
|
||||
{review.reviewerName}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "actions",
|
||||
render: (review: ReviewResult) => (
|
||||
<div className="space-x-2">
|
||||
<Link
|
||||
to={`/reviews/${review.id}`}
|
||||
className="btn-text"
|
||||
>
|
||||
<i className="ri-search-line mr-1"></i>
|
||||
查看详情
|
||||
</Link>
|
||||
<button className="btn-text">
|
||||
<i className="ri-download-line mr-1"></i>
|
||||
导出报告
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 flex justify-between items-center">
|
||||
<h2 className="text-xl font-medium">评查结果列表</h2>
|
||||
<Link to="/files/upload" className="btn-primary">
|
||||
<i className="ri-upload-cloud-line mr-1"></i>
|
||||
上传新文件
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Card className="mb-4">
|
||||
<form className="filter-form">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="form-group">
|
||||
<label htmlFor="keyword" className="form-label">关键词</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
id="keyword"
|
||||
name="keyword"
|
||||
className="form-input pl-8"
|
||||
placeholder="文件名称"
|
||||
/>
|
||||
<i className="ri-search-line absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="status" className="form-label">评查状态</label>
|
||||
<select id="status" name="status" className="form-select">
|
||||
<option value="">全部状态</option>
|
||||
<option value="pass">通过</option>
|
||||
<option value="warning">警告</option>
|
||||
<option value="fail">不通过</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="startDate" className="form-label">开始日期</label>
|
||||
<input type="date" id="startDate" name="startDate" className="form-input" />
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="endDate" className="form-label">结束日期</label>
|
||||
<input type="date" id="endDate" name="endDate" className="form-input" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end mt-4">
|
||||
<button type="reset" className="btn-default mr-2">
|
||||
<i className="ri-refresh-line mr-1"></i>
|
||||
重置
|
||||
</button>
|
||||
<button type="submit" className="btn-primary">
|
||||
<i className="ri-search-line mr-1"></i>
|
||||
查询
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div className="mb-3 text-gray-500">
|
||||
共 <span className="text-primary">{totalCount}</span> 条评查结果
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={reviews}
|
||||
rowKey="id"
|
||||
pagination={{
|
||||
current: currentPage,
|
||||
pageSize: 10,
|
||||
total: totalCount,
|
||||
totalPages: totalPages
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,264 +0,0 @@
|
||||
import { json, redirect, type ActionFunctionArgs, type LoaderFunctionArgs, type MetaFunction } from "@remix-run/node";
|
||||
import { Form, useLoaderData, useNavigation, useActionData } from "@remix-run/react";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export const meta: MetaFunction = ({ data }) => {
|
||||
return [
|
||||
{ title: `编辑评查点分组 - ${data?.group?.name || '加载中'} - 中国烟草AI合同及卷宗审核系统` },
|
||||
{ name: "description", content: "编辑评查点分组信息,包括名称、编码、描述和状态" },
|
||||
];
|
||||
};
|
||||
|
||||
// 模拟数据
|
||||
const MOCK_GROUPS = [
|
||||
{
|
||||
id: "1",
|
||||
name: "合同条款类",
|
||||
code: "CONTRACT",
|
||||
description: "关于合同条款的评查点分组",
|
||||
status: "active",
|
||||
sortOrder: 1,
|
||||
createdAt: "2024-01-01T00:00:00Z",
|
||||
updatedAt: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "合规性类",
|
||||
code: "COMPLIANCE",
|
||||
description: "关于合规性的评查点分组",
|
||||
status: "active",
|
||||
sortOrder: 2,
|
||||
createdAt: "2024-01-01T00:00:00Z",
|
||||
updatedAt: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "风险提示类",
|
||||
code: "RISK",
|
||||
description: "关于风险提示的评查点分组",
|
||||
status: "inactive",
|
||||
sortOrder: 3,
|
||||
createdAt: "2024-01-01T00:00:00Z",
|
||||
updatedAt: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
export async function loader({ params }: LoaderFunctionArgs) {
|
||||
const { groupId } = params;
|
||||
|
||||
// 真实环境中,这里会调用API获取数据
|
||||
// const response = await fetch(`${process.env.API_URL}/api/rule-groups/${groupId}`);
|
||||
// if (response.status === 404) {
|
||||
// throw new Response("评查点分组不存在", { status: 404 });
|
||||
// }
|
||||
// if (!response.ok) {
|
||||
// throw new Response("获取评查点分组失败", { status: response.status });
|
||||
// }
|
||||
// const group = await response.json();
|
||||
|
||||
// 使用模拟数据
|
||||
const group = MOCK_GROUPS.find(g => g.id === groupId);
|
||||
if (!group) {
|
||||
throw new Response("评查点分组不存在", { status: 404 });
|
||||
}
|
||||
|
||||
return json({ group });
|
||||
}
|
||||
|
||||
export async function action({ request, params }: ActionFunctionArgs) {
|
||||
const formData = await request.formData();
|
||||
const groupId = params.groupId;
|
||||
|
||||
const name = formData.get("name");
|
||||
const code = formData.get("code");
|
||||
const description = formData.get("description");
|
||||
const status = formData.get("status");
|
||||
const sortOrder = formData.get("sortOrder");
|
||||
|
||||
// 基本验证
|
||||
const errors = {};
|
||||
if (!name) errors.name = "分组名称不能为空";
|
||||
if (!code) errors.code = "分组编码不能为空";
|
||||
if (Object.keys(errors).length > 0) {
|
||||
return json({ errors, values: Object.fromEntries(formData) });
|
||||
}
|
||||
|
||||
// 构建更新数据
|
||||
const updateData = {
|
||||
name,
|
||||
code,
|
||||
description,
|
||||
status,
|
||||
sortOrder: Number(sortOrder) || 0,
|
||||
};
|
||||
|
||||
// 真实环境中,这里会调用API更新数据
|
||||
// const response = await fetch(`${process.env.API_URL}/api/rule-groups/${groupId}`, {
|
||||
// method: "PUT",
|
||||
// headers: { "Content-Type": "application/json" },
|
||||
// body: JSON.stringify(updateData),
|
||||
// });
|
||||
//
|
||||
// if (!response.ok) {
|
||||
// throw new Response("更新评查点分组失败", { status: response.status });
|
||||
// }
|
||||
|
||||
// 模拟更新成功
|
||||
console.log('保存分组数据:', { id: groupId, ...updateData });
|
||||
|
||||
// 重定向回列表页
|
||||
return redirect('/rule-groups');
|
||||
}
|
||||
|
||||
export default function EditRuleGroup() {
|
||||
const { group } = useLoaderData<typeof loader>();
|
||||
const actionData = useActionData<typeof action>();
|
||||
const navigation = useNavigation();
|
||||
|
||||
const isSubmitting = navigation.state === "submitting";
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name: group.name,
|
||||
code: group.code,
|
||||
description: group.description || "",
|
||||
status: group.status,
|
||||
sortOrder: group.sortOrder.toString(),
|
||||
});
|
||||
|
||||
// 当actionData中有错误时,保留用户输入的值
|
||||
useEffect(() => {
|
||||
if (actionData?.values) {
|
||||
setFormData({
|
||||
name: actionData.values.name,
|
||||
code: actionData.values.code,
|
||||
description: actionData.values.description || "",
|
||||
status: actionData.values.status,
|
||||
sortOrder: actionData.values.sortOrder,
|
||||
});
|
||||
}
|
||||
}, [actionData]);
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="edit-rule-group">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-medium">编辑评查点分组</h1>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<Form method="post" className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="form-group">
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
分组名称 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
className={`w-full h-10 px-3 border ${actionData?.errors?.name ? 'border-red-500' : 'border-gray-300'} rounded-md focus:border-primary focus:ring-1 focus:ring-primary`}
|
||||
placeholder="请输入分组名称"
|
||||
/>
|
||||
{actionData?.errors?.name && (
|
||||
<p className="mt-1 text-sm text-red-500">{actionData.errors.name}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="code" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
分组编码 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="code"
|
||||
name="code"
|
||||
value={formData.code}
|
||||
onChange={handleChange}
|
||||
className={`w-full h-10 px-3 border ${actionData?.errors?.code ? 'border-red-500' : 'border-gray-300'} rounded-md focus:border-primary focus:ring-1 focus:ring-primary`}
|
||||
placeholder="请输入分组编码"
|
||||
/>
|
||||
{actionData?.errors?.code && (
|
||||
<p className="mt-1 text-sm text-red-500">{actionData.errors.code}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="description" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
分组描述
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
name="description"
|
||||
value={formData.description}
|
||||
onChange={handleChange}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:border-primary focus:ring-1 focus:ring-primary"
|
||||
placeholder="请输入分组描述"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="form-group">
|
||||
<label htmlFor="status" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
状态
|
||||
</label>
|
||||
<select
|
||||
id="status"
|
||||
name="status"
|
||||
value={formData.status}
|
||||
onChange={handleChange}
|
||||
className="w-full h-10 px-3 border border-gray-300 rounded-md focus:border-primary focus:ring-1 focus:ring-primary"
|
||||
>
|
||||
<option value="active">启用</option>
|
||||
<option value="inactive">禁用</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="sortOrder" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
排序
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="sortOrder"
|
||||
name="sortOrder"
|
||||
value={formData.sortOrder}
|
||||
onChange={handleChange}
|
||||
className="w-full h-10 px-3 border border-gray-300 rounded-md focus:border-primary focus:ring-1 focus:ring-primary"
|
||||
placeholder="请输入排序值"
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3 pt-4 border-t border-gray-100">
|
||||
<a
|
||||
href="/rule-groups"
|
||||
className="ant-btn ant-btn-default"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
window.history.back();
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</a>
|
||||
<button
|
||||
type="submit"
|
||||
className="ant-btn ant-btn-primary"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? '保存中...' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,10 @@ import { Table } from "~/components/ui/Table";
|
||||
import { FilterPanel, FilterSelect, SearchFilter } from "~/components/ui/FilterPanel";
|
||||
import { Pagination } from "~/components/ui/Pagination";
|
||||
|
||||
export function links() {
|
||||
return [{ rel: "stylesheet", href: indexStyles }];
|
||||
}
|
||||
|
||||
// 定义数据类型
|
||||
interface RuleGroup {
|
||||
id: string;
|
||||
@@ -21,10 +25,6 @@ interface RuleGroup {
|
||||
children?: RuleGroup[];
|
||||
}
|
||||
|
||||
export const handle = {
|
||||
breadcrumb: "评查点分组"
|
||||
};
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
{ title: "评查点分组 - 中国烟草AI合同及卷宗审核系统" },
|
||||
@@ -32,9 +32,6 @@ export const meta: MetaFunction = () => {
|
||||
];
|
||||
};
|
||||
|
||||
export function links() {
|
||||
return [{ rel: "stylesheet", href: indexStyles }];
|
||||
}
|
||||
|
||||
// 模拟数据
|
||||
const MOCK_GROUPS: RuleGroup[] = [
|
||||
@@ -263,20 +260,21 @@ export default function RuleGroupsIndex() {
|
||||
key: "operation",
|
||||
width: "180px",
|
||||
render: (_: unknown, record: RuleGroup) => (
|
||||
<>
|
||||
<button
|
||||
className="ant-btn ant-btn-text ant-btn-sm text-primary"
|
||||
onClick={() => navigate(`/rule-groups/${record.id}`)}
|
||||
<div className="operations-cell">
|
||||
<button
|
||||
onClick={() => navigate(`/rule-groups/new?id=${record.id}`)}
|
||||
className="operation-btn"
|
||||
>
|
||||
<i className="ri-edit-line"></i> 编辑
|
||||
</button>
|
||||
<button
|
||||
className="ant-btn ant-btn-text ant-btn-sm text-error"
|
||||
type="button"
|
||||
className="operation-btn !text-[--color-error]"
|
||||
onClick={() => handleDeleteGroup(record.id)}
|
||||
>
|
||||
<i className="ri-delete-bin-line"></i> 删除
|
||||
</button>
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
+458
-166
@@ -1,205 +1,497 @@
|
||||
// app/routes/rule-groups.new.tsx
|
||||
import { json, redirect, type ActionFunctionArgs, type MetaFunction } from "@remix-run/node";
|
||||
import { Form, useNavigation, useActionData } from "@remix-run/react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { redirect, json, type ActionFunctionArgs, type LoaderFunctionArgs, type MetaFunction } from "@remix-run/node";
|
||||
import { useLoaderData, useActionData, useNavigation, Form } from "@remix-run/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "~/components/ui/Button";
|
||||
import { Card } from "~/components/ui/Card";
|
||||
import ruleGroupsNewStyles from "~/styles/pages/rule-groups_new.css?url";
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
// 类型定义
|
||||
interface RuleGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
description?: string;
|
||||
status: 'active' | 'inactive';
|
||||
parentId?: string | null;
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
interface ParentGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// 定义加载器返回数据类型
|
||||
export interface LoaderData {
|
||||
group?: RuleGroup;
|
||||
parentGroups: ParentGroup[];
|
||||
isEdit: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// 定义action返回数据类型
|
||||
export interface ActionData {
|
||||
success?: boolean;
|
||||
errors?: {
|
||||
name?: string;
|
||||
code?: string;
|
||||
parentId?: string;
|
||||
general?: string;
|
||||
};
|
||||
values?: Record<string, string>;
|
||||
}
|
||||
|
||||
// 样式链接
|
||||
export function links() {
|
||||
return [{ rel: "stylesheet", href: ruleGroupsNewStyles }];
|
||||
}
|
||||
|
||||
// 动态面包屑
|
||||
export const handle = {
|
||||
breadcrumb: (data: LoaderData) => {
|
||||
return data.isEdit ? "编辑分组" : "新增分组";
|
||||
}
|
||||
};
|
||||
|
||||
// 页面元数据
|
||||
export const meta: MetaFunction = ({ location }) => {
|
||||
const isEdit = new URLSearchParams(location.search).has("id");
|
||||
const title = isEdit ? "编辑评查点分组" : "新建评查点分组";
|
||||
|
||||
return [
|
||||
{ title: "新建评查点分组 - 中国烟草AI合同及卷宗审核系统" },
|
||||
{ title: `${title} - 中国烟草AI合同及卷宗审核系统` },
|
||||
{ name: "description", content: "创建新的评查点分组,包括分组名称、编码、描述和状态" },
|
||||
];
|
||||
};
|
||||
|
||||
// 数据加载器
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
console.log("rule-groups.new loader被调用,URL:", request.url);
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const id = url.searchParams.get("id");
|
||||
console.log("获取到的ID参数:", id);
|
||||
|
||||
// 获取一级分组列表 (用于选择父级分组)
|
||||
const parentGroups: ParentGroup[] = [
|
||||
{ id: "1", name: "合同基本要素检查" },
|
||||
{ id: "4", name: "销售合同专项检查" },
|
||||
{ id: "5", name: "行政处罚规范性检查" }
|
||||
];
|
||||
|
||||
// 简化这里,初始时不获取数据,避免可能的错误
|
||||
let group: RuleGroup | undefined = undefined;
|
||||
|
||||
// 如果有ID才尝试获取数据
|
||||
if (id) {
|
||||
// 简化的模拟数据
|
||||
const demoData: Record<string, RuleGroup> = {
|
||||
"1": {
|
||||
id: "1",
|
||||
name: "合同基本要素检查",
|
||||
code: "contract-base",
|
||||
description: "检查合同基本要素是否完整规范",
|
||||
status: "active",
|
||||
parentId: null,
|
||||
sortOrder: 0
|
||||
},
|
||||
"2": {
|
||||
id: "2",
|
||||
name: "必备要素检查",
|
||||
code: "essential-elements",
|
||||
description: "检查合同中的必要信息项是否齐全",
|
||||
status: "active",
|
||||
parentId: "1",
|
||||
sortOrder: 1
|
||||
},
|
||||
"3": {
|
||||
id: "3",
|
||||
name: "合同主体检查",
|
||||
code: "contract-parties",
|
||||
description: "检查合同主体信息是否规范",
|
||||
status: "active",
|
||||
parentId: "1",
|
||||
sortOrder: 2
|
||||
},
|
||||
"4": {
|
||||
id: "4",
|
||||
name: "销售合同专项检查",
|
||||
code: "contract-sales",
|
||||
description: "针对销售合同的专项合规检查",
|
||||
status: "active",
|
||||
parentId: null,
|
||||
sortOrder: 3
|
||||
},
|
||||
"5": {
|
||||
id: "5",
|
||||
name: "行政处罚规范性检查",
|
||||
code: "punishment",
|
||||
description: "对行政处罚文书的合规性检查",
|
||||
status: "inactive",
|
||||
parentId: null,
|
||||
sortOrder: 4
|
||||
}
|
||||
};
|
||||
|
||||
group = demoData[id];
|
||||
console.log("找到的group数据:", group);
|
||||
}
|
||||
|
||||
// 返回简化的数据结构
|
||||
return json<LoaderData>({
|
||||
group,
|
||||
parentGroups,
|
||||
isEdit: !!group,
|
||||
error: undefined // 显式返回error字段,无错误时为undefined
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("loader函数出错:", error);
|
||||
// 返回一个基本的响应,避免500错误
|
||||
return json<LoaderData>({
|
||||
group: undefined,
|
||||
parentGroups: [],
|
||||
isEdit: false,
|
||||
error: "加载数据时出错"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 表单处理
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
const formData = await request.formData();
|
||||
|
||||
const name = formData.get("name");
|
||||
const code = formData.get("code");
|
||||
const description = formData.get("description");
|
||||
const status = formData.get("status") || "active";
|
||||
const sortOrder = formData.get("sortOrder") || "0";
|
||||
// 提取表单数据
|
||||
const id = formData.get("id") as string | null;
|
||||
const name = formData.get("name") as string;
|
||||
const code = formData.get("code") as string;
|
||||
const description = formData.get("description") as string;
|
||||
const status = formData.get("status") as string || "active";
|
||||
const groupType = formData.get("groupType") as string;
|
||||
const parentId = groupType === "secondary" ? formData.get("parentId") as string : null;
|
||||
const sortOrder = parseInt(formData.get("sortOrder") as string || "0", 10);
|
||||
|
||||
// 基本验证
|
||||
const errors = {};
|
||||
if (!name) errors.name = "分组名称不能为空";
|
||||
if (!code) errors.code = "分组编码不能为空";
|
||||
if (Object.keys(errors).length > 0) {
|
||||
return json({ errors, values: Object.fromEntries(formData) });
|
||||
// 表单验证
|
||||
const errors: ActionData["errors"] = {};
|
||||
|
||||
if (!name || name.trim() === "") {
|
||||
errors.name = "分组名称不能为空";
|
||||
}
|
||||
|
||||
// 构建创建数据
|
||||
const createData = {
|
||||
name,
|
||||
code,
|
||||
description,
|
||||
if (!code || code.trim() === "") {
|
||||
errors.code = "分组编码不能为空";
|
||||
} else if (!/^[a-zA-Z0-9-]+$/.test(code)) {
|
||||
errors.code = "分组编码只能包含字母、数字和连字符";
|
||||
}
|
||||
|
||||
if (groupType === "secondary" && (!parentId || parentId.trim() === "")) {
|
||||
errors.parentId = "请选择上级分组";
|
||||
}
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
return json<ActionData>({
|
||||
errors,
|
||||
values: Object.fromEntries(formData) as Record<string, string>
|
||||
});
|
||||
}
|
||||
|
||||
// 构建保存数据
|
||||
const saveData = {
|
||||
id,
|
||||
name: name.trim(),
|
||||
code: code.trim(),
|
||||
description: description?.trim() || "",
|
||||
status,
|
||||
sortOrder: Number(sortOrder) || 0,
|
||||
parentId,
|
||||
sortOrder
|
||||
};
|
||||
|
||||
// 真实环境中,这里会调用API创建数据
|
||||
// const response = await fetch(`${process.env.API_URL}/api/rule-groups`, {
|
||||
// method: "POST",
|
||||
// headers: { "Content-Type": "application/json" },
|
||||
// body: JSON.stringify(createData),
|
||||
// });
|
||||
//
|
||||
// if (!response.ok) {
|
||||
// throw new Response("创建评查点分组失败", { status: response.status });
|
||||
// }
|
||||
|
||||
// 模拟创建成功
|
||||
console.log('创建分组数据:', createData);
|
||||
|
||||
// 重定向回列表页
|
||||
return redirect('/rule-groups');
|
||||
try {
|
||||
// 实际应用中应调用API
|
||||
console.log("保存分组数据:", saveData);
|
||||
|
||||
// const response = await fetch(`${process.env.API_BASE_URL}/api/rule-groups${id ? `/${id}` : ''}`, {
|
||||
// method: id ? "PUT" : "POST",
|
||||
// headers: {
|
||||
// "Content-Type": "application/json",
|
||||
// },
|
||||
// body: JSON.stringify(saveData),
|
||||
// });
|
||||
//
|
||||
// if (!response.ok) {
|
||||
// throw new Error(`保存失败: ${response.status}`);
|
||||
// }
|
||||
|
||||
// 保存成功,重定向到列表页
|
||||
return redirect("/rule-groups");
|
||||
} catch (error) {
|
||||
console.error("保存分组失败:", error);
|
||||
return json<ActionData>({
|
||||
success: false,
|
||||
errors: {
|
||||
general: "保存分组失败,请稍后重试"
|
||||
},
|
||||
values: Object.fromEntries(formData) as Record<string, string>
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default function NewRuleGroup() {
|
||||
// 页面组件
|
||||
export default function RuleGroupNew() {
|
||||
// 所有Hooks必须在组件顶部无条件调用
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const actionData = useActionData<typeof action>();
|
||||
const navigation = useNavigation();
|
||||
|
||||
const isSubmitting = navigation.state === "submitting";
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
code: "",
|
||||
description: "",
|
||||
status: "active",
|
||||
sortOrder: "0",
|
||||
});
|
||||
// 表单状态
|
||||
const [groupType, setGroupType] = useState<"primary" | "secondary">("primary");
|
||||
const [showParentSelect, setShowParentSelect] = useState(false);
|
||||
|
||||
// 当actionData中有错误时,保留用户输入的值
|
||||
// 解构数据
|
||||
const { group, parentGroups, isEdit, error } = data;
|
||||
|
||||
// 初始化表单状态
|
||||
useEffect(() => {
|
||||
if (actionData?.values) {
|
||||
setFormData({
|
||||
name: actionData.values.name,
|
||||
code: actionData.values.code,
|
||||
description: actionData.values.description || "",
|
||||
status: actionData.values.status,
|
||||
sortOrder: actionData.values.sortOrder,
|
||||
});
|
||||
if (group) {
|
||||
if (group.parentId) {
|
||||
setGroupType("secondary");
|
||||
setShowParentSelect(true);
|
||||
} else {
|
||||
setGroupType("primary");
|
||||
setShowParentSelect(false);
|
||||
}
|
||||
}
|
||||
}, [actionData]);
|
||||
}, [group]);
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
// 处理分组类型变更
|
||||
const handleGroupTypeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value as "primary" | "secondary";
|
||||
setGroupType(value);
|
||||
setShowParentSelect(value === "secondary");
|
||||
};
|
||||
|
||||
// 如果加载数据时出错,显示错误信息
|
||||
if (error) {
|
||||
return (
|
||||
<div className="error-container">
|
||||
<h1>加载出错</h1>
|
||||
<p>{error}</p>
|
||||
<Button type="default" to="/rule-groups">
|
||||
<i className="ri-arrow-left-line"></i> 返回列表
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="new-rule-group">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-medium">新建评查点分组</h1>
|
||||
<div className="rule-group-new-page">
|
||||
{/* 页面头部 */}
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">{isEdit ? "编辑评查点分组" : "新增评查点分组"}</h1>
|
||||
<p className="page-subtitle">创建新的评查点分组,用于组织管理评查点</p>
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<Button
|
||||
type="default"
|
||||
to="/rule-groups"
|
||||
className="mr-3"
|
||||
>
|
||||
<i className="ri-arrow-left-line"></i> 返回列表
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
form="group-form"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<i className="ri-save-line"></i> {isSubmitting ? '保存中...' : '保存分组'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<Form method="post" className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="form-group">
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
分组名称 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
className={`w-full h-10 px-3 border ${actionData?.errors?.name ? 'border-red-500' : 'border-gray-300'} rounded-md focus:border-primary focus:ring-1 focus:ring-primary`}
|
||||
placeholder="请输入分组名称"
|
||||
/>
|
||||
{actionData?.errors?.name && (
|
||||
<p className="mt-1 text-sm text-red-500">{actionData.errors.name}</p>
|
||||
<div className="form-container">
|
||||
{/* 提示信息 */}
|
||||
<div className="info-message">
|
||||
<i className="ri-information-line"></i>
|
||||
<p>评查点分组用于对评查点进行分类管理,合理的分组结构有助于更高效地组织和查找评查点。</p>
|
||||
</div>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{actionData?.errors?.general && (
|
||||
<div className="general-error">
|
||||
<i className="ri-error-warning-line mr-2"></i>
|
||||
{actionData.errors.general}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 表单 */}
|
||||
<Form method="post" id="group-form">
|
||||
{/* 如果是编辑模式,添加ID */}
|
||||
{group?.id && <input type="hidden" name="id" value={group.id} />}
|
||||
|
||||
{/* 基本信息区域 */}
|
||||
<Card className="form-section">
|
||||
<div className="form-section-header">
|
||||
<i className="ri-file-info-line"></i>
|
||||
<h3>基本信息</h3>
|
||||
</div>
|
||||
<div className="form-section-body">
|
||||
{/* 分组类型选择 */}
|
||||
<div className="form-group">
|
||||
<legend className="form-label" id="groupTypeLabel">
|
||||
分组类型 <span className="required-mark">*</span>
|
||||
</legend>
|
||||
<div className="radio-group" role="radiogroup" aria-labelledby="groupTypeLabel">
|
||||
<label className="radio-item" htmlFor="groupType-primary">
|
||||
<input
|
||||
type="radio"
|
||||
id="groupType-primary"
|
||||
name="groupType"
|
||||
className="radio-input"
|
||||
value="primary"
|
||||
checked={groupType === "primary"}
|
||||
onChange={handleGroupTypeChange}
|
||||
/>
|
||||
<span>一级分组</span>
|
||||
</label>
|
||||
<label className="radio-item" htmlFor="groupType-secondary">
|
||||
<input
|
||||
type="radio"
|
||||
id="groupType-secondary"
|
||||
name="groupType"
|
||||
className="radio-input"
|
||||
value="secondary"
|
||||
checked={groupType === "secondary"}
|
||||
onChange={handleGroupTypeChange}
|
||||
/>
|
||||
<span>二级分组</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-tip">一级分组作为顶层分类,二级分组需要选择所属的一级分组</div>
|
||||
</div>
|
||||
|
||||
{/* 上级分组选择 */}
|
||||
{showParentSelect && (
|
||||
<div className="form-group">
|
||||
<label htmlFor="parentId" className="form-label">
|
||||
上级分组 <span className="required-mark">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="parentId"
|
||||
name="parentId"
|
||||
className={`form-select ${actionData?.errors?.parentId ? 'error' : ''}`}
|
||||
defaultValue={group?.parentId || ""}
|
||||
>
|
||||
<option value="">请选择上级分组</option>
|
||||
{parentGroups.map((parent) => (
|
||||
<option key={parent.id} value={parent.id}>
|
||||
{parent.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{actionData?.errors?.parentId && (
|
||||
<div className="form-error">{actionData.errors.parentId}</div>
|
||||
)}
|
||||
<div className="form-tip">选择此分组所属的上级分组</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 分组编码和名称 */}
|
||||
<div className="form-row">
|
||||
<div className="form-col">
|
||||
<div className="form-group">
|
||||
<label htmlFor="code" className="form-label">
|
||||
分组编码 <span className="required-mark">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="code"
|
||||
name="code"
|
||||
className={`form-input ${actionData?.errors?.code ? 'error' : ''}`}
|
||||
defaultValue={group?.code || actionData?.values?.code || ""}
|
||||
placeholder="请输入分组编码,如contract-base"
|
||||
/>
|
||||
{actionData?.errors?.code && (
|
||||
<div className="form-error">{actionData.errors.code}</div>
|
||||
)}
|
||||
<div className="form-tip">编码只能包含字母、数字和连字符,且必须唯一</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-col">
|
||||
<div className="form-group">
|
||||
<label htmlFor="name" className="form-label">
|
||||
分组名称 <span className="required-mark">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
className={`form-input ${actionData?.errors?.name ? 'error' : ''}`}
|
||||
defaultValue={group?.name || actionData?.values?.name || ""}
|
||||
placeholder="请输入分组名称,如合同基本要素检查"
|
||||
/>
|
||||
{actionData?.errors?.name && (
|
||||
<div className="form-error">{actionData.errors.name}</div>
|
||||
)}
|
||||
<div className="form-tip">请使用简洁明了的名称,不超过30个字符</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="code" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
分组编码 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="code"
|
||||
name="code"
|
||||
value={formData.code}
|
||||
onChange={handleChange}
|
||||
className={`w-full h-10 px-3 border ${actionData?.errors?.code ? 'border-red-500' : 'border-gray-300'} rounded-md focus:border-primary focus:ring-1 focus:ring-primary`}
|
||||
placeholder="请输入分组编码"
|
||||
/>
|
||||
{actionData?.errors?.code && (
|
||||
<p className="mt-1 text-sm text-red-500">{actionData.errors.code}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="description" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
分组描述
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
name="description"
|
||||
value={formData.description}
|
||||
onChange={handleChange}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:border-primary focus:ring-1 focus:ring-primary"
|
||||
placeholder="请输入分组描述"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="form-group">
|
||||
<label htmlFor="status" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
状态
|
||||
</label>
|
||||
<select
|
||||
id="status"
|
||||
name="status"
|
||||
value={formData.status}
|
||||
onChange={handleChange}
|
||||
className="w-full h-10 px-3 border border-gray-300 rounded-md focus:border-primary focus:ring-1 focus:ring-primary"
|
||||
>
|
||||
<option value="active">启用</option>
|
||||
<option value="inactive">禁用</option>
|
||||
</select>
|
||||
{/* 详细配置区域 */}
|
||||
<Card className="form-section">
|
||||
<div className="form-section-header">
|
||||
<i className="ri-settings-4-line"></i>
|
||||
<h3>详细配置</h3>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="sortOrder" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
排序
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="sortOrder"
|
||||
name="sortOrder"
|
||||
value={formData.sortOrder}
|
||||
onChange={handleChange}
|
||||
className="w-full h-10 px-3 border border-gray-300 rounded-md focus:border-primary focus:ring-1 focus:ring-primary"
|
||||
placeholder="请输入排序值"
|
||||
min="0"
|
||||
/>
|
||||
<div className="form-section-body">
|
||||
{/* 分组描述 */}
|
||||
<div className="form-group">
|
||||
<label htmlFor="description" className="form-label">分组描述</label>
|
||||
<textarea
|
||||
id="description"
|
||||
name="description"
|
||||
className="form-textarea"
|
||||
defaultValue={group?.description || actionData?.values?.description || ""}
|
||||
placeholder="请输入分组描述,包括适用场景、分组目的等"
|
||||
></textarea>
|
||||
<div className="form-tip">详细描述有助于其他用户了解该分组的用途</div>
|
||||
</div>
|
||||
|
||||
{/* 状态 */}
|
||||
<div className="form-group" style={{ maxWidth: "400px" }}>
|
||||
<label htmlFor="status" className="form-label">状态</label>
|
||||
<select
|
||||
id="status"
|
||||
name="status"
|
||||
className="form-select"
|
||||
defaultValue={group?.status || actionData?.values?.status || "active"}
|
||||
>
|
||||
<option value="active">启用</option>
|
||||
<option value="inactive">禁用</option>
|
||||
</select>
|
||||
<div className="form-tip">禁用状态的分组及其下的评查点将不会参与评查</div>
|
||||
</div>
|
||||
|
||||
{/* 排序 */}
|
||||
<div className="form-group" style={{ maxWidth: "400px" }}>
|
||||
<label htmlFor="sortOrder" className="form-label">排序</label>
|
||||
<input
|
||||
type="number"
|
||||
id="sortOrder"
|
||||
name="sortOrder"
|
||||
className="form-input"
|
||||
defaultValue={group?.sortOrder?.toString() || actionData?.values?.sortOrder || "0"}
|
||||
placeholder="请输入排序值,数字越小排序越靠前"
|
||||
min="0"
|
||||
/>
|
||||
<div className="form-tip">用于设置分组在列表中的显示顺序,默认为0</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3 pt-4 border-t border-gray-100">
|
||||
<a
|
||||
href="/rule-groups"
|
||||
className="ant-btn ant-btn-default"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
window.history.back();
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</a>
|
||||
<button
|
||||
type="submit"
|
||||
className="ant-btn ant-btn-primary"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? '创建中...' : '创建'}
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
// app/routes/rule-groups.tsx
|
||||
import { Outlet } from "@remix-run/react";
|
||||
|
||||
/**
|
||||
* 评查点分组管理 - 父级路由
|
||||
* 仅作为嵌套路由的容器,不包含具体内容
|
||||
*/
|
||||
export const handle = {
|
||||
breadcrumb: "评查规则库"
|
||||
};
|
||||
|
||||
export default function RuleGroupsLayout() {
|
||||
return <Outlet />
|
||||
breadcrumb: "评查点分组"
|
||||
}
|
||||
export default function RuleGroups() {
|
||||
return <Outlet />;
|
||||
}
|
||||
+206
-174
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { json, type MetaFunction, type LoaderFunctionArgs, redirect } from "@remix-run/node";
|
||||
import { useLoaderData, useSearchParams, useSubmit } from "@remix-run/react";
|
||||
import { useLoaderData, useSearchParams, useSubmit,Link } from "@remix-run/react";
|
||||
import { Button } from '~/components/ui/Button';
|
||||
import { Card } from '~/components/ui/Card';
|
||||
import { Tag } from '~/components/ui/Tag';
|
||||
@@ -9,219 +9,245 @@ import rulesStyles from "~/styles/pages/rules_index.css?url";
|
||||
import type { Rule } from '~/models/rule';
|
||||
import { RULE_TYPE_LABELS, RULE_TYPE_COLORS, RULE_PRIORITY_LABELS, RULE_PRIORITY_COLORS } from '~/models/rule';
|
||||
import type { TagColor } from '~/components/ui/Tag';
|
||||
import { Link } from '@remix-run/react';
|
||||
import { Table } from '~/components/ui/Table';
|
||||
import { FilterPanel, FilterSelect, SearchFilter } from '~/components/ui/FilterPanel';
|
||||
import { Pagination } from '~/components/ui/Pagination';
|
||||
// import { getRulesList } from '~/api/evaluation_points/rules';
|
||||
|
||||
export const links = () => [
|
||||
{ rel: "stylesheet", href: rulesStyles }
|
||||
];
|
||||
|
||||
// export const handle = {
|
||||
// breadcrumb: "评查点列表"
|
||||
// };
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
{ title: "中国烟草AI合同及卷宗审核系统 - 评查点列表" },
|
||||
{ name: "description", content: "管理评查点规则,支持根据类型、规则组和状态进行筛选" },
|
||||
{ name: "rules", content: "管理评查点规则,支持根据类型、规则组和状态进行筛选" },
|
||||
{ name: "keywords", content: "评查点,合同审核,规则管理,中国烟草" }
|
||||
];
|
||||
};
|
||||
|
||||
interface LoaderData {
|
||||
rules: Rule[];
|
||||
groups: {
|
||||
id: string;
|
||||
name: string;
|
||||
}[];
|
||||
totalCount: number;
|
||||
currentPage: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
}
|
||||
// 模拟数据 - 用于开发阶段展示UI
|
||||
const mockRules: Rule[] = [
|
||||
{
|
||||
id: '1',
|
||||
code: 'EP001',
|
||||
name: '合同名称要素检查',
|
||||
ruleType: 'essential',
|
||||
ruleGroupId: '1',
|
||||
groupName: '合同基本要素类检查',
|
||||
priority: 'high',
|
||||
description: '检查合同是否包含清晰的合同名称',
|
||||
checkMethod: 'automatic',
|
||||
prompt: '查找文档中的合同名称',
|
||||
isActive: true,
|
||||
createdAt: '2024-03-15T08:30:00Z',
|
||||
updatedAt: '2024-03-15T08:30:00Z'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
code: 'EP002',
|
||||
name: '合同编号要素检查',
|
||||
ruleType: 'essential',
|
||||
ruleGroupId: '1',
|
||||
groupName: '合同基本要素类检查',
|
||||
priority: 'high',
|
||||
description: '检查合同是否包含唯一的合同编号',
|
||||
checkMethod: 'automatic',
|
||||
prompt: '查找文档中的合同编号',
|
||||
isActive: true,
|
||||
createdAt: '2024-03-15T09:15:00Z',
|
||||
updatedAt: '2024-03-15T09:15:00Z'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
code: 'EP003',
|
||||
name: '合同主体资格检查',
|
||||
ruleType: 'legal',
|
||||
ruleGroupId: '2',
|
||||
groupName: '销售合同专项检查',
|
||||
priority: 'medium',
|
||||
description: '检查合同签署方是否具有合法的主体资格',
|
||||
checkMethod: 'manual',
|
||||
prompt: '确认合同签署方的法律主体资格',
|
||||
isActive: true,
|
||||
createdAt: '2024-03-16T10:20:00Z',
|
||||
updatedAt: '2024-03-16T10:20:00Z'
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
code: 'EP004',
|
||||
name: '付款条件检查',
|
||||
ruleType: 'content',
|
||||
ruleGroupId: '2',
|
||||
groupName: '销售合同专项检查',
|
||||
priority: 'medium',
|
||||
description: '检查合同中的付款条件是否明确',
|
||||
checkMethod: 'automatic',
|
||||
prompt: '提取文档中的付款条件相关内容',
|
||||
isActive: true,
|
||||
createdAt: '2024-03-17T11:30:00Z',
|
||||
updatedAt: '2024-03-17T11:30:00Z'
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
code: 'EP005',
|
||||
name: '违约责任条款检查',
|
||||
ruleType: 'legal',
|
||||
ruleGroupId: '3',
|
||||
groupName: '采购合同专项检查',
|
||||
priority: 'high',
|
||||
description: '检查合同是否包含违约责任条款',
|
||||
checkMethod: 'mixed',
|
||||
prompt: '提取文档中的违约责任相关条款',
|
||||
isActive: true,
|
||||
createdAt: '2024-03-18T13:45:00Z',
|
||||
updatedAt: '2024-03-18T13:45:00Z'
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
code: 'EP006',
|
||||
name: '合同文本格式检查',
|
||||
ruleType: 'format',
|
||||
ruleGroupId: '1',
|
||||
groupName: '合同基本要素类检查',
|
||||
priority: 'low',
|
||||
description: '检查合同文本格式是否符合规范',
|
||||
checkMethod: 'automatic',
|
||||
prompt: '检查文档的整体格式规范性',
|
||||
isActive: false,
|
||||
createdAt: '2024-03-19T14:50:00Z',
|
||||
updatedAt: '2024-03-19T14:50:00Z'
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
code: 'EP007',
|
||||
name: '专卖许可证有效性检查',
|
||||
ruleType: 'legal',
|
||||
ruleGroupId: '4',
|
||||
groupName: '专卖许可证审核规则',
|
||||
priority: 'high',
|
||||
description: '检查专卖许可证是否在有效期内',
|
||||
checkMethod: 'automatic',
|
||||
prompt: '提取专卖许可证有效期信息并判断有效性',
|
||||
isActive: true,
|
||||
createdAt: '2024-03-20T15:55:00Z',
|
||||
updatedAt: '2024-03-20T15:55:00Z'
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
code: 'EP008',
|
||||
name: '处罚决定书格式检查',
|
||||
ruleType: 'format',
|
||||
ruleGroupId: '5',
|
||||
groupName: '行政处罚规范性检查',
|
||||
priority: 'medium',
|
||||
description: '检查行政处罚决定书格式是否规范',
|
||||
checkMethod: 'automatic',
|
||||
prompt: '检查处罚决定书的格式规范性',
|
||||
isActive: true,
|
||||
createdAt: '2024-03-21T16:00:00Z',
|
||||
updatedAt: '2024-03-21T16:00:00Z'
|
||||
},
|
||||
{
|
||||
id: '9',
|
||||
code: 'EP009',
|
||||
name: '处罚依据合法性检查',
|
||||
ruleType: 'legal',
|
||||
ruleGroupId: '5',
|
||||
groupName: '行政处罚规范性检查',
|
||||
priority: 'high',
|
||||
description: '检查行政处罚依据是否合法',
|
||||
checkMethod: 'manual',
|
||||
prompt: '审核处罚依据的法律合法性',
|
||||
isActive: true,
|
||||
createdAt: '2024-03-22T09:10:00Z',
|
||||
updatedAt: '2024-03-22T09:10:00Z'
|
||||
},
|
||||
{
|
||||
id: '10',
|
||||
code: 'EP010',
|
||||
name: '业务特殊条款检查',
|
||||
ruleType: 'business',
|
||||
ruleGroupId: '3',
|
||||
groupName: '采购合同专项检查',
|
||||
priority: 'medium',
|
||||
description: '检查合同是否包含烟草行业特殊条款',
|
||||
checkMethod: 'mixed',
|
||||
prompt: '识别文档中的烟草行业特殊要求条款',
|
||||
isActive: true,
|
||||
createdAt: '2024-03-23T10:15:00Z',
|
||||
updatedAt: '2024-03-23T10:15:00Z'
|
||||
}
|
||||
];
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
const url = new URL(request.url);
|
||||
const ruleType = url.searchParams.get("ruleType") || "";
|
||||
const groupId = url.searchParams.get("groupId") || "";
|
||||
const isActive = url.searchParams.get("isActive") || "";
|
||||
const keyword = url.searchParams.get("keyword") || "";
|
||||
const currentPage = parseInt(url.searchParams.get("page") || "1", 10);
|
||||
const pageSize = parseInt(url.searchParams.get("pageSize") || "10", 10);
|
||||
|
||||
// 从 URL 参数中提取查询条件
|
||||
const params = {
|
||||
ruleType: url.searchParams.get("ruleType") || undefined,
|
||||
groupId: url.searchParams.get("groupId") || undefined,
|
||||
isActive: url.searchParams.get("isActive") ? url.searchParams.get("isActive") === "true" : undefined,
|
||||
keyword: url.searchParams.get("keyword") || undefined,
|
||||
page: parseInt(url.searchParams.get("page") || "1", 10),
|
||||
pageSize: parseInt(url.searchParams.get("pageSize") || "10", 10)
|
||||
};
|
||||
|
||||
try {
|
||||
// 模拟数据,实际项目中应从API获取
|
||||
const rules: Rule[] = [
|
||||
{
|
||||
id: "1",
|
||||
code: "CP001",
|
||||
name: "合同主体信息完整性检查",
|
||||
ruleGroupId: "1",
|
||||
groupName: "合同基本要素检查",
|
||||
ruleType: "essential",
|
||||
priority: "high",
|
||||
description: "检查合同中是否完整包含签约方的基本信息,包括名称、地址、法定代表人等",
|
||||
checkMethod: "automatic",
|
||||
prompt: "检查合同主体双方信息是否完整,包括企业名称、注册地址、法定代表人或授权代表、联系方式等",
|
||||
isActive: true,
|
||||
createdAt: "2023-06-15 10:30",
|
||||
updatedAt: "2023-06-15 10:30"
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
code: "CP002",
|
||||
name: "合同金额一致性校验",
|
||||
ruleGroupId: "1",
|
||||
groupName: "合同基本要素检查",
|
||||
ruleType: "content",
|
||||
priority: "high",
|
||||
description: "检查合同大小写金额是否一致",
|
||||
checkMethod: "automatic",
|
||||
prompt: "检查合同中的金额大写和小写表示是否一致,如¥10,000.00(壹万元整)",
|
||||
isActive: true,
|
||||
createdAt: "2023-06-20 14:15",
|
||||
updatedAt: "2023-06-20 14:15"
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
code: "CP003",
|
||||
name: "保密条款合规性审核",
|
||||
ruleGroupId: "1",
|
||||
groupName: "合同基本要素检查",
|
||||
ruleType: "legal",
|
||||
priority: "medium",
|
||||
description: "检查合同是否包含保密条款并符合行业要求",
|
||||
checkMethod: "mixed",
|
||||
prompt: "检查合同中的保密条款是否完整、清晰,包含保密范围、期限、违约责任等",
|
||||
isActive: true,
|
||||
createdAt: "2023-07-05 09:45",
|
||||
updatedAt: "2023-07-05 09:45"
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
code: "CP004",
|
||||
name: "合同签约日期格式检查",
|
||||
ruleGroupId: "1",
|
||||
groupName: "合同基本要素检查",
|
||||
ruleType: "format",
|
||||
priority: "low",
|
||||
description: "检查合同签约日期格式是否规范",
|
||||
checkMethod: "automatic",
|
||||
prompt: "检查合同签约日期格式是否符合YYYY年MM月DD日的规范格式",
|
||||
isActive: false,
|
||||
createdAt: "2023-07-10 16:20",
|
||||
updatedAt: "2023-07-10 16:20"
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
code: "CP005",
|
||||
name: "违约责任条款完整性检查",
|
||||
ruleGroupId: "2",
|
||||
groupName: "销售合同专项检查",
|
||||
ruleType: "legal",
|
||||
priority: "high",
|
||||
description: "检查合同违约责任条款是否明确、完整",
|
||||
checkMethod: "mixed",
|
||||
prompt: "检查合同中的违约责任条款是否包含违约情形、违约金计算方式、责任承担方式等内容",
|
||||
isActive: true,
|
||||
createdAt: "2023-07-15 11:30",
|
||||
updatedAt: "2023-07-15 11:30"
|
||||
},
|
||||
{
|
||||
id: "6",
|
||||
code: "CP006",
|
||||
name: "交货期限有效性检查",
|
||||
ruleGroupId: "2",
|
||||
groupName: "销售合同专项检查",
|
||||
ruleType: "business",
|
||||
priority: "medium",
|
||||
description: "检查合同中交货期限是否明确、合理",
|
||||
checkMethod: "automatic",
|
||||
prompt: "检查合同中是否明确约定了交货期限,并且期限设置是否合理",
|
||||
isActive: true,
|
||||
createdAt: "2023-08-01 14:40",
|
||||
updatedAt: "2023-08-01 14:40"
|
||||
},
|
||||
{
|
||||
id: "7",
|
||||
code: "CP007",
|
||||
name: "合同条款矛盾性检查",
|
||||
ruleGroupId: "3",
|
||||
groupName: "采购合同专项检查",
|
||||
ruleType: "legal",
|
||||
priority: "high",
|
||||
description: "检查合同条款之间是否存在矛盾或冲突",
|
||||
checkMethod: "mixed",
|
||||
prompt: "分析合同各条款,检查是否存在相互矛盾或冲突的内容",
|
||||
isActive: true,
|
||||
createdAt: "2023-08-10 09:15",
|
||||
updatedAt: "2023-08-10 09:15"
|
||||
}
|
||||
];
|
||||
// 使用模拟数据而不是API调用
|
||||
// const response = await getRulesList(params);
|
||||
|
||||
const groups = [
|
||||
{ id: "1", name: "合同基本要素检查" },
|
||||
{ id: "2", name: "销售合同专项检查" },
|
||||
{ id: "3", name: "采购合同专项检查" },
|
||||
{ id: "4", name: "专卖许可证审核规则" },
|
||||
{ id: "5", name: "行政处罚规范性检查" }
|
||||
];
|
||||
// 过滤模拟数据
|
||||
let filteredRules = [...mockRules];
|
||||
|
||||
// 过滤数据
|
||||
let filteredRules = [...rules];
|
||||
|
||||
if (ruleType) {
|
||||
filteredRules = filteredRules.filter(rule => rule.ruleType === ruleType);
|
||||
if (params.ruleType) {
|
||||
filteredRules = filteredRules.filter(rule => rule.ruleType === params.ruleType);
|
||||
}
|
||||
|
||||
if (groupId) {
|
||||
filteredRules = filteredRules.filter(rule => rule.ruleGroupId === groupId);
|
||||
if (params.groupId) {
|
||||
filteredRules = filteredRules.filter(rule => rule.ruleGroupId === params.groupId);
|
||||
}
|
||||
|
||||
if (isActive) {
|
||||
const activeValue = isActive === 'true';
|
||||
filteredRules = filteredRules.filter(rule => rule.isActive === activeValue);
|
||||
if (params.isActive !== undefined) {
|
||||
filteredRules = filteredRules.filter(rule => rule.isActive === params.isActive);
|
||||
}
|
||||
|
||||
if (keyword) {
|
||||
const lowerKeyword = keyword.toLowerCase();
|
||||
filteredRules = filteredRules.filter(rule =>
|
||||
rule.name.toLowerCase().includes(lowerKeyword) ||
|
||||
rule.code.toLowerCase().includes(lowerKeyword)
|
||||
if (params.keyword) {
|
||||
const keyword = params.keyword.toLowerCase();
|
||||
filteredRules = filteredRules.filter(
|
||||
rule => rule.name.toLowerCase().includes(keyword) ||
|
||||
rule.code.toLowerCase().includes(keyword)
|
||||
);
|
||||
}
|
||||
|
||||
// 计算分页信息
|
||||
// 计算总记录数
|
||||
const totalCount = filteredRules.length;
|
||||
const totalPages = Math.ceil(totalCount / pageSize);
|
||||
const totalPages = Math.ceil(totalCount / params.pageSize);
|
||||
|
||||
// 验证页码范围
|
||||
if (currentPage < 1 || (totalCount > 0 && currentPage > totalPages)) {
|
||||
// 如果页码超出范围,重定向到第一页
|
||||
if (params.page < 1 || (totalCount > 0 && params.page > totalPages)) {
|
||||
const newUrl = new URL(request.url);
|
||||
newUrl.searchParams.set('page', '1');
|
||||
return redirect(newUrl.pathname + newUrl.search);
|
||||
}
|
||||
|
||||
// 分页截取
|
||||
const startIndex = (currentPage - 1) * pageSize;
|
||||
const endIndex = startIndex + pageSize;
|
||||
const paginatedRules = filteredRules.slice(startIndex, endIndex);
|
||||
// 分页
|
||||
const offset = (params.page - 1) * params.pageSize;
|
||||
const paginatedRules = filteredRules.slice(offset, offset + params.pageSize);
|
||||
|
||||
return json<LoaderData>({
|
||||
return json({
|
||||
rules: paginatedRules,
|
||||
groups,
|
||||
totalCount,
|
||||
currentPage,
|
||||
pageSize,
|
||||
currentPage: params.page,
|
||||
pageSize: params.pageSize,
|
||||
totalPages
|
||||
}, {
|
||||
headers: {
|
||||
// 添加缓存控制,在生产环境中可以调整
|
||||
"Cache-Control": "max-age=60, s-maxage=180"
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('加载评查点列表失败:', error);
|
||||
throw new Response('加载评查点列表失败', { status: 500 });
|
||||
@@ -303,7 +329,7 @@ const priorityLabels = {
|
||||
};
|
||||
|
||||
export default function RulesIndex() {
|
||||
const { rules, groups, totalCount, currentPage, pageSize } = useLoaderData<typeof loader>();
|
||||
const { rules, totalCount, currentPage, pageSize } = useLoaderData<typeof loader>();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const submit = useSubmit();
|
||||
|
||||
@@ -380,9 +406,9 @@ export default function RulesIndex() {
|
||||
};
|
||||
|
||||
// 处理重置筛选
|
||||
const handleReset = () => {
|
||||
setSearchParams(new URLSearchParams());
|
||||
};
|
||||
// const handleReset = () => {
|
||||
// setSearchParams(new URLSearchParams());
|
||||
// };
|
||||
|
||||
// 定义表格列配置
|
||||
const columns = [
|
||||
@@ -489,16 +515,22 @@ export default function RulesIndex() {
|
||||
{ value: "business", label: "业务专项类" }
|
||||
]}
|
||||
onChange={handleFilterChange}
|
||||
className="mr-3 w-80 "
|
||||
className="mr-3 w-60 "
|
||||
/>
|
||||
|
||||
<FilterSelect
|
||||
label="所属规则组"
|
||||
name="groupId"
|
||||
value={searchParams.get('groupId') || ''}
|
||||
options={groups.map(group => ({ value: group.id, label: group.name }))}
|
||||
options={[
|
||||
{ value: "1", label: "合同基本要素类检查" },
|
||||
{ value: "2", label: "销售合同专项检查" },
|
||||
{ value: "3", label: "采购合同专项检查" },
|
||||
{ value: "4", label: "专卖许可证审核规则" },
|
||||
{ value: "5", label: "行政处罚规范性检查" }
|
||||
]}
|
||||
onChange={handleFilterChange}
|
||||
className="mr-3 w-80"
|
||||
className="mr-3 w-60"
|
||||
/>
|
||||
|
||||
<FilterSelect
|
||||
@@ -510,7 +542,7 @@ export default function RulesIndex() {
|
||||
{ value: "false", label: "禁用" }
|
||||
]}
|
||||
onChange={handleFilterChange}
|
||||
className="mr-3 w-80"
|
||||
className="mr-3 w-60"
|
||||
/>
|
||||
|
||||
<SearchFilter
|
||||
|
||||
Reference in New Issue
Block a user