Files
leaudit-platform-frontend/app/routes/entry-modules._index.tsx
T
2026-04-29 22:25:06 +08:00

461 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect } from "react";
import { useSearchParams, useNavigate, useLoaderData, useRevalidator } from "@remix-run/react";
import { ClientLoaderFunctionArgs, MetaFunction } from "@remix-run/react";
import { Table } from "~/components/ui/Table";
import { Card } from "~/components/ui/Card";
import { Button } from "~/components/ui/Button";
import { Pagination } from "~/components/ui/Pagination";
import { FilterPanel, FilterSelect, SearchFilter } from "~/components/ui/FilterPanel";
import { toastService } from "~/components/ui/Toast";
import { messageService } from "~/components/ui/MessageModal";
import { usePermission } from "~/hooks/usePermission";
import {
getEntryModules,
deleteEntryModule,
type EntryModule,
type EntryModuleSearchParams
} from "~/api/entry-modules/entry-modules";
import entryModulesStyles from "~/styles/pages/entry-modules.css?url";
import { DOCUMENT_URL } from "~/config/api-config";
// 引入CSS样式
export function links() {
return [
{ rel: "stylesheet", href: entryModulesStyles }
];
}
// 页面元数据
export const meta: MetaFunction = () => {
return [
{ title: "入口模块管理 - 中国烟草AI合同及卷宗审核系统" },
{ name: "description", content: "管理入口模块,包括查看、编辑和删除入口模块" },
];
};
// 面包屑配置
export const handle = {
breadcrumb: "入口模块管理"
};
// 定义加载器返回的数据类型
interface LoaderData {
modules: EntryModule[];
total: number;
pageSize: number;
currentPage: number;
error?: string;
accessDenied?: boolean;
}
function resolveModuleLogoUrl(path?: string | null): string | null {
if (!path) {
return null;
}
if (path.startsWith("/images/") || path.startsWith("http://") || path.startsWith("https://")) {
return path;
}
return `${DOCUMENT_URL}${path}`;
}
// 🔑 客户端加载函数 - 在浏览器端执行,axios-client 会自动添加 JWT
export async function clientLoader({ request }: ClientLoaderFunctionArgs) {
try {
const url = new URL(request.url);
const name = url.searchParams.get('name') || undefined;
const area = url.searchParams.get('area') || undefined;
const page = parseInt(url.searchParams.get('page') || '1', 10);
const pageSize = parseInt(url.searchParams.get('pageSize') || '10', 10);
// 构建搜索参数(注意:API使用page_size而不是pageSize
const searchParams: EntryModuleSearchParams = {
name,
area,
page,
page_size: pageSize // API使用page_size
};
// ✅ 不需要传递 JWTaxios-client 会自动从 localStorage 读取并添加
const modulesResponse = await getEntryModules(searchParams);
if (modulesResponse.error) {
console.error("❌ [clientLoader] 获取入口模块失败:", modulesResponse.error);
throw new Error(modulesResponse.error);
}
const modulesResult = modulesResponse.data?.modules || [];
const totalCount = modulesResponse.data?.total || modulesResult.length;
return {
modules: modulesResult,
total: totalCount,
pageSize,
currentPage: page,
accessDenied: false,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "加载入口模块列表失败";
console.error("加载入口模块列表失败:", error);
return {
modules: [],
total: 0,
pageSize: 10,
currentPage: 1,
error: errorMessage,
accessDenied: errorMessage.includes('无权限') || errorMessage.includes('权限') || errorMessage.includes('403'),
};
}
}
// 地区选项
const AREA_OPTIONS = [
{ value: "梅州", label: "梅州" },
{ value: "云浮", label: "云浮" },
{ value: "揭阳", label: "揭阳" },
{ value: "潮州", label: "潮州" },
{ value: "省局", label: "省局" }
];
// 入口模块列表组件
export default function EntryModulesList() {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const [isDeleting, setIsDeleting] = useState(false);
const revalidator = useRevalidator();
// 记录加载失败的图片ID
const [failedImages, setFailedImages] = useState<Set<number>>(new Set());
// 获取加载器数据
const loaderData = useLoaderData<LoaderData>();
const { modules, total, error, accessDenied } = loaderData;
// ✅ 使用权限 Hook
const { canCreate, canUpdate, canDelete, canView } = usePermission();
const canCreateModule = canCreate('entry_module');
const canUpdateModule = canUpdate('entry_module');
const canDeleteModule = canDelete('entry_module');
const canViewModule = canView('entry_module');
// 获取搜索参数
const name = searchParams.get('name') || '';
const area = searchParams.get('area') || '';
const currentPage = parseInt(searchParams.get('page') || String(1), 10);
const pageSize = parseInt(searchParams.get('pageSize') || String(10), 10);
// 处理loader加载数据的时候的错误
useEffect(() => {
if (error) {
if (accessDenied) {
toastService.warning('您当前只有入口模块页面可见权限,没有列表读取权限');
return;
}
toastService.error(error);
}
}, [error, accessDenied]);
// 处理名称搜索
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 handleFilterChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const { name, value } = e.target;
const newParams = new URLSearchParams(searchParams);
if (value) {
newParams.set(name, value);
} else {
newParams.delete(name);
}
// 切换筛选条件时,重置到第一页
newParams.set('page', '1');
setSearchParams(newParams);
};
// 处理重置筛选
const handleReset = () => {
const nameInput = document.querySelector('input[placeholder="请输入入口模块名称"]');
if (nameInput) {
(nameInput as HTMLInputElement).value = '';
}
// 重置所有筛选条件
setSearchParams(new URLSearchParams());
};
// 处理删除入口模块
const handleDelete = async (id: number) => {
// ✅ 检查删除权限
if (!canDeleteModule) {
toastService.warning('您没有删除权限');
return;
}
messageService.show({
title: "确认删除",
message: "确定要删除该入口模块吗?此操作不可撤销。",
type: "warning",
confirmText: "删除",
cancelText: "取消",
confirmDelay: 3,
onConfirm: async () => {
setIsDeleting(true);
try {
// 直接调用 API 删除函数
const result = await deleteEntryModule(id);
if (result.success) {
toastService.success('删除成功!');
// 重新验证数据,刷新表格
revalidator.revalidate();
} else {
toastService.error(`删除失败: ${result.error || '未知错误'}`);
}
} catch (error) {
console.error('删除入口模块失败:', error);
toastService.error(`删除失败: ${error instanceof Error ? error.message : '未知错误'}`);
} finally {
setIsDeleting(false);
}
}
});
};
// 处理编辑入口模块
const handleEdit = (id: number) => {
navigate(`/entry-modules/new?id=${id}`);
};
// 处理分页变更
const handlePageChange = (page: number) => {
const newParams = new URLSearchParams(searchParams);
newParams.set('page', page.toString());
setSearchParams(newParams);
};
// 处理每页条数变更
const handlePageSizeChange = (size: number) => {
const newParams = new URLSearchParams(searchParams);
newParams.set('pageSize', size.toString());
newParams.set('page', '1');
setSearchParams(newParams);
};
// 表格列定义
const columns = [
{
key: 'name',
title: '模块名称',
width: '200px',
render: (_: any, record: EntryModule) => (
<span className="font-medium text-gray-900">{record.name}</span>
)
},
{
key: 'description',
title: '描述',
width: '250px',
render: (_: any, record: EntryModule) => (
<span className="text-gray-600">{record.description || '-'}</span>
)
},
{
key: 'logo',
title: 'Logo图片',
width: '150px',
render: (_: any, record: EntryModule) => {
const logoUrl = resolveModuleLogoUrl(record.path);
if (!logoUrl) {
return <span className="text-gray-400"></span>;
}
const hasFailed = failedImages.has(record.id!);
return (
<div className="flex items-center">
{!hasFailed ? (
<>
<a
href={logoUrl}
target="_blank"
rel="noopener noreferrer"
className="ml-2 text-blue-600 hover:underline text-sm"
>
<div className="h-10 w-10 bg-gray-100 rounded flex items-center justify-center overflow-hidden">
<img
src={logoUrl}
alt={record.name}
className="h-full w-full object-contain"
onError={() => {
// 使用 React 状态管理加载失败的图片
setFailedImages(prev => new Set(prev).add(record.id!));
}}
/>
</div>
</a>
</>
) : (
<div className="flex items-center">
<div className="h-8 w-8 bg-red-50 border border-red-200 rounded flex items-center justify-center">
<i className="ri-image-line text-red-400 text-lg"></i>
</div>
<span className="ml-2 text-red-500 text-sm"></span>
</div>
)}
</div>
);
}
},
{
key: 'areas',
title: '适用地区',
width: '200px',
render: (_: any, record: EntryModule) => (
<div className="flex flex-wrap gap-1">
{record.areas && record.areas.length > 0 ? (
record.areas
.filter(areaConfig => areaConfig.enabled !== false) // 只显示启用的地区
.sort((a, b) => a.sort_order - b.sort_order) // 按排序号排序
.map((areaConfig, index) => (
<span
key={index}
className="inline-block px-2 py-1 text-xs bg-blue-100 text-blue-800 rounded"
>
{areaConfig.area}
</span>
))
) : (
<span className="text-gray-400"></span>
)}
</div>
)
},
{
key: 'created_at',
title: '创建时间',
width: '180px',
render: (_: any, record: EntryModule) =>
record.created_at ? new Date(record.created_at).toLocaleString('zh-CN') : '-'
},
{
key: 'actions',
title: '操作',
width: '180px',
render: (_: any, record: EntryModule) => (
<div className="operations-cell">
{ canViewModule &&
<button
onClick={() => handleEdit(record.id!)}
className="operation-btn"
title="查看/编辑入口模块"
>
<i className="ri-edit-line"></i> {canUpdateModule ? '编辑' : '查看'}
</button>
}
{canDeleteModule && (
<button
type="button"
className="operation-btn !text-[--color-error]"
onClick={() => handleDelete(record.id!)}
disabled={isDeleting}
title="删除入口模块"
>
<i className="ri-delete-bin-line"></i>
</button>
)}
</div>
)
}
];
return (
<div className="entry-modules-page">
<Card>
{/* 页面头部 */}
<div className="page-header">
<div>
<h1 className="text-2xl font-bold text-gray-900"></h1>
<p className="text-sm text-gray-600 mt-1">Logo图片和适用地区设置</p>
</div>
{/* ✅ 仅在有创建权限时显示新建按钮 */}
{canCreateModule && (
<Button
type="primary"
icon="ri-add-line"
to="/entry-modules/new"
>
</Button>
)}
</div>
{/* 筛选面板 */}
<FilterPanel onReset={handleReset}>
<FilterSelect
label="适用地区"
name="area"
value={area}
options={AREA_OPTIONS}
onChange={handleFilterChange}
/>
<SearchFilter
label="模块名称"
placeholder="请输入入口模块名称"
value={name}
onSearch={handleNameSearch}
className="filter-item-wide"
/>
</FilterPanel>
{accessDenied ? (
<div className="empty-state" style={{ padding: '64px 24px', textAlign: 'center' }}>
<div style={{ fontSize: 40, color: '#faad14', marginBottom: 12 }}>
<i className="ri-lock-line"></i>
</div>
<div style={{ fontSize: 18, fontWeight: 600, color: '#1f2937', marginBottom: 8 }}>
</div>
<div style={{ fontSize: 14, color: '#6b7280' }}>
`entry_module:list:read` 访
</div>
</div>
) : (
<Table
columns={columns}
dataSource={modules || []}
rowKey="id"
loading={false}
emptyText="暂无入口模块数据"
/>
)}
{/* 分页 */}
{!accessDenied && total > 0 && (
<Pagination
// pageSizeOptions={[10,20]}
currentPage={currentPage}
pageSize={pageSize}
total={total}
onChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
/>
)}
</Card>
</div>
);
}