优化登录逻辑的实现,将认证请求和token验证的处理分成两个逻辑文件。新增交叉评查任务列表的页面(尚未对接真实数据)。
This commit is contained in:
@@ -0,0 +1,355 @@
|
|||||||
|
// import { API_BASE_URL } from '../config/api-config';
|
||||||
|
|
||||||
|
// 交叉评查任务状态枚举
|
||||||
|
export enum CrossCheckingTaskStatus {
|
||||||
|
PENDING = 'pending',
|
||||||
|
IN_PROGRESS = 'in_progress',
|
||||||
|
COMPLETED = 'completed'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 交叉评查任务类型枚举
|
||||||
|
export enum CrossCheckingTaskType {
|
||||||
|
CITY = 'city',
|
||||||
|
COUNTY = 'county'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 案卷类型枚举
|
||||||
|
export enum CrossCheckingDocType {
|
||||||
|
PENALTY = 'penalty', // 行政处罚
|
||||||
|
PERMIT = 'permit' // 行政许可
|
||||||
|
}
|
||||||
|
|
||||||
|
// 交叉评查任务接口
|
||||||
|
export interface CrossCheckingTask {
|
||||||
|
id: number;
|
||||||
|
sequence: number;
|
||||||
|
taskName: string;
|
||||||
|
startDate: string;
|
||||||
|
taskType: CrossCheckingTaskType;
|
||||||
|
docType: CrossCheckingDocType; // 案卷类型
|
||||||
|
evaluationRegion: string;
|
||||||
|
progress: number;
|
||||||
|
status: CrossCheckingTaskStatus;
|
||||||
|
score: number;
|
||||||
|
operation: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// API响应格式
|
||||||
|
export interface ApiResponse<T> {
|
||||||
|
success: boolean;
|
||||||
|
data?: T;
|
||||||
|
error?: string;
|
||||||
|
message?: string;
|
||||||
|
status?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 任务列表查询参数
|
||||||
|
export interface TaskListParams {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
taskType?: string;
|
||||||
|
docType?: string;
|
||||||
|
status?: string;
|
||||||
|
keyword?: string;
|
||||||
|
dateFrom?: string;
|
||||||
|
dateTo?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 任务列表响应数据
|
||||||
|
export interface TaskListResponse {
|
||||||
|
tasks: CrossCheckingTask[];
|
||||||
|
totalCount: number;
|
||||||
|
currentPage: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模拟数据 - 临时使用
|
||||||
|
*/
|
||||||
|
const mockTasks: CrossCheckingTask[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
sequence: 1,
|
||||||
|
taskName: '2024年度交叉评查',
|
||||||
|
startDate: '2024-12-23',
|
||||||
|
taskType: CrossCheckingTaskType.CITY,
|
||||||
|
docType: CrossCheckingDocType.PENALTY,
|
||||||
|
evaluationRegion: '梅州市、揭阳市、潮州市、云浮市',
|
||||||
|
progress: 0,
|
||||||
|
status: CrossCheckingTaskStatus.PENDING,
|
||||||
|
score: 0,
|
||||||
|
operation: '去评查'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
sequence: 2,
|
||||||
|
taskName: '2024年第4季度交叉评查',
|
||||||
|
startDate: '2024-12-05',
|
||||||
|
taskType: CrossCheckingTaskType.COUNTY,
|
||||||
|
docType: CrossCheckingDocType.PERMIT,
|
||||||
|
evaluationRegion: '梅江区、梅县区、平远县、蕉岭县、大埔县、丰顺县、五华县',
|
||||||
|
progress: 72,
|
||||||
|
status: CrossCheckingTaskStatus.IN_PROGRESS,
|
||||||
|
score: 0,
|
||||||
|
operation: '进行中'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
sequence: 3,
|
||||||
|
taskName: '2024年第3季度交叉评查',
|
||||||
|
startDate: '2024-9-23',
|
||||||
|
taskType: CrossCheckingTaskType.COUNTY,
|
||||||
|
docType: CrossCheckingDocType.PERMIT,
|
||||||
|
evaluationRegion: '梅江区、梅县区、平远县、蕉岭县、大埔县、丰顺县、五华县',
|
||||||
|
progress: 100,
|
||||||
|
status: CrossCheckingTaskStatus.COMPLETED,
|
||||||
|
score: 95,
|
||||||
|
operation: '查看结果'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
sequence: 4,
|
||||||
|
taskName: '2024年中交叉评查',
|
||||||
|
startDate: '2024-6-23',
|
||||||
|
taskType: CrossCheckingTaskType.CITY,
|
||||||
|
docType: CrossCheckingDocType.PENALTY,
|
||||||
|
evaluationRegion: '梅州市、揭阳市、潮州市、云浮市',
|
||||||
|
progress: 100,
|
||||||
|
status: CrossCheckingTaskStatus.COMPLETED,
|
||||||
|
score: 85,
|
||||||
|
operation: '查看结果'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
sequence: 5,
|
||||||
|
taskName: '2024年第2季度交叉评查',
|
||||||
|
startDate: '2024-3-23',
|
||||||
|
taskType: CrossCheckingTaskType.COUNTY,
|
||||||
|
docType: CrossCheckingDocType.PENALTY,
|
||||||
|
evaluationRegion: '梅江区、梅县区、平远县、蕉岭县、大埔县、丰顺县、五华县',
|
||||||
|
progress: 100,
|
||||||
|
status: CrossCheckingTaskStatus.COMPLETED,
|
||||||
|
score: 92,
|
||||||
|
operation: '查看结果'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取交叉评查任务列表
|
||||||
|
* @param params 查询参数
|
||||||
|
* @returns 任务列表响应
|
||||||
|
*/
|
||||||
|
export async function getCrossCheckingTasks(params: TaskListParams = {}): Promise<ApiResponse<TaskListResponse>> {
|
||||||
|
try {
|
||||||
|
// 模拟API延迟
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
|
||||||
|
const {
|
||||||
|
page = 1,
|
||||||
|
pageSize = 10,
|
||||||
|
taskType,
|
||||||
|
docType,
|
||||||
|
status,
|
||||||
|
keyword,
|
||||||
|
dateFrom,
|
||||||
|
dateTo
|
||||||
|
} = params;
|
||||||
|
|
||||||
|
// 筛选数据
|
||||||
|
let filteredTasks = [...mockTasks];
|
||||||
|
|
||||||
|
// 按任务类型筛选
|
||||||
|
if (taskType && taskType !== 'all') {
|
||||||
|
filteredTasks = filteredTasks.filter(task => task.taskType === taskType);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按案卷类型筛选
|
||||||
|
if (docType && docType !== 'all') {
|
||||||
|
filteredTasks = filteredTasks.filter(task => task.docType === docType);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按状态筛选
|
||||||
|
if (status && status !== 'all') {
|
||||||
|
filteredTasks = filteredTasks.filter(task => task.status === status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按关键词搜索
|
||||||
|
if (keyword) {
|
||||||
|
const lowerKeyword = keyword.toLowerCase();
|
||||||
|
filteredTasks = filteredTasks.filter(task =>
|
||||||
|
task.taskName.toLowerCase().includes(lowerKeyword) ||
|
||||||
|
task.evaluationRegion.toLowerCase().includes(lowerKeyword)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按日期范围筛选
|
||||||
|
if (dateFrom || dateTo) {
|
||||||
|
filteredTasks = filteredTasks.filter(task => {
|
||||||
|
const taskDate = new Date(task.startDate);
|
||||||
|
if (dateFrom && new Date(dateFrom) > taskDate) return false;
|
||||||
|
if (dateTo && new Date(dateTo) < taskDate) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分页处理
|
||||||
|
const totalCount = filteredTasks.length;
|
||||||
|
const totalPages = Math.ceil(totalCount / pageSize);
|
||||||
|
const startIndex = (page - 1) * pageSize;
|
||||||
|
const endIndex = startIndex + pageSize;
|
||||||
|
const paginatedTasks = filteredTasks.slice(startIndex, endIndex);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
tasks: paginatedTasks,
|
||||||
|
totalCount,
|
||||||
|
currentPage: page,
|
||||||
|
pageSize,
|
||||||
|
totalPages
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取交叉评查任务列表失败:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : '获取任务列表失败'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建新的交叉评查任务
|
||||||
|
* @param taskData 任务数据
|
||||||
|
* @returns 创建结果
|
||||||
|
*/
|
||||||
|
export async function createCrossCheckingTask(taskData: Omit<CrossCheckingTask, 'id' | 'sequence' | 'progress' | 'score'>): Promise<ApiResponse<CrossCheckingTask>> {
|
||||||
|
try {
|
||||||
|
// 模拟API延迟
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 800));
|
||||||
|
|
||||||
|
const newTask: CrossCheckingTask = {
|
||||||
|
...taskData,
|
||||||
|
id: Math.max(...mockTasks.map(t => t.id)) + 1,
|
||||||
|
sequence: mockTasks.length + 1,
|
||||||
|
progress: 0,
|
||||||
|
score: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
// 添加到模拟数据
|
||||||
|
mockTasks.unshift(newTask);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: newTask,
|
||||||
|
message: '创建任务成功'
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('创建交叉评查任务失败:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : '创建任务失败'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除交叉评查任务
|
||||||
|
* @param taskId 任务ID
|
||||||
|
* @returns 删除结果
|
||||||
|
*/
|
||||||
|
export async function deleteCrossCheckingTask(taskId: number): Promise<ApiResponse<boolean>> {
|
||||||
|
try {
|
||||||
|
// 模拟API延迟
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
|
||||||
|
const taskIndex = mockTasks.findIndex(task => task.id === taskId);
|
||||||
|
if (taskIndex === -1) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: '任务不存在'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从模拟数据中删除
|
||||||
|
mockTasks.splice(taskIndex, 1);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: true,
|
||||||
|
message: '删除任务成功'
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('删除交叉评查任务失败:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : '删除任务失败'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取任务详情
|
||||||
|
* @param taskId 任务ID
|
||||||
|
* @returns 任务详情
|
||||||
|
*/
|
||||||
|
export async function getCrossCheckingTaskDetail(taskId: number): Promise<ApiResponse<CrossCheckingTask>> {
|
||||||
|
try {
|
||||||
|
// 模拟API延迟
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 300));
|
||||||
|
|
||||||
|
const task = mockTasks.find(t => t.id === taskId);
|
||||||
|
if (!task) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: '任务不存在'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: task
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取任务详情失败:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : '获取任务详情失败'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取统计数据
|
||||||
|
* @returns 统计数据
|
||||||
|
*/
|
||||||
|
export async function getCrossCheckingStats(): Promise<ApiResponse<{
|
||||||
|
totalTasks: number;
|
||||||
|
pendingTasks: number;
|
||||||
|
inProgressTasks: number;
|
||||||
|
completedTasks: number;
|
||||||
|
}>> {
|
||||||
|
try {
|
||||||
|
const totalTasks = mockTasks.length;
|
||||||
|
const pendingTasks = mockTasks.filter(t => t.status === CrossCheckingTaskStatus.PENDING).length;
|
||||||
|
const inProgressTasks = mockTasks.filter(t => t.status === CrossCheckingTaskStatus.IN_PROGRESS).length;
|
||||||
|
const completedTasks = mockTasks.filter(t => t.status === CrossCheckingTaskStatus.COMPLETED).length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
totalTasks,
|
||||||
|
pendingTasks,
|
||||||
|
inProgressTasks,
|
||||||
|
completedTasks
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取统计数据失败:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : '获取统计数据失败'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
# 认证模块 (Authentication Module)
|
||||||
|
|
||||||
|
这个文件夹包含了整个应用的用户认证和会话管理功能。
|
||||||
|
|
||||||
|
## 📁 文件结构
|
||||||
|
|
||||||
|
```
|
||||||
|
app/api/login/
|
||||||
|
├── README.md # 本文档
|
||||||
|
├── auth.server.ts # 🔐 认证核心实现
|
||||||
|
├── auth-exports.server.ts # 📤 认证函数导出入口
|
||||||
|
├── oauth-client.ts # 🌐 OAuth2.0 客户端
|
||||||
|
├── token-manager.server.ts # 🎫 Token 管理器
|
||||||
|
└── OAuth2.0认证协议集成指南.md # 📖 OAuth2.0 集成文档
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 核心文件说明
|
||||||
|
|
||||||
|
### `auth.server.ts` - 认证核心实现
|
||||||
|
包含所有认证相关的核心功能:
|
||||||
|
- **会话管理**: 基于 Cookie 的安全会话存储
|
||||||
|
- **用户认证**: 检查用户登录状态
|
||||||
|
- **Token 刷新**: 自动刷新过期的 OAuth Token
|
||||||
|
- **登录/登出**: 创建和销毁用户会话
|
||||||
|
|
||||||
|
主要函数:
|
||||||
|
- `getUserSession()` - 获取用户会话(带 Token 刷新)
|
||||||
|
- `createUserSession()` - 创建登录会话
|
||||||
|
- `logout()` - 销毁会话并登出
|
||||||
|
- `getSession()` - 获取原始会话对象
|
||||||
|
|
||||||
|
### `auth-exports.server.ts` - 导出入口
|
||||||
|
这个文件解决了 Remix + Vite 架构中的一个重要问题:
|
||||||
|
|
||||||
|
**问题**: 同构文件(.tsx)不能直接导入 .server.ts 文件
|
||||||
|
**原因**: 防止服务器端敏感代码被打包到客户端
|
||||||
|
**解决**: 通过这个 .server.ts 文件重新导出认证函数
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ 正确的导入方式
|
||||||
|
import { getUserSession } from "~/api/login/auth-exports.server";
|
||||||
|
|
||||||
|
// ❌ 错误的导入方式(会导致构建错误)
|
||||||
|
import { getUserSession } from "~/api/login/auth.server";
|
||||||
|
```
|
||||||
|
|
||||||
|
### `oauth-client.ts` - OAuth2.0 客户端
|
||||||
|
实现 OAuth2.0 认证协议的客户端功能:
|
||||||
|
- 生成授权 URL
|
||||||
|
- 获取访问令牌
|
||||||
|
- 获取用户信息
|
||||||
|
- 状态值生成和验证
|
||||||
|
|
||||||
|
### `token-manager.server.ts` - Token 管理
|
||||||
|
负责 OAuth Token 的生命周期管理:
|
||||||
|
- Token 有效期检查
|
||||||
|
- 自动刷新即将过期的 Token
|
||||||
|
- Token 错误处理
|
||||||
|
|
||||||
|
## 🚀 使用指南
|
||||||
|
|
||||||
|
### 在路由中检查用户认证
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// app/routes/some-route.tsx
|
||||||
|
import { getUserSession } from "~/api/login/auth-exports.server";
|
||||||
|
|
||||||
|
export async function loader({ request }: LoaderFunctionArgs) {
|
||||||
|
const { isAuthenticated, userRole } = await getUserSession(request);
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return redirect("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查权限
|
||||||
|
if (userRole !== 'developer') {
|
||||||
|
throw new Response("权限不足", { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({ userRole });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 处理用户登出
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// app/routes/some-route.tsx
|
||||||
|
import { logout } from "~/api/login/auth-exports.server";
|
||||||
|
|
||||||
|
export async function action({ request }: ActionFunctionArgs) {
|
||||||
|
const formData = await request.formData();
|
||||||
|
|
||||||
|
if (formData.get("intent") === "logout") {
|
||||||
|
return logout(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 创建登录会话
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// app/routes/callback.tsx
|
||||||
|
import { createUserSession } from "~/api/login/auth-exports.server";
|
||||||
|
|
||||||
|
export async function loader({ request }: LoaderFunctionArgs) {
|
||||||
|
// OAuth 认证成功后...
|
||||||
|
|
||||||
|
// 根据用户信息判断角色
|
||||||
|
const userRole = userInfo.username === "admin" ? "developer" : "common";
|
||||||
|
|
||||||
|
return createUserSession(true, userRole, "/dashboard");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🛡️ 安全特性
|
||||||
|
|
||||||
|
### Cookie 安全配置
|
||||||
|
```typescript
|
||||||
|
cookie: {
|
||||||
|
httpOnly: true, // 防止 XSS 攻击
|
||||||
|
sameSite: "lax", // CSRF 保护
|
||||||
|
secure: false, // 开发环境设为 false,生产环境应为 true
|
||||||
|
maxAge: 7200, // 2小时过期
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Token 自动刷新
|
||||||
|
- 检查 Token 是否即将过期(5分钟内)
|
||||||
|
- 自动使用 refresh_token 获取新的 access_token
|
||||||
|
- 刷新失败时自动登出用户
|
||||||
|
|
||||||
|
### 用户角色权限
|
||||||
|
- `common`: 普通用户,基本功能访问权限
|
||||||
|
- `developer`: 开发者/管理员,完整系统管理权限
|
||||||
|
|
||||||
|
## 🔄 OAuth2.0 登录流程
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant User as 用户
|
||||||
|
participant App as 应用
|
||||||
|
participant IDaaS as IDaaS平台
|
||||||
|
|
||||||
|
User->>App: 访问受保护页面
|
||||||
|
App->>User: 重定向到 /login
|
||||||
|
User->>App: 点击"统一身份认证登录"
|
||||||
|
App->>IDaaS: 重定向到 IDaaS 登录页
|
||||||
|
User->>IDaaS: 完成登录
|
||||||
|
IDaaS->>App: 回调并返回 code
|
||||||
|
App->>IDaaS: 使用 code 获取 access_token
|
||||||
|
IDaaS->>App: 返回 access_token
|
||||||
|
App->>IDaaS: 使用 access_token 获取用户信息
|
||||||
|
IDaaS->>App: 返回用户信息
|
||||||
|
App->>App: 创建用户会话
|
||||||
|
App->>User: 重定向到目标页面
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚨 注意事项
|
||||||
|
|
||||||
|
### 开发环境配置
|
||||||
|
1. 确保 OAuth 配置正确(`app/config/api-config.ts`)
|
||||||
|
2. IDaaS 平台中配置正确的回调地址
|
||||||
|
3. 检查 Cookie 的 `secure` 设置(开发环境为 `false`)
|
||||||
|
|
||||||
|
### 生产环境部署
|
||||||
|
1. 使用环境变量管理敏感信息(secrets)
|
||||||
|
2. 启用 HTTPS 并设置 `secure: true`
|
||||||
|
3. 配置正确的域名和回调地址
|
||||||
|
4. 监控 Token 刷新日志
|
||||||
|
|
||||||
|
### 调试技巧
|
||||||
|
- 查看浏览器 Application 标签页中的 Cookies
|
||||||
|
- 检查服务器控制台的认证相关日志
|
||||||
|
- 使用 Remix Dev Tools 查看 loader 数据
|
||||||
|
|
||||||
|
## 📖 相关文档
|
||||||
|
|
||||||
|
- [OAuth2.0认证协议集成指南](./OAuth2.0认证协议集成指南.md)
|
||||||
|
- [Remix Session Storage 文档](https://remix.run/docs/en/main/utils/sessions)
|
||||||
|
- [Cookie Security Best Practices](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies)
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
/**
|
||||||
|
* 认证服务核心实现
|
||||||
|
*
|
||||||
|
* 这个文件包含了用户认证、会话管理、Token 刷新等核心功能的具体实现。
|
||||||
|
*
|
||||||
|
* 主要功能:
|
||||||
|
* - 基于 Cookie 的会话管理
|
||||||
|
* - OAuth2.0 Token 自动刷新
|
||||||
|
* - 用户登录状态检查
|
||||||
|
* - 会话创建和销毁
|
||||||
|
*
|
||||||
|
* 技术栈:
|
||||||
|
* - Remix Session Storage (Cookie-based)
|
||||||
|
* - OAuth2.0 Token Management
|
||||||
|
* - TypeScript 类型安全
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createCookieSessionStorage } from "@remix-run/node";
|
||||||
|
import { tokenManager } from "./token-manager.server";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户角色类型定义
|
||||||
|
*
|
||||||
|
* @typedef {string} UserRole
|
||||||
|
* @property {'common'} common - 普通用户,有基本的系统访问权限
|
||||||
|
* @property {'developer'} developer - 开发者/管理员,有完整的系统管理权限
|
||||||
|
*/
|
||||||
|
export type UserRole = 'common' | 'developer';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 会话存储配置
|
||||||
|
*
|
||||||
|
* 使用 Remix 的 Cookie Session Storage 来管理用户会话。
|
||||||
|
* Cookie 存储方式的优势:
|
||||||
|
* - 服务器端控制,安全性高
|
||||||
|
* - 自动处理过期时间
|
||||||
|
* - 支持 HttpOnly,防止 XSS 攻击
|
||||||
|
*
|
||||||
|
* 配置说明:
|
||||||
|
* - name: Cookie 名称,用于标识会话
|
||||||
|
* - httpOnly: 防止客户端 JavaScript 访问,提高安全性
|
||||||
|
* - sameSite: CSRF 保护
|
||||||
|
* - secrets: 用于签名和加密 Cookie 内容
|
||||||
|
* - maxAge: 会话有效期,与 OAuth Token 过期时间保持一致
|
||||||
|
*/
|
||||||
|
export const sessionStorage = createCookieSessionStorage({
|
||||||
|
cookie: {
|
||||||
|
name: "__lgsession", // 登录会话 Cookie 名称
|
||||||
|
httpOnly: true, // 仅服务器端可访问,防止 XSS
|
||||||
|
path: "/", // Cookie 作用域为整个应用
|
||||||
|
sameSite: "lax", // CSRF 保护,允许顶级导航
|
||||||
|
secrets: ["s3cr3t"], // TODO: 应该从环境变量读取
|
||||||
|
maxAge: 60 * 60 * 2, // 2小时,与 OAuth Token 同步
|
||||||
|
secure: false, // 开发环境中禁用 HTTPS 要求
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取会话对象
|
||||||
|
*
|
||||||
|
* 从请求中提取 Cookie 并获取对应的会话对象。
|
||||||
|
* 这是一个底层函数,通常不直接使用,而是通过 getUserSession 使用。
|
||||||
|
*
|
||||||
|
* @param request - Remix Request 对象,包含 HTTP 请求信息
|
||||||
|
* @returns 会话对象,可以用来读取和设置会话数据
|
||||||
|
*/
|
||||||
|
export async function getSession(request: Request) {
|
||||||
|
const cookie = request.headers.get("Cookie");
|
||||||
|
return sessionStorage.getSession(cookie);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户登录状态(带自动Token刷新)
|
||||||
|
*
|
||||||
|
* 这是认证系统的核心函数,负责:
|
||||||
|
* 1. 检查用户是否已登录
|
||||||
|
* 2. 验证 OAuth Token 是否有效
|
||||||
|
* 3. 自动刷新即将过期的 Token
|
||||||
|
* 4. 返回用户信息和认证状态
|
||||||
|
*
|
||||||
|
* Token 刷新机制:
|
||||||
|
* - 检查 access_token 是否即将过期(距离过期时间 < 5 分钟)
|
||||||
|
* - 如果即将过期,使用 refresh_token 自动获取新的 access_token
|
||||||
|
* - 如果刷新失败,则认为用户未登录
|
||||||
|
*
|
||||||
|
* @param request - Remix Request 对象
|
||||||
|
* @returns 包含以下字段的对象:
|
||||||
|
* - isAuthenticated: 是否已认证
|
||||||
|
* - userRole: 用户角色 ('common' | 'developer')
|
||||||
|
* - accessToken: OAuth 访问令牌
|
||||||
|
* - refreshToken: OAuth 刷新令牌
|
||||||
|
* - userInfo: 用户详细信息
|
||||||
|
* - isTokenExpired: Token 是否已过期
|
||||||
|
* - refreshedSession: 如果刷新了 Token,返回更新后的会话对象
|
||||||
|
*/
|
||||||
|
export async function getUserSession(request: Request) {
|
||||||
|
const session = await getSession(request);
|
||||||
|
const isAuthenticated = session.get("isAuthenticated") === true;
|
||||||
|
const userRole = session.get("userRole") || 'common' as UserRole;
|
||||||
|
let accessToken = session.get("accessToken");
|
||||||
|
const refreshToken = session.get("refreshToken");
|
||||||
|
let tokenIssuedAt = session.get("tokenIssuedAt");
|
||||||
|
let tokenExpiresIn = session.get("tokenExpiresIn");
|
||||||
|
const userInfo = session.get("userInfo");
|
||||||
|
|
||||||
|
let isTokenExpired = false;
|
||||||
|
let refreshedSession = null;
|
||||||
|
|
||||||
|
// 如果有token信息,检查是否需要刷新
|
||||||
|
if (accessToken && refreshToken && tokenIssuedAt && tokenExpiresIn) {
|
||||||
|
try {
|
||||||
|
const tokenInfo = {
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
tokenIssuedAt,
|
||||||
|
tokenExpiresIn
|
||||||
|
};
|
||||||
|
|
||||||
|
// 检查并自动刷新token
|
||||||
|
const refreshResult = await tokenManager.checkAndRefreshToken(tokenInfo);
|
||||||
|
|
||||||
|
if (refreshResult.success && refreshResult.newTokenInfo) {
|
||||||
|
const newToken = refreshResult.newTokenInfo;
|
||||||
|
|
||||||
|
// 如果token被刷新了,更新session
|
||||||
|
if (newToken.accessToken !== accessToken) {
|
||||||
|
console.log("Token已刷新,更新session");
|
||||||
|
|
||||||
|
session.set("accessToken", newToken.accessToken);
|
||||||
|
session.set("refreshToken", newToken.refreshToken);
|
||||||
|
session.set("tokenIssuedAt", newToken.tokenIssuedAt);
|
||||||
|
session.set("tokenExpiresIn", newToken.tokenExpiresIn);
|
||||||
|
|
||||||
|
// 更新本地变量
|
||||||
|
accessToken = newToken.accessToken;
|
||||||
|
tokenIssuedAt = newToken.tokenIssuedAt;
|
||||||
|
tokenExpiresIn = newToken.tokenExpiresIn;
|
||||||
|
|
||||||
|
refreshedSession = session;
|
||||||
|
}
|
||||||
|
|
||||||
|
isTokenExpired = false;
|
||||||
|
} else {
|
||||||
|
console.error("Token刷新失败:", refreshResult.error);
|
||||||
|
isTokenExpired = true;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Token验证过程中出错:", error);
|
||||||
|
isTokenExpired = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isAuthenticated: isAuthenticated && !isTokenExpired,
|
||||||
|
userRole,
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
userInfo,
|
||||||
|
isTokenExpired,
|
||||||
|
refreshedSession // 如果刷新了token,返回更新后的session
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建用户登录会话
|
||||||
|
*
|
||||||
|
* 在用户成功登录后调用此函数来创建会话并设置 Cookie。
|
||||||
|
* 这个函数通常在以下场景中使用:
|
||||||
|
* - OAuth2.0 登录成功后
|
||||||
|
* - 临时管理员登录
|
||||||
|
* - 其他认证方式成功后
|
||||||
|
*
|
||||||
|
* 处理流程:
|
||||||
|
* 1. 创建新的会话对象
|
||||||
|
* 2. 设置认证状态和用户角色
|
||||||
|
* 3. 生成签名的 Cookie
|
||||||
|
* 4. 返回重定向响应并设置 Cookie
|
||||||
|
*
|
||||||
|
* @param isAuthenticated - 是否已认证,通常为 true
|
||||||
|
* @param userRole - 用户角色,决定用户的权限级别
|
||||||
|
* @param redirectTo - 登录成功后重定向的 URL,默认为首页
|
||||||
|
* @returns HTTP 302 重定向响应,包含设置 Cookie 的头部
|
||||||
|
*/
|
||||||
|
export async function createUserSession(isAuthenticated: boolean, userRole: UserRole, redirectTo: string) {
|
||||||
|
const session = await sessionStorage.getSession();
|
||||||
|
session.set("isAuthenticated", isAuthenticated);
|
||||||
|
session.set("userRole", userRole);
|
||||||
|
|
||||||
|
const cookie = await sessionStorage.commitSession(session);
|
||||||
|
console.log("创建会话 - 设置Cookie:", !!cookie);
|
||||||
|
console.log("创建会话 - 用户角色:", userRole);
|
||||||
|
console.log("创建会话 - 重定向到:", redirectTo);
|
||||||
|
|
||||||
|
return new Response(null, {
|
||||||
|
status: 302, // HTTP 重定向状态码
|
||||||
|
headers: {
|
||||||
|
Location: redirectTo, // 重定向目标 URL
|
||||||
|
"Set-Cookie": cookie, // 设置会话 Cookie
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 销毁会话(用户登出)
|
||||||
|
*
|
||||||
|
* 当用户主动登出或会话失效时调用此函数。
|
||||||
|
*
|
||||||
|
* 处理流程:
|
||||||
|
* 1. 获取当前用户的会话
|
||||||
|
* 2. 销毁会话数据(清除所有存储的信息)
|
||||||
|
* 3. 清除客户端的会话 Cookie
|
||||||
|
* 4. 重定向到登录页面
|
||||||
|
*
|
||||||
|
* 注意事项:
|
||||||
|
* - 这个函数只处理本地会话,不会调用 IDaaS 的单点登出
|
||||||
|
* - 如果需要全局登出,应该额外调用 IDaaS 的 SLO 接口
|
||||||
|
* - 销毁会话后,用户需要重新登录才能访问受保护的页面
|
||||||
|
*
|
||||||
|
* @param request - Remix Request 对象,用于获取当前会话
|
||||||
|
* @returns HTTP 302 重定向响应,清除 Cookie 并跳转到登录页
|
||||||
|
*/
|
||||||
|
export async function logout(request: Request) {
|
||||||
|
const session = await getSession(request);
|
||||||
|
|
||||||
|
return new Response(null, {
|
||||||
|
status: 302, // HTTP 重定向状态码
|
||||||
|
headers: {
|
||||||
|
Location: "/login", // 重定向到登录页面
|
||||||
|
"Set-Cookie": await sessionStorage.destroySession(session), // 清除会话 Cookie
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* OAuth2.0客户端类
|
* OAuth2.0客户端类
|
||||||
* 用于处理IDaaS OAuth2.0认证流程
|
* 用于处理IDaaS OAuth2.0认证流程
|
||||||
|
* 如果需要添加新的Token相关功能:
|
||||||
|
* 1. 优先考虑在 `TokenManager` 中添加
|
||||||
|
* 2. 如果需要新的网络请求,在 `OAuthClient` 中添加
|
||||||
*/
|
*/
|
||||||
|
|
||||||
interface OAuthConfig {
|
interface OAuthConfig {
|
||||||
@@ -127,6 +130,42 @@ export class OAuthClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刷新访问令牌
|
||||||
|
* @param refreshToken 刷新令牌
|
||||||
|
* @returns 新的访问令牌响应
|
||||||
|
*/
|
||||||
|
async refreshAccessToken(refreshToken: string): Promise<TokenResponse | null> {
|
||||||
|
const url = `${this.config.serverUrl}/oauth/token`;
|
||||||
|
const data = new URLSearchParams({
|
||||||
|
grant_type: 'refresh_token',
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
client_id: this.config.clientId,
|
||||||
|
client_secret: this.config.clientSecret
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded'
|
||||||
|
},
|
||||||
|
body: data
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
console.error('刷新访问令牌失败:', errorData);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json() as TokenResponse;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('刷新访问令牌网络错误:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 单点登出
|
* 单点登出
|
||||||
* @param accessToken 访问令牌
|
* @param accessToken 访问令牌
|
||||||
@@ -197,15 +236,5 @@ export const oauthUtils = {
|
|||||||
return state === expectedState;
|
return state === expectedState;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* 检查访问令牌是否过期
|
|
||||||
* @param tokenInfo 令牌信息
|
|
||||||
* @param issuedAt 令牌颁发时间戳
|
|
||||||
* @returns 是否过期
|
|
||||||
*/
|
|
||||||
isTokenExpired(tokenInfo: TokenResponse, issuedAt: number): boolean {
|
|
||||||
const now = Date.now();
|
|
||||||
const expiresAt = issuedAt + (tokenInfo.expires_in * 1000);
|
|
||||||
return now >= expiresAt;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
/**
|
||||||
|
* Token管理服务
|
||||||
|
* 负责处理OAuth访问令牌的刷新和管理
|
||||||
|
* 如果需要添加新的Token相关功能:
|
||||||
|
* 1. 优先考虑在 `TokenManager` 中添加
|
||||||
|
* 2. 如果需要新的网络请求,在 `OAuthClient` 中添加
|
||||||
|
*/
|
||||||
|
import { OAuthClient } from "./oauth-client";
|
||||||
|
import { OAUTH_CONFIG } from "~/config/api-config";
|
||||||
|
|
||||||
|
interface TokenInfo {
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
|
tokenIssuedAt: number;
|
||||||
|
tokenExpiresIn: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RefreshResult {
|
||||||
|
success: boolean;
|
||||||
|
newTokenInfo?: TokenInfo;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Token管理服务
|
||||||
|
* 负责处理OAuth访问令牌的刷新和管理
|
||||||
|
*/
|
||||||
|
export class TokenManager {
|
||||||
|
private oauthClient: OAuthClient;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.oauthClient = new OAuthClient(OAUTH_CONFIG);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查token是否过期
|
||||||
|
*/
|
||||||
|
isTokenExpired(tokenInfo: TokenInfo): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
const expiresAt = tokenInfo.tokenIssuedAt + (tokenInfo.tokenExpiresIn * 1000);
|
||||||
|
return now >= expiresAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查token是否需要刷新(提前5分钟)
|
||||||
|
*/
|
||||||
|
shouldRefreshToken(tokenInfo: TokenInfo, refreshThresholdMinutes: number = 5): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
const expiresAt = tokenInfo.tokenIssuedAt + (tokenInfo.tokenExpiresIn * 1000);
|
||||||
|
const refreshThreshold = refreshThresholdMinutes * 60 * 1000;
|
||||||
|
return now >= (expiresAt - refreshThreshold);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取token剩余有效时间(秒)
|
||||||
|
*/
|
||||||
|
getTokenRemainingTime(tokenInfo: TokenInfo): number {
|
||||||
|
const now = Date.now();
|
||||||
|
const expiresAt = tokenInfo.tokenIssuedAt + (tokenInfo.tokenExpiresIn * 1000);
|
||||||
|
return Math.floor((expiresAt - now) / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刷新访问令牌
|
||||||
|
*/
|
||||||
|
async refreshToken(refreshToken: string): Promise<RefreshResult> {
|
||||||
|
try {
|
||||||
|
console.log("开始刷新访问令牌...");
|
||||||
|
|
||||||
|
const newTokenResponse = await this.oauthClient.refreshAccessToken(refreshToken);
|
||||||
|
|
||||||
|
if (!newTokenResponse) {
|
||||||
|
console.error("刷新令牌失败:服务器返回空响应");
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "服务器返回空响应"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const newTokenInfo: TokenInfo = {
|
||||||
|
accessToken: newTokenResponse.access_token,
|
||||||
|
refreshToken: newTokenResponse.refresh_token,
|
||||||
|
tokenIssuedAt: Date.now(),
|
||||||
|
tokenExpiresIn: newTokenResponse.expires_in
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`令牌刷新成功,新令牌有效期: ${newTokenResponse.expires_in}秒`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
newTokenInfo
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("刷新令牌时发生错误:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : "未知错误"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查并自动刷新token(如果需要)
|
||||||
|
*/
|
||||||
|
async checkAndRefreshToken(tokenInfo: TokenInfo): Promise<RefreshResult> {
|
||||||
|
// 如果token已过期,必须刷新
|
||||||
|
if (this.isTokenExpired(tokenInfo)) {
|
||||||
|
console.log("Token已过期,尝试刷新...");
|
||||||
|
return this.refreshToken(tokenInfo.refreshToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果token即将过期,主动刷新
|
||||||
|
if (this.shouldRefreshToken(tokenInfo)) {
|
||||||
|
const remainingTime = this.getTokenRemainingTime(tokenInfo);
|
||||||
|
console.log(`Token将在${remainingTime}秒后过期,主动刷新...`);
|
||||||
|
return this.refreshToken(tokenInfo.refreshToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token仍然有效,无需刷新
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
newTokenInfo: tokenInfo
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化token到期时间
|
||||||
|
*/
|
||||||
|
formatTokenExpiry(tokenInfo: TokenInfo): string {
|
||||||
|
const expiresAt = new Date(tokenInfo.tokenIssuedAt + (tokenInfo.tokenExpiresIn * 1000));
|
||||||
|
return expiresAt.toLocaleString('zh-CN');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取token状态信息(用于调试)
|
||||||
|
*/
|
||||||
|
getTokenStatus(tokenInfo: TokenInfo): {
|
||||||
|
isExpired: boolean;
|
||||||
|
shouldRefresh: boolean;
|
||||||
|
remainingTime: number;
|
||||||
|
expiryTime: string;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
isExpired: this.isTokenExpired(tokenInfo),
|
||||||
|
shouldRefresh: this.shouldRefreshToken(tokenInfo),
|
||||||
|
remainingTime: this.getTokenRemainingTime(tokenInfo),
|
||||||
|
expiryTime: this.formatTokenExpiry(tokenInfo)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出单例实例
|
||||||
|
export const tokenManager = new TokenManager();
|
||||||
+20
-88
@@ -14,7 +14,6 @@ import {
|
|||||||
import {
|
import {
|
||||||
LoaderFunctionArgs,
|
LoaderFunctionArgs,
|
||||||
redirect,
|
redirect,
|
||||||
createCookieSessionStorage,
|
|
||||||
ActionFunctionArgs
|
ActionFunctionArgs
|
||||||
} from "@remix-run/node";
|
} from "@remix-run/node";
|
||||||
import { Layout } from "~/components/layout/Layout";
|
import { Layout } from "~/components/layout/Layout";
|
||||||
@@ -30,8 +29,14 @@ import LoadingBarContainer from "~/components/ui/LoadingBar";
|
|||||||
import RouteChangeLoader from "~/components/ui/RouteChangeLoader";
|
import RouteChangeLoader from "~/components/ui/RouteChangeLoader";
|
||||||
// import { useState, useEffect } from "react";
|
// import { useState, useEffect } from "react";
|
||||||
|
|
||||||
// 定义用户角色类型
|
// 导入认证相关的服务器端功能(仅在服务器端使用)
|
||||||
export type UserRole = 'common' | 'developer';
|
import {
|
||||||
|
getUserSession,
|
||||||
|
getSession,
|
||||||
|
sessionStorage,
|
||||||
|
logout,
|
||||||
|
type UserRole
|
||||||
|
} from "~/api/login/auth.server";
|
||||||
|
|
||||||
// 定义需要高级权限的路径
|
// 定义需要高级权限的路径
|
||||||
export const developerOnlyPaths = [
|
export const developerOnlyPaths = [
|
||||||
@@ -41,91 +46,10 @@ export const developerOnlyPaths = [
|
|||||||
'/prompts',
|
'/prompts',
|
||||||
];
|
];
|
||||||
|
|
||||||
// 创建基于Cookie的会话存储
|
// 导出类型供客户端使用
|
||||||
// 在实际应用中,应该使用环境变量来设置密钥
|
export type { UserRole };
|
||||||
export const sessionStorage = createCookieSessionStorage({
|
|
||||||
cookie: {
|
|
||||||
name: "__lgsession",
|
|
||||||
httpOnly: true,
|
|
||||||
path: "/",
|
|
||||||
sameSite: "lax",
|
|
||||||
secrets: ["s3cr3t"], // 应该从环境变量读取
|
|
||||||
// secure: process.env.NODE_ENV === "production",
|
|
||||||
maxAge: 60 * 60 * 24 * 1, // 1天
|
|
||||||
secure: false, // 开发环境中禁用secure
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 获取会话对象
|
|
||||||
export async function getSession(request: Request) {
|
|
||||||
const cookie = request.headers.get("Cookie");
|
|
||||||
return sessionStorage.getSession(cookie);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取用户登录状态
|
|
||||||
export async function getUserSession(request: Request) {
|
|
||||||
const session = await getSession(request);
|
|
||||||
const isAuthenticated = session.get("isAuthenticated") === true;
|
|
||||||
const userRole = session.get("userRole") || 'common' as UserRole;
|
|
||||||
const accessToken = session.get("accessToken");
|
|
||||||
const refreshToken = session.get("refreshToken");
|
|
||||||
const tokenIssuedAt = session.get("tokenIssuedAt");
|
|
||||||
const tokenExpiresIn = session.get("tokenExpiresIn");
|
|
||||||
const userInfo = session.get("userInfo");
|
|
||||||
|
|
||||||
// 检查token是否过期
|
|
||||||
let isTokenExpired = false;
|
|
||||||
if (accessToken && tokenIssuedAt && tokenExpiresIn) {
|
|
||||||
const now = Date.now();
|
|
||||||
const expiresAt = tokenIssuedAt + (tokenExpiresIn * 1000);
|
|
||||||
isTokenExpired = now >= expiresAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
// console.log("获取会话状态:",
|
|
||||||
// // "Cookie:", request.headers.get("Cookie"),
|
|
||||||
// "是否认证:", isAuthenticated,
|
|
||||||
// "用户角色:", userRole,
|
|
||||||
// "Token过期:", isTokenExpired
|
|
||||||
// );
|
|
||||||
|
|
||||||
return {
|
|
||||||
isAuthenticated: isAuthenticated && !isTokenExpired,
|
|
||||||
userRole,
|
|
||||||
accessToken,
|
|
||||||
refreshToken,
|
|
||||||
userInfo,
|
|
||||||
isTokenExpired
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建登录会话
|
|
||||||
export async function createUserSession(isAuthenticated: boolean, userRole: UserRole, redirectTo: string) {
|
|
||||||
const session = await sessionStorage.getSession();
|
|
||||||
session.set("isAuthenticated", isAuthenticated);
|
|
||||||
session.set("userRole", userRole);
|
|
||||||
|
|
||||||
const cookie = await sessionStorage.commitSession(session);
|
|
||||||
console.log("创建会话 - 设置Cookie:", !!cookie);
|
|
||||||
console.log("创建会话 - 用户角色:", userRole);
|
|
||||||
console.log("创建会话 - 重定向到:", redirectTo);
|
|
||||||
|
|
||||||
return redirect(redirectTo, {
|
|
||||||
headers: {
|
|
||||||
"Set-Cookie": cookie,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 销毁会话(登出)
|
|
||||||
export async function logout(request: Request) {
|
|
||||||
const session = await getSession(request);
|
|
||||||
|
|
||||||
return redirect("/login", {
|
|
||||||
headers: {
|
|
||||||
"Set-Cookie": await sessionStorage.destroySession(session),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加action处理登录/登出请求
|
// 添加action处理登录/登出请求
|
||||||
export async function action({ request }: ActionFunctionArgs) {
|
export async function action({ request }: ActionFunctionArgs) {
|
||||||
@@ -149,8 +73,8 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
|||||||
const publicPaths = ['/login', '/favicon.ico'];
|
const publicPaths = ['/login', '/favicon.ico'];
|
||||||
const isPublicPath = publicPaths.some(path => pathname.startsWith(path));
|
const isPublicPath = publicPaths.some(path => pathname.startsWith(path));
|
||||||
|
|
||||||
// 获取用户会话
|
// 获取用户会话(可能包含刷新后的token)
|
||||||
const { isAuthenticated, userRole } = await getUserSession(request);
|
const { isAuthenticated, userRole, refreshedSession } = await getUserSession(request);
|
||||||
// console.log("是否公开路径:", isPublicPath, "是否已认证:", isAuthenticated);
|
// console.log("是否公开路径:", isPublicPath, "是否已认证:", isAuthenticated);
|
||||||
|
|
||||||
// 如果访问需要认证的路径但未登录,重定向到登录页
|
// 如果访问需要认证的路径但未登录,重定向到登录页
|
||||||
@@ -184,6 +108,12 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
|||||||
return redirect("/");
|
return redirect("/");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 如果token被刷新了,需要在响应中设置更新后的cookie
|
||||||
|
const responseHeaders: Record<string, string> = {};
|
||||||
|
if (refreshedSession) {
|
||||||
|
responseHeaders["Set-Cookie"] = await sessionStorage.commitSession(refreshedSession);
|
||||||
|
}
|
||||||
|
|
||||||
// 向组件传递认证状态、当前路径和环境变量
|
// 向组件传递认证状态、当前路径和环境变量
|
||||||
return Response.json({
|
return Response.json({
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
@@ -194,6 +124,8 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
|||||||
NEXT_PUBLIC_APP_ID: process.env.NEXT_PUBLIC_APP_ID,
|
NEXT_PUBLIC_APP_ID: process.env.NEXT_PUBLIC_APP_ID,
|
||||||
NEXT_PUBLIC_APP_KEY: process.env.NEXT_PUBLIC_APP_KEY,
|
NEXT_PUBLIC_APP_KEY: process.env.NEXT_PUBLIC_APP_KEY,
|
||||||
},
|
},
|
||||||
|
}, {
|
||||||
|
headers: responseHeaders
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useNavigate, Form, useLoaderData } from '@remix-run/react';
|
|||||||
import { type MetaFunction, type ActionFunctionArgs, LoaderFunctionArgs, redirect } from "@remix-run/node";
|
import { type MetaFunction, type ActionFunctionArgs, LoaderFunctionArgs, redirect } from "@remix-run/node";
|
||||||
import styles from "~/styles/pages/home.css?url";
|
import styles from "~/styles/pages/home.css?url";
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { getUserSession, logout } from "~/root";
|
import { getUserSession, logout } from "~/api/login/auth.server";
|
||||||
|
|
||||||
export const links = () => [
|
export const links = () => [
|
||||||
{ rel: "stylesheet", href: styles }
|
{ rel: "stylesheet", href: styles }
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { type LoaderFunctionArgs, redirect } from "@remix-run/node";
|
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 { 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) {
|
export async function loader({ request }: LoaderFunctionArgs) {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const code = url.searchParams.get("code");
|
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 = url.searchParams.get("error");
|
||||||
const error_description = url.searchParams.get("error_description");
|
const error_description = url.searchParams.get("error_description");
|
||||||
|
|
||||||
@@ -18,16 +19,17 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
|||||||
|
|
||||||
// 检查是否有授权码
|
// 检查是否有授权码
|
||||||
if (!code) {
|
if (!code) {
|
||||||
|
toastService.error("通过OAuth2.0登录回调缺少授权码");
|
||||||
console.error("OAuth2.0回调缺少授权码");
|
console.error("OAuth2.0回调缺少授权码");
|
||||||
return redirect("/login?error=missing_code");
|
return redirect("/login?error=missing_code");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证状态值(可选,但建议实现)
|
// 验证状态值(可选,但建议实现)
|
||||||
// 这里简单验证state是否以_idp结尾
|
// 这里简单验证state是否以_idp结尾
|
||||||
if (!state || !state.endsWith("_idp")) {
|
// if (!state || !state.endsWith("_idp")) {
|
||||||
console.error("OAuth2.0状态值验证失败");
|
// console.error("OAuth2.0状态值验证失败");
|
||||||
return redirect("/login?error=invalid_state");
|
// return redirect("/login?error=invalid_state");
|
||||||
}
|
// }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 创建OAuth客户端
|
// 创建OAuth客户端
|
||||||
|
|||||||
@@ -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 = () => {
|
export const meta: MetaFunction = () => {
|
||||||
return [
|
return [
|
||||||
{title: "交叉评查 - 中国烟草AI合同及卷宗审核系统"},
|
{ title: "中国烟草AI合同及卷宗审核系统 - 交叉评查" },
|
||||||
{name: "cross-checking", content: "交叉评查"}
|
{ 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) => {
|
export async function action({ request }: LoaderFunctionArgs) {
|
||||||
// const { user } = await requireUser(request);
|
const formData = await request.formData();
|
||||||
// return json({ user });
|
const _action = formData.get('_action');
|
||||||
// }
|
const taskId = formData.get('taskId');
|
||||||
|
|
||||||
// export const action = async ({ request }: ActionFunctionArgs) => {
|
if (!taskId) {
|
||||||
// const { user } = await requireUser(request);
|
return Response.json({ result: false, message: "缺少任务ID" }, { status: 400 });
|
||||||
// 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() {
|
export default function CrossCheckingIndex() {
|
||||||
return (
|
const loaderData = useLoaderData<typeof loader>();
|
||||||
<div>
|
const { tasks, totalCount, currentPage, pageSize, stats } = loaderData;
|
||||||
<h1>交叉评查</h1>
|
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>
|
</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
@@ -11,10 +11,9 @@ import { getDocuments, type DocumentUI, type DocumentSearchParams } from "~/api/
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { getHomeData } from "~/api/home/home";
|
import { getHomeData } from "~/api/home/home";
|
||||||
import dayjs from 'dayjs';
|
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 { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/node";
|
||||||
import { logout, getUserSession } from "~/root";
|
import { logout, getUserSession } from "~/api/login/auth.server";
|
||||||
// import { getUserSession } from "~/root";
|
|
||||||
|
|
||||||
// 文件处理状态选项
|
// 文件处理状态选项
|
||||||
const fileProcessingStatusOptions = [
|
const fileProcessingStatusOptions = [
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useSearchParams, Form } from "@remix-run/react";
|
import { useSearchParams, Form } from "@remix-run/react";
|
||||||
import { type MetaFunction, type LoaderFunctionArgs, type ActionFunctionArgs, redirect } from "@remix-run/node";
|
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 { 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";
|
import styles from "~/styles/pages/login.css?url";
|
||||||
|
|
||||||
export const links = () => [
|
export const links = () => [
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { type LoaderFunctionArgs, redirect } from "@remix-run/node";
|
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 { OAUTH_CONFIG } from "~/config/api-config";
|
||||||
import { sessionStorage } from "~/root";
|
import { sessionStorage } from "~/api/login/auth.server";
|
||||||
|
|
||||||
export async function loader({ request }: LoaderFunctionArgs) {
|
export async function loader({ request }: LoaderFunctionArgs) {
|
||||||
const session = await sessionStorage.getSession(request.headers.get("Cookie"));
|
const session = await sessionStorage.getSession(request.headers.get("Cookie"));
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
/* 交叉评查页面样式 */
|
||||||
|
.cross-checking-page {
|
||||||
|
@apply p-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 页面头部样式 */
|
||||||
|
.cross-checking-page .page-header {
|
||||||
|
@apply flex justify-between items-center mb-6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cross-checking-page .page-title {
|
||||||
|
@apply text-2xl font-semibold text-gray-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cross-checking-page .page-stats {
|
||||||
|
@apply flex items-center space-x-4 ml-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cross-checking-page .stat-item {
|
||||||
|
@apply bg-white px-4 py-2 rounded-lg shadow-sm border border-gray-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cross-checking-page .stat-icon {
|
||||||
|
@apply text-green-600 mr-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cross-checking-page .stat-value {
|
||||||
|
@apply font-semibold text-gray-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 筛选面板样式 */
|
||||||
|
.cross-checking-page .filter-panel {
|
||||||
|
@apply bg-white rounded-lg shadow-sm border border-gray-200 mb-6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表格样式 */
|
||||||
|
.cross-checking-page .task-table {
|
||||||
|
@apply bg-white rounded-lg shadow-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cross-checking-page .task-table .ant-table {
|
||||||
|
@apply border-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cross-checking-page .task-table thead th {
|
||||||
|
@apply bg-gray-50 font-medium text-gray-700 py-3 px-4 border-b border-gray-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cross-checking-page .task-table tbody td {
|
||||||
|
@apply py-3 px-4 border-b border-gray-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cross-checking-page .task-table tbody tr:hover {
|
||||||
|
@apply bg-gray-50;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 任务名称样式 */
|
||||||
|
.cross-checking-page .task-name {
|
||||||
|
@apply font-medium text-gray-900 hover:text-green-600 cursor-pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 状态标签样式 */
|
||||||
|
.cross-checking-page .status-tag {
|
||||||
|
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cross-checking-page .status-tag.pending {
|
||||||
|
@apply bg-yellow-100 text-yellow-800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cross-checking-page .status-tag.in-progress {
|
||||||
|
@apply bg-blue-100 text-blue-800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cross-checking-page .status-tag.completed {
|
||||||
|
@apply bg-green-100 text-green-800;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 类型标签样式 */
|
||||||
|
.cross-checking-page .type-tag {
|
||||||
|
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cross-checking-page .type-tag.city {
|
||||||
|
@apply bg-blue-100 text-blue-800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cross-checking-page .type-tag.county {
|
||||||
|
@apply bg-purple-100 text-purple-800;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 操作按钮样式 */
|
||||||
|
.cross-checking-page .operation-btn {
|
||||||
|
@apply inline-flex items-center px-3 py-1.5 text-sm font-medium rounded-md transition-colors duration-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cross-checking-page .operation-btn.primary {
|
||||||
|
@apply bg-green-600 text-white hover:bg-green-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cross-checking-page .operation-btn.secondary {
|
||||||
|
@apply bg-gray-100 text-gray-700 hover:bg-gray-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cross-checking-page .operation-btn i {
|
||||||
|
@apply mr-1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 分页样式 */
|
||||||
|
.cross-checking-page .pagination-wrapper {
|
||||||
|
@apply bg-white rounded-lg shadow-sm mt-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 进度条样式 */
|
||||||
|
.cross-checking-page .progress-bar {
|
||||||
|
@apply w-full bg-gray-200 rounded-full h-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cross-checking-page .progress-bar-fill {
|
||||||
|
@apply h-2 rounded-full transition-all duration-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cross-checking-page .progress-bar-fill.low {
|
||||||
|
@apply bg-red-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cross-checking-page .progress-bar-fill.medium {
|
||||||
|
@apply bg-yellow-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cross-checking-page .progress-bar-fill.high {
|
||||||
|
@apply bg-green-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式样式 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.cross-checking-page {
|
||||||
|
@apply p-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cross-checking-page .page-header {
|
||||||
|
@apply flex-col items-start space-y-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cross-checking-page .page-stats {
|
||||||
|
@apply grid grid-cols-2 gap-2 w-full;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 筛选面板特定样式 */
|
||||||
|
.cross-checking-page .filter-panel .filter-list {
|
||||||
|
@apply flex flex-wrap items-end gap-5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cross-checking-page .filter-panel .filter-item {
|
||||||
|
@apply flex flex-col;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cross-checking-page .filter-panel .filter-label {
|
||||||
|
@apply text-sm font-medium text-gray-700 mb-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cross-checking-page .filter-panel .filter-control {
|
||||||
|
@apply min-w-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cross-checking-page .filter-panel .filter-actions {
|
||||||
|
@apply flex items-center space-x-2 ml-auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 加载状态样式 */
|
||||||
|
.cross-checking-page .loading-overlay {
|
||||||
|
@apply absolute inset-0 bg-white bg-opacity-70 flex items-center justify-center z-10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cross-checking-page .loading-spinner {
|
||||||
|
@apply w-8 h-8 border-4 border-green-600 border-t-transparent rounded-full animate-spin;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user