Files
leaudit-platform-frontend/app/routes/cross-checking._index.tsx
T
LiangShiyong 8a50671c39 fix: 1.将主页和法务助手对话设置成手机也能够正确加载的响应式布局。
2. 修改合同重新上传模板的可接受文件类型,修改对接的上传模板对应的接口。
3. 交叉评查任务列表去除任务名称的点击效果。
4. 交叉评查文件预览在点击完成评查的按钮后会返回任务列表并打开任务的文档列表。
5.修复点击完成评查按钮造成页面刷新。
6. 修复创建任务的第3步无法返回列表。
2025-11-12 15:51:39 +08:00

795 lines
25 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 React, { useState, useEffect } from 'react';
import { type MetaFunction, type LoaderFunctionArgs, type ActionFunctionArgs } from "@remix-run/node";
import { useLoaderData, useSearchParams, useNavigate, useFetcher } from "@remix-run/react";
import { Button } from '~/components/ui/Button';
import { Card } from '~/components/ui/Card';
import { Tag } from '~/components/ui/Tag';
import { DocumentListModal } from '~/components/cross-checking';
import crossCheckingStyles from "~/styles/pages/cross-checking_index.css?url";
import { Table } from '~/components/ui/Table';
import { FilterPanel, FilterSelect, SearchFilter, DateRangeFilter } from '~/components/ui/FilterPanel';
import { Pagination } from '~/components/ui/Pagination';
import { toastService } from '~/components/ui/Toast';
import {
getCrossCheckingTasks,
getCrossCheckingStats,
deleteCrossCheckingTask,
getCrossCheckingTaskDetail,
type CrossCheckingTask,
type TaskDocument,
type TaskListParams,
CrossCheckingTaskStatus,
CrossCheckingTaskType,
CrossCheckingDocType
} from '~/api/cross-checking/cross-files';
export const links = () => [
{ rel: "stylesheet", href: crossCheckingStyles }
];
export const meta: MetaFunction = () => {
return [
{ title: "中国烟草AI合同及卷宗审核系统 - 交叉评查" },
{ name: "cross-checking", content: "交叉评查任务管理,支持根据类型、状态和时间进行筛选" },
{ name: "keywords", content: "交叉评查,任务管理,合同审核,中国烟草" }
];
};
// 声明loader返回的数据类型
export type LoaderData = {
tasks: CrossCheckingTask[];
totalCount: number;
currentPage: number;
pageSize: number;
totalPages: number;
stats: {
totalTasks: number;
pendingTasks: number;
inProgressTasks: number;
completedTasks: number;
};
initialLoad?: boolean;
frontendJWT?: string; // 新增JWT
};
export async function loader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
// 从 URL 参数中提取查询条件
const params: TaskListParams = {
page: parseInt(url.searchParams.get("page") || "1", 10),
pageSize: parseInt(url.searchParams.get("pageSize") || "10", 10),
taskType: url.searchParams.get("taskType") || undefined,
docType: url.searchParams.get("docType") || undefined,
status: url.searchParams.get("status") || undefined,
keyword: url.searchParams.get("keyword") || undefined,
dateFrom: url.searchParams.get("dateFrom") || undefined,
dateTo: url.searchParams.get("dateTo") || undefined
};
try {
// 获取用户会话信息
const { getUserSession } = await import("~/api/login/auth.server");
const { userInfo, frontendJWT } = await getUserSession(request);
// console.log('frontendJWT', frontendJWT);
// 获取任务列表和统计数据,传递用户信息和JWT
const [tasksResponse, statsResponse] = await Promise.all([
getCrossCheckingTasks(params, userInfo, frontendJWT),
getCrossCheckingStats(userInfo, frontendJWT)
]);
// console.log('tasksResponse', tasksResponse.data?.tasks);
if (!tasksResponse.success) {
console.error('获取任务列表失败:', tasksResponse.error);
return Response.json({
error: tasksResponse.error || '获取任务列表失败',
status: 500
}, { status: 500 });
}
if (!statsResponse.success) {
console.error('获取统计数据失败:', statsResponse.error);
}
return Response.json({
tasks: tasksResponse.data?.tasks || [],
totalCount: tasksResponse.data?.totalCount || 0,
currentPage: tasksResponse.data?.currentPage || params.page,
pageSize: tasksResponse.data?.pageSize || params.pageSize,
totalPages: tasksResponse.data?.totalPages || 0,
stats: statsResponse.data || { totalTasks: 0, pendingTasks: 0, inProgressTasks: 0, completedTasks: 0 },
frontendJWT // 新增:返回JWT给客户端
}, {
headers: {
"Cache-Control": "max-age=60, s-maxage=180"
}
});
} catch (error) {
console.error('加载交叉评查任务列表失败:', error);
return Response.json({
error: error || '加载任务列表失败',
status: 500
}, { status: 500 });
}
}
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const _action = formData.get('_action');
const taskId = formData.get('taskId');
if (_action === 'delete' && taskId) {
try {
const deleteResponse = await deleteCrossCheckingTask(Number(taskId));
if (!deleteResponse.success) {
return Response.json({
result: false,
message: deleteResponse.error || '删除任务失败'
}, { status: 500 });
}
return Response.json({ result: true, message: "任务删除成功" }, { status: 200 });
} catch (error) {
console.error('操作任务失败:', error);
return Response.json({
result: false,
message: error instanceof Error ? error.message : "操作失败"
}, { status: 500 });
}
}
if (_action === 'getTaskDetail') {
try {
// 获取用户会话信息
const { getUserSession } = await import("~/api/login/auth.server");
const { frontendJWT } = await getUserSession(request);
const page = parseInt(formData.get('page') as string || '1', 10);
const pageSize = parseInt(formData.get('pageSize') as string || '10', 10);
if (!taskId) {
return Response.json({
success: false,
error: "缺少必要参数"
}, { status: 400 });
}
const response = await getCrossCheckingTaskDetail(
Number(taskId),
page,
pageSize,
frontendJWT
);
if (response.error) {
return Response.json({
success: false,
error: response.error
}, { status: 500 });
}
console.log('用户任务详情返回:', response.data);
return Response.json({
success: true,
data: response.data
});
} catch (error) {
console.error('获取任务详情失败:', error);
return Response.json({
success: false,
error: error instanceof Error ? error.message : "获取任务详情失败"
}, { status: 500 });
}
}
return Response.json({ result: false, message: "无效的操作" }, { status: 400 });
}
// 状态标签配置
const statusConfig = {
[CrossCheckingTaskStatus.PENDING]: { label: '未开始', color: 'yellow' as const },
[CrossCheckingTaskStatus.IN_PROGRESS]: { label: '进行中', color: 'blue' as const },
[CrossCheckingTaskStatus.COMPLETED]: { label: '已完成', color: 'green' as const }
};
// 任务类型标签配置
const taskTypeConfig = {
[CrossCheckingTaskType.CITY]: { label: '市级交叉评查', color: 'green' as const },
[CrossCheckingTaskType.COUNTY]: { label: '下级交叉评查', color: 'orange' as const }
};
// 案卷类型标签配置
const docTypeConfig = {
[CrossCheckingDocType.PENALTY]: { label: '行政处罚', color: 'blue' as const },
[CrossCheckingDocType.PERMIT]: { label: '行政许可', color: 'purple' as const }
};
export default function CrossCheckingIndex() {
const loaderData = useLoaderData<typeof loader>();
const { tasks, totalCount, currentPage, pageSize, stats, frontendJWT } = loaderData;
const [searchParams, setSearchParams] = useSearchParams();
const dateFrom = searchParams.get('dateFrom') || '';
const dateTo = searchParams.get('dateTo') || '';
const navigate = useNavigate();
const fetcher = useFetcher();
// 状态管理
const [isDeleting, setIsDeleting] = useState(false);
const [hasAutoOpened, setHasAutoOpened] = useState(false); // 标记是否已自动打开模态框
const [modalState, setModalState] = useState<{
isOpen: boolean;
title: string;
files: TaskDocument[];
loading: boolean;
// 分页相关状态
currentPage: number;
pageSize: number;
total: number;
}>({
isOpen: false,
title: '',
files: [],
loading: false,
currentPage: 1,
pageSize: 10,
total: 0
});
// 获取进度条样式类
const getProgressClass = (progress: number) => {
if (progress === 0) return 'low';
if (progress < 70) return 'medium';
return 'high';
};
// 处理查看结果 - 打开文档列表模态框
const handleViewResult = async (taskId: number, taskName: string) => {
// 存储任务信息用于分页
setCurrentTaskInfo({ taskId, taskName });
// 打开模态框
setModalState(prev => ({
...prev,
isOpen: true,
currentPage: 1,
pageSize: 10
}));
// 加载第一页数据
await loadModalData(taskId, 1, 10);
};
// 关闭模态框
const handleCloseModal = () => {
setModalState({
isOpen: false,
title: '',
files: [],
loading: false,
currentPage: 1,
pageSize: 10,
total: 0
});
setCurrentTaskInfo(null);
};
// 处理文档查看 - 导航到评查详情页
const handleViewFile = (fileId: string) => {
const params = new URLSearchParams({
id: fileId,
tId: currentTaskInfo?.taskId?.toString() || '',
tName: currentTaskInfo?.taskName || '',
previousRoute: 'crossChecking'
});
navigate(`/cross-checking/result?${params.toString()}`);
};
// 存储当前任务信息用于分页
const [currentTaskInfo, setCurrentTaskInfo] = useState<{
taskId: number;
taskName: string;
} | null>(null);
// 加载分页数据
const loadModalData = async (taskId: number, page: number = 1, pageSize: number = 10) => {
try {
setModalState(prev => ({
...prev,
loading: true
}));
// 使用 fetcher 调用 action 来获取任务详情
const formData = new FormData();
formData.append('_action', 'getTaskDetail');
formData.append('taskId', taskId.toString());
formData.append('page', page.toString());
formData.append('pageSize', pageSize.toString());
fetcher.submit(formData, { method: "POST" });
} catch (error) {
console.error('获取任务文档列表失败:', error);
toastService.error(`获取任务文档列表失败: ${error instanceof Error ? error.message : '未知错误'}`);
setModalState(prev => ({
...prev,
loading: false
}));
}
};
// 处理模态框分页变化
const handleModalPageChange = (page: number) => {
if (currentTaskInfo) {
loadModalData(currentTaskInfo.taskId, page, modalState.pageSize);
}
};
// 处理模态框每页大小变化
const handleModalPageSizeChange = (size: number) => {
if (currentTaskInfo) {
loadModalData(currentTaskInfo.taskId, 1, size);
}
};
// 渲染进度条
const renderProgress = (progress: number) => (
<div className="flex items-center space-x-2">
<div className="progress-bar w-16">
<div
className={`progress-bar-fill ${getProgressClass(progress)}`}
style={{ width: `${progress}%` }}
/>
</div>
<span className="text-sm text-gray-600 min-w-[3rem]">{progress}%</span>
</div>
);
// 渲染操作按钮
const renderOperation = (task: CrossCheckingTask) => {
switch (task.status) {
case CrossCheckingTaskStatus.PENDING:
return (
<Button
type="primary"
size="small"
className="operation-btn primary"
onClick={() => handleViewResult(task.id,task.taskName)}
>
<i className="ri-play-line"></i>
</Button>
);
case CrossCheckingTaskStatus.IN_PROGRESS:
return (
<Button
type="default"
size="small"
className="operation-btn secondary"
onClick={() => handleViewResult(task.id,task.taskName)}
>
<i className="ri-eye-line"></i>
</Button>
);
case CrossCheckingTaskStatus.COMPLETED:
return (
<Button
type="default"
size="small"
className="operation-btn secondary"
onClick={() => handleViewResult(task.id,task.taskName)}
>
<i className="ri-file-text-line"></i>
</Button>
);
default:
return <span>-</span>;
}
};
// 处理筛选变化
const handleFilterChange = (e: React.ChangeEvent<HTMLSelectElement | HTMLInputElement>) => {
const { name, value } = e.target;
const newParams = new URLSearchParams(searchParams);
if (value) {
newParams.set(name, value);
} else {
newParams.delete(name);
}
// 切换筛选条件时,重置到第一页
newParams.set('page', '1');
setSearchParams(newParams);
};
// 处理搜索
const handleSearch = (keyword: string) => {
const newParams = new URLSearchParams(searchParams);
if (keyword) {
newParams.set('keyword', keyword);
} else {
newParams.delete('keyword');
}
newParams.set('page', '1');
setSearchParams(newParams);
};
// 处理分页变化
const handlePageChange = (page: number) => {
const newParams = new URLSearchParams(searchParams);
newParams.set('page', page.toString());
setSearchParams(newParams);
};
// 处理每页条数变化
const handlePageSizeChange = (size: number) => {
const newParams = new URLSearchParams(searchParams);
newParams.set('pageSize', size.toString());
newParams.set('page', '1');
setSearchParams(newParams);
};
// 处理重置筛选
const handleReset = () => {
const input = document.querySelector('input[placeholder="输入任务名称或评查地区"]');
if (input) {
(input as HTMLInputElement).value = '';
}
const dateFromInput = document.querySelector('input[name="dateFrom"]');
const dateToInput = document.querySelector('input[name="dateTo"]');
if(dateFromInput) {
(dateFromInput as HTMLInputElement).value = '';
}
if(dateToInput) {
(dateToInput as HTMLInputElement).value = '';
}
setSearchParams(new URLSearchParams());
};
// 处理时间范围变更
const handleDateChange = (field: 'dateFrom' | 'dateTo', value: string) => {
const newParams = new URLSearchParams(searchParams);
if(value) {
newParams.set(field, value);
} else {
newParams.delete(field);
}
newParams.set('page', '1');
setSearchParams(newParams);
};
// 检测URL参数,自动打开模态框
useEffect(() => {
const openModal = searchParams.get('openModal');
const taskId = searchParams.get('taskId');
const taskName = searchParams.get('taskName');
if (openModal === 'true' && taskId && !hasAutoOpened) {
console.log('[自动打开模态框] taskId:', taskId, 'taskName:', taskName);
// 标记已自动打开,防止重复触发
setHasAutoOpened(true);
// 清除URL参数,避免刷新页面时再次打开
const newParams = new URLSearchParams(searchParams);
newParams.delete('openModal');
newParams.delete('taskId');
newParams.delete('taskName');
setSearchParams(newParams, { replace: true });
// 延迟自动打开模态框,确保状态已更新
setTimeout(() => {
handleViewResult(Number(taskId), taskName || '任务详情');
}, 100);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams, hasAutoOpened]);
// 监听fetcher状态变化 - 删除操作
useEffect(() => {
if (fetcher.data && fetcher.state === 'idle' && isDeleting) {
setIsDeleting(false);
const data = fetcher.data as { result?: boolean; message?: string };
if (data.result) {
toastService.success(data.message || '操作成功');
// 删除成功后刷新页面
window.location.reload();
} else {
toastService.error(data.message || '操作失败');
}
}
}, [fetcher.data, fetcher.state, isDeleting]);
// 监听fetcher状态变化 - 获取任务详情
useEffect(() => {
if (fetcher.data && fetcher.state === 'idle' && !isDeleting && modalState.loading) {
const data = fetcher.data as {
success?: boolean;
data?: {
files: TaskDocument[];
total: number;
currentPage: number;
pageSize: number;
};
error?: string;
};
if (data.success && data.data) {
const { files, total, currentPage, pageSize: returnedPageSize } = data.data;
setModalState(prev => ({
...prev,
loading: false,
title: `${currentTaskInfo?.taskName || ''} - 文档列表`,
files: files || [],
total: total || 0,
currentPage: currentPage || prev.currentPage,
pageSize: returnedPageSize || prev.pageSize
}));
} else {
console.error('获取任务文档列表失败:', data.error);
toastService.error(`获取任务文档列表失败: ${data.error || '未知错误'}`);
setModalState(prev => ({
...prev,
loading: false
}));
}
}
}, [fetcher.data, fetcher.state, isDeleting, modalState.loading, currentTaskInfo?.taskId]);
// 定义表格列配置
const columns = [
{
title: "序号",
dataIndex: "sequence" as keyof CrossCheckingTask,
key: "sequence",
align: "center" as const,
width: "2%"
},
{
title: "任务名称",
dataIndex: "taskName" as keyof CrossCheckingTask,
key: "taskName",
align: "left" as const,
width: "16%"
},
{
title: "评查开始时间",
dataIndex: "startDate" as keyof CrossCheckingTask,
key: "startDate",
align: "center" as const,
width: "10%"
},
{
title: "案卷类型",
key: "docType",
align: "center" as const,
width: "8%",
render: (_: unknown, record: CrossCheckingTask) => {
const config = docTypeConfig[record.docType as keyof typeof docTypeConfig] || { label: record.docType, color: 'gray' as const };
return (
<Tag color={config.color}>
{config.label}
</Tag>
);
}
},
{
title: "任务类型",
key: "taskType",
align: "center" as const,
width: "10%",
render: (_: unknown, record: CrossCheckingTask) => {
const config = taskTypeConfig[record.taskType];
return (
<Tag color={config.color}>
{config.label}
</Tag>
);
}
},
{
title: "评查地区",
dataIndex: "evaluationRegion" as keyof CrossCheckingTask,
key: "evaluationRegion",
align: "left" as const,
width: "16%"
},
{
title: "评查进度",
key: "progress",
align: "left" as const,
width: "12%",
render: (_: unknown, record: CrossCheckingTask) => renderProgress(record.progress)
},
{
title: "评查状态",
key: "status",
align: "center" as const,
width: "auto",
render: (_: unknown, record: CrossCheckingTask) => {
const config = statusConfig[record.status as keyof typeof statusConfig] || { label: record.status, color: 'gray' as const };
return (
<Tag color={config.color}>
{config.label}
</Tag>
);
}
},
// {
// title: "评查分数",
// key: "score",
// align: "center" as const,
// width: "5%",
// render: (_: unknown, record: CrossCheckingTask) =>
// record.status === CrossCheckingTaskStatus.COMPLETED ? record.score : '-'
// },
{
title: "操作",
key: "operation",
align: "center" as const,
width: "auto",
render: (_: unknown, record: CrossCheckingTask) => renderOperation(record)
}
];
return (
<div className="cross-checking-page">
{/* 页面头部 */}
<div className="page-header">
<div className="flex items-center">
<h2 className="page-title"></h2>
<div className="page-stats">
<div className="stat-item">
<i className="ri-file-list-3-line stat-icon"></i>
<span className="text-sm text-gray-600"></span>
<span className="stat-value">{stats.totalTasks}</span>
</div>
<div className="stat-item">
<i className="ri-time-line stat-icon"></i>
<span className="text-sm text-gray-600"></span>
<span className="stat-value">{stats.pendingTasks}</span>
</div>
<div className="stat-item">
<i className="ri-play-circle-line stat-icon"></i>
<span className="text-sm text-gray-600"></span>
<span className="stat-value">{stats.inProgressTasks}</span>
</div>
<div className="stat-item">
<i className="ri-checkbox-circle-line stat-icon"></i>
<span className="text-sm text-gray-600"></span>
<span className="stat-value">{stats.completedTasks}</span>
</div>
</div>
</div>
<Button type="primary" icon="ri-add-line" to="/cross-checking/upload">
</Button>
</div>
{/* 筛选区域 */}
<FilterPanel className="px-4 py-4" noActionDivider={true}
actions={
<>
<Button type="default" icon="ri-refresh-line" onClick={handleReset} className="mr-2 hover:!border-gray-300">
</Button>
</>
}
>
<FilterSelect
label="案卷类型"
name="docType"
value={searchParams.get('docType') || ''}
options={[
{ value: CrossCheckingDocType.PENALTY, label: "行政处罚" },
{ value: CrossCheckingDocType.PERMIT, label: "行政许可" }
]}
onChange={handleFilterChange}
className="mr-4 w-[15%]"
/>
<FilterSelect
label="评查状态"
name="status"
value={searchParams.get('status') || ''}
options={[
{ value: CrossCheckingTaskStatus.PENDING, label: "未开始" },
{ value: CrossCheckingTaskStatus.IN_PROGRESS, label: "进行中" },
{ value: CrossCheckingTaskStatus.COMPLETED, label: "已完成" }
]}
onChange={handleFilterChange}
className="mr-4 w-[15%]"
/>
<DateRangeFilter
label="时间范围"
startDate={dateFrom}
endDate={dateTo}
onStartDateChange={(value) => handleDateChange('dateFrom', value)}
onEndDateChange={(value) => handleDateChange('dateTo', value)}
simple={true}
colorMode="light"
/>
<SearchFilter
label="搜索"
placeholder="输入任务名称或评查地区"
value={searchParams.get('keyword') || ''}
buttonText="搜索"
onSearch={handleSearch}
className="min-w-[200px] flex-1"
/>
</FilterPanel>
{/* 任务列表 */}
<Card className="task-table">
<div>
<Table
columns={columns}
dataSource={tasks}
rowKey="id"
className="cross-checking-table"
/>
{/* 分页 */}
{totalCount > 0 && (
<Pagination
currentPage={currentPage}
total={totalCount}
pageSize={pageSize}
onChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
showTotal={true}
showPageSizeChanger={true}
pageSizeOptions={[10, 20, 30, 50]}
/>
)}
</div>
</Card>
{/* 文档列表模态框 */}
<DocumentListModal
isOpen={modalState.isOpen}
onClose={handleCloseModal}
title={modalState.title}
files={modalState.files}
onViewFile={handleViewFile}
loading={modalState.loading}
currentPage={modalState.currentPage}
pageSize={modalState.pageSize}
total={modalState.total}
onPageChange={handleModalPageChange}
onPageSizeChange={handleModalPageSizeChange}
frontendJWT={frontendJWT}
/>
</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>
);
}