diff --git a/app/api/cross-checking/cross-files.ts b/app/api/cross-checking/cross-files.ts new file mode 100644 index 0000000..06f77aa --- /dev/null +++ b/app/api/cross-checking/cross-files.ts @@ -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 { + 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> { + 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): Promise> { + 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> { + 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> { + 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> { + 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 : '获取统计数据失败' + }; + } +} diff --git a/app/api/login/README.md b/app/api/login/README.md new file mode 100644 index 0000000..97794d2 --- /dev/null +++ b/app/api/login/README.md @@ -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) \ No newline at end of file diff --git a/app/api/login/auth.server.ts b/app/api/login/auth.server.ts new file mode 100644 index 0000000..955d9e4 --- /dev/null +++ b/app/api/login/auth.server.ts @@ -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 + }, + }); +} \ No newline at end of file diff --git a/app/utils/oauth-client.ts b/app/api/login/oauth-client.ts similarity index 80% rename from app/utils/oauth-client.ts rename to app/api/login/oauth-client.ts index 9cf59be..f729c59 100644 --- a/app/utils/oauth-client.ts +++ b/app/api/login/oauth-client.ts @@ -1,6 +1,9 @@ /** * OAuth2.0客户端类 * 用于处理IDaaS OAuth2.0认证流程 + * 如果需要添加新的Token相关功能: + * 1. 优先考虑在 `TokenManager` 中添加 + * 2. 如果需要新的网络请求,在 `OAuthClient` 中添加 */ interface OAuthConfig { @@ -127,6 +130,42 @@ export class OAuthClient { } } + /** + * 刷新访问令牌 + * @param refreshToken 刷新令牌 + * @returns 新的访问令牌响应 + */ + async refreshAccessToken(refreshToken: string): Promise { + 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 访问令牌 @@ -197,15 +236,5 @@ export const oauthUtils = { 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; - } + }; \ No newline at end of file diff --git a/app/api/login/token-manager.server.ts b/app/api/login/token-manager.server.ts new file mode 100644 index 0000000..cfc7506 --- /dev/null +++ b/app/api/login/token-manager.server.ts @@ -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 { + 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 { + // 如果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(); \ No newline at end of file diff --git a/app/root.tsx b/app/root.tsx index 2d41d96..2ada1a1 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -14,7 +14,6 @@ import { import { LoaderFunctionArgs, redirect, - createCookieSessionStorage, ActionFunctionArgs } from "@remix-run/node"; import { Layout } from "~/components/layout/Layout"; @@ -30,8 +29,14 @@ import LoadingBarContainer from "~/components/ui/LoadingBar"; import RouteChangeLoader from "~/components/ui/RouteChangeLoader"; // 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 = [ @@ -41,91 +46,10 @@ export const developerOnlyPaths = [ '/prompts', ]; -// 创建基于Cookie的会话存储 -// 在实际应用中,应该使用环境变量来设置密钥 -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 type { UserRole }; -// 获取会话对象 -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处理登录/登出请求 export async function action({ request }: ActionFunctionArgs) { @@ -149,8 +73,8 @@ export async function loader({ request }: LoaderFunctionArgs) { const publicPaths = ['/login', '/favicon.ico']; const isPublicPath = publicPaths.some(path => pathname.startsWith(path)); - // 获取用户会话 - const { isAuthenticated, userRole } = await getUserSession(request); + // 获取用户会话(可能包含刷新后的token) + const { isAuthenticated, userRole, refreshedSession } = await getUserSession(request); // console.log("是否公开路径:", isPublicPath, "是否已认证:", isAuthenticated); // 如果访问需要认证的路径但未登录,重定向到登录页 @@ -184,6 +108,12 @@ export async function loader({ request }: LoaderFunctionArgs) { return redirect("/"); } + // 如果token被刷新了,需要在响应中设置更新后的cookie + const responseHeaders: Record = {}; + if (refreshedSession) { + responseHeaders["Set-Cookie"] = await sessionStorage.commitSession(refreshedSession); + } + // 向组件传递认证状态、当前路径和环境变量 return Response.json({ isAuthenticated, @@ -194,6 +124,8 @@ export async function loader({ request }: LoaderFunctionArgs) { NEXT_PUBLIC_APP_ID: process.env.NEXT_PUBLIC_APP_ID, NEXT_PUBLIC_APP_KEY: process.env.NEXT_PUBLIC_APP_KEY, }, + }, { + headers: responseHeaders }); } diff --git a/app/routes/_index.tsx b/app/routes/_index.tsx index 8cee19b..70b1c2d 100644 --- a/app/routes/_index.tsx +++ b/app/routes/_index.tsx @@ -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 } diff --git a/app/routes/callback.tsx b/app/routes/callback.tsx index 8e82ab0..c26308a 100644 --- a/app/routes/callback.tsx +++ b/app/routes/callback.tsx @@ -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客户端 diff --git a/app/routes/cross-checking._index.tsx b/app/routes/cross-checking._index.tsx index 7fceb16..802e534 100644 --- a/app/routes/cross-checking._index.tsx +++ b/app/routes/cross-checking._index.tsx @@ -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 ( -
-

交叉评查

+ const loaderData = useLoaderData(); + 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) => ( +
+
+
+
+ {progress}% +
+ ); + + // 渲染操作按钮 + const renderOperation = (task: CrossCheckingTask) => { + switch (task.status) { + case CrossCheckingTaskStatus.PENDING: + return ( + + ); + case CrossCheckingTaskStatus.IN_PROGRESS: + return ( + + ); + case CrossCheckingTaskStatus.COMPLETED: + return ( + + ); + default: + return -; + } + }; + + // 处理筛选变化 + const handleFilterChange = (e: React.ChangeEvent) => { + 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) => ( + + ) + }, + { + 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 ( + + {config.label} + + ); + } + }, + { + title: "任务类型", + key: "taskType", + align: "center" as const, + width: "10%", + render: (_: unknown, record: CrossCheckingTask) => { + const config = taskTypeConfig[record.taskType]; + return ( + + {config.label} + + ); + } + }, + { + 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 ( + + {config.label} + + ); + } + }, + { + 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 ( +
+ {/* 页面头部 */} +
+
+

评查任务

+
+
+ + 总任务数: + {stats.totalTasks} +
+
+ + 待开始: + {stats.pendingTasks} +
+
+ + 进行中: + {stats.inProgressTasks} +
+
+ + 已完成: + {stats.completedTasks} +
+
- ) + +
+ + {/* 筛选区域 */} + + + + } + > + + + + + handleDateChange('dateFrom', value)} + onEndDateChange={(value) => handleDateChange('dateTo', value)} + simple={true} + colorMode="light" + /> + + + + + {/* 任务列表 */} + +
+ + + {/* 分页 */} + {totalCount > 0 && ( + + )} + + + + ); +} + +// 错误边界 +export function ErrorBoundary() { + return ( +
+

出错了

+

加载交叉评查任务列表时发生错误。请稍后再试,或联系管理员。

+ +
+ ); } \ No newline at end of file diff --git a/app/routes/home.tsx b/app/routes/home.tsx index 9cf2b35..fea69b2 100644 --- a/app/routes/home.tsx +++ b/app/routes/home.tsx @@ -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 = [ diff --git a/app/routes/login.tsx b/app/routes/login.tsx index af0e2b2..6914467 100644 --- a/app/routes/login.tsx +++ b/app/routes/login.tsx @@ -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 = () => [ diff --git a/app/routes/logout.tsx b/app/routes/logout.tsx index a1972ec..7333685 100644 --- a/app/routes/logout.tsx +++ b/app/routes/logout.tsx @@ -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")); diff --git a/app/styles/pages/cross-checking_index.css b/app/styles/pages/cross-checking_index.css new file mode 100644 index 0000000..7ced4c8 --- /dev/null +++ b/app/styles/pages/cross-checking_index.css @@ -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; +}