优化登录逻辑的实现,将认证请求和token验证的处理分成两个逻辑文件。新增交叉评查任务列表的页面(尚未对接真实数据)。

This commit is contained in:
2025-07-16 14:32:20 +08:00
parent d876d66dcb
commit 328f326db3
13 changed files with 1729 additions and 131 deletions
+1 -1
View File
@@ -3,7 +3,7 @@ import { useNavigate, Form, useLoaderData } from '@remix-run/react';
import { type MetaFunction, type ActionFunctionArgs, LoaderFunctionArgs, redirect } from "@remix-run/node";
import styles from "~/styles/pages/home.css?url";
import dayjs from 'dayjs';
import { getUserSession, logout } from "~/root";
import { getUserSession, logout } from "~/api/login/auth.server";
export const links = () => [
{ rel: "stylesheet", href: styles }
+9 -7
View File
@@ -1,12 +1,13 @@
import { type LoaderFunctionArgs, redirect } from "@remix-run/node";
import { OAuthClient } from "~/utils/oauth-client";
import { OAuthClient } from "~/api/login/oauth-client";
import { OAUTH_CONFIG } from "~/config/api-config";
import { sessionStorage } from "~/root";
import { sessionStorage } from "~/api/login/auth.server";
import { toastService } from "~/components/ui";
export async function loader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
// const state = url.searchParams.get("state");
const error = url.searchParams.get("error");
const error_description = url.searchParams.get("error_description");
@@ -18,16 +19,17 @@ export async function loader({ request }: LoaderFunctionArgs) {
// 检查是否有授权码
if (!code) {
toastService.error("通过OAuth2.0登录回调缺少授权码");
console.error("OAuth2.0回调缺少授权码");
return redirect("/login?error=missing_code");
}
// 验证状态值(可选,但建议实现)
// 这里简单验证state是否以_idp结尾
if (!state || !state.endsWith("_idp")) {
console.error("OAuth2.0状态值验证失败");
return redirect("/login?error=invalid_state");
}
// if (!state || !state.endsWith("_idp")) {
// console.error("OAuth2.0状态值验证失败");
// return redirect("/login?error=invalid_state");
// }
try {
// 创建OAuth客户端
+551 -17
View File
@@ -1,26 +1,560 @@
import {type MetaFunction} from "@remix-run/node";
import React, { useState, useEffect } from 'react';
import { type MetaFunction, type LoaderFunctionArgs } 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 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,
type CrossCheckingTask,
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: "交叉评查"}
]
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;
};
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 [tasksResponse, statsResponse] = await Promise.all([
getCrossCheckingTasks(params),
getCrossCheckingStats()
]);
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 }
}, {
headers: {
"Cache-Control": "max-age=60, s-maxage=180"
}
});
} catch (error) {
console.error('加载交叉评查任务列表失败:', error);
return Response.json({
error: error || '加载任务列表失败',
status: 500
}, { status: 500 });
}
}
// export const loader = async ({ request }: LoaderFunctionArgs) => {
// const { user } = await requireUser(request);
// return json({ user });
// }
export async function action({ request }: LoaderFunctionArgs) {
const formData = await request.formData();
const _action = formData.get('_action');
const taskId = formData.get('taskId');
if (!taskId) {
return Response.json({ result: false, message: "缺少任务ID" }, { status: 400 });
}
// export const action = async ({ request }: ActionFunctionArgs) => {
// const { user } = await requireUser(request);
// return json({ user });
// }
try {
if (_action === 'delete') {
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 });
}
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() {
return (
<div>
<h1></h1>
const loaderData = useLoaderData<typeof loader>();
const { tasks, totalCount, currentPage, pageSize, stats } = 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 getProgressClass = (progress: number) => {
if (progress === 0) return 'low';
if (progress < 70) return 'medium';
return 'high';
};
// 渲染进度条
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={() => navigate(`/cross-checking/${task.id}`)}
>
<i className="ri-play-line"></i>
</Button>
);
case CrossCheckingTaskStatus.IN_PROGRESS:
return (
<Button
type="default"
size="small"
className="operation-btn secondary"
onClick={() => navigate(`/cross-checking/${task.id}`)}
>
<i className="ri-eye-line"></i>
</Button>
);
case CrossCheckingTaskStatus.COMPLETED:
return (
<Button
type="default"
size="small"
className="operation-btn secondary"
onClick={() => navigate(`/cross-checking/${task.id}/results`)}
>
<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);
};
// 监听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]);
// 定义表格列配置
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%",
render: (value: string, record: CrossCheckingTask) => (
<button
className="task-name text-left w-full"
onClick={() => navigate(`/cross-checking/${record.id}`)}
type="button"
>
{value}
</button>
)
},
{
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];
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];
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/new">
</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: "", label: "全部类型" },
{ 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>
</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>
);
}
+2 -3
View File
@@ -11,10 +11,9 @@ import { getDocuments, type DocumentUI, type DocumentSearchParams } from "~/api/
import { useState, useEffect } from "react";
import { getHomeData } from "~/api/home/home";
import dayjs from 'dayjs';
import type { UserRole } from '~/root';
import type { UserRole } from '~/api/login/auth.server';
import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/node";
import { logout, getUserSession } from "~/root";
// import { getUserSession } from "~/root";
import { logout, getUserSession } from "~/api/login/auth.server";
// 文件处理状态选项
const fileProcessingStatusOptions = [
+2 -2
View File
@@ -1,9 +1,9 @@
import { useEffect } from "react";
import { useSearchParams, Form } from "@remix-run/react";
import { type MetaFunction, type LoaderFunctionArgs, type ActionFunctionArgs, redirect } from "@remix-run/node";
import { OAuthClient } from "~/utils/oauth-client";
import { OAuthClient } from "~/api/login/oauth-client";
import { OAUTH_CONFIG } from "~/config/api-config";
import { getUserSession, getSession, createUserSession } from "~/root";
import { getUserSession, getSession, createUserSession } from "~/api/login/auth.server";
import styles from "~/styles/pages/login.css?url";
export const links = () => [
+2 -2
View File
@@ -1,7 +1,7 @@
import { type LoaderFunctionArgs, redirect } from "@remix-run/node";
import { OAuthClient } from "~/utils/oauth-client";
import { OAuthClient } from "~/api/login/oauth-client";
import { OAUTH_CONFIG } from "~/config/api-config";
import { sessionStorage } from "~/root";
import { sessionStorage } from "~/api/login/auth.server";
export async function loader({ request }: LoaderFunctionArgs) {
const session = await sessionStorage.getSession(request.headers.get("Cookie"));