feat: 1. 修改完善全局路由检测。 2. 完善统一的token认证管理,token失效自动跳转到登录页。
This commit is contained in:
+15
-1
@@ -54,7 +54,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
// 🔑 检查是否是管理员账密登录(直接传递 token 和 userInfo)
|
||||
const token = url.searchParams.get("token");
|
||||
const userInfo = url.searchParams.get("userInfo");
|
||||
const redirectTo = url.searchParams.get("redirectTo") || "/";
|
||||
// const redirectTo = url.searchParams.get("redirectTo") || "/";
|
||||
|
||||
// 🔑 如果有 token 和 userInfo,说明是管理员账密登录
|
||||
// login.tsx action 已经创建了 Cookie Session,这里只需要返回 null
|
||||
@@ -180,6 +180,19 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
const frontendJWT = loginResponse.data.access_token;
|
||||
const savedUserInfo = loginResponse.data.user_info;
|
||||
|
||||
// 🔑 提取后端返回的签发时间并转换为时间戳
|
||||
let tokenIssuedAt = Date.now(); // 默认使用当前时间
|
||||
if (loginResponse.data.issued_time) {
|
||||
try {
|
||||
// 后端返回格式:"2025-11-18 17:41:06"
|
||||
// 转换为时间戳(毫秒)
|
||||
tokenIssuedAt = new Date(loginResponse.data.issued_time.replace(' ', 'T')).getTime();
|
||||
console.log("📅 [Callback] 使用后端返回的签发时间:", loginResponse.data.issued_time, "→", tokenIssuedAt);
|
||||
} catch (error) {
|
||||
console.warn("⚠️ [Callback] 无法解析 issued_time,使用当前时间:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// 更新userInfo以包含数据库ID、JWT(user_role 从后端返回)
|
||||
const enhancedUserInfo = {
|
||||
...userInfo.data, // 保留OAuth返回的原字段(包含 nickname 等)
|
||||
@@ -221,6 +234,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
accessToken: tokenResponse.access_token,
|
||||
refreshToken: tokenResponse.refresh_token,
|
||||
tokenExpiresIn: tokenResponse.expires_in,
|
||||
tokenIssuedAt: tokenIssuedAt, // 🔑 传递后端返回的签发时间
|
||||
userInfo: enhancedUserInfo,
|
||||
frontendJWT
|
||||
});
|
||||
|
||||
@@ -44,30 +44,30 @@ function transformCategory(category: ContractCategoryWithCount) {
|
||||
* @returns 分类数据
|
||||
*/
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
// 获取 JWT
|
||||
const { frontendJWT } = await getUserSession(request);
|
||||
const jwt = frontendJWT || undefined;
|
||||
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const { handleServerAuth } = await import("~/utils/server-auth-handler");
|
||||
|
||||
return handleServerAuth(async () => {
|
||||
// 获取 JWT
|
||||
const { frontendJWT } = await getUserSession(request);
|
||||
const jwt = frontendJWT || undefined;
|
||||
|
||||
// 使用聚合查询获取分类及其模板数量
|
||||
const categoriesResponse = await getContractCategoriesWithCount(jwt);
|
||||
|
||||
// 处理分类数据
|
||||
if (categoriesResponse.error) {
|
||||
console.error('获取分类失败:', categoriesResponse.error);
|
||||
return { categories: [] };
|
||||
}
|
||||
// 处理分类数据
|
||||
if (categoriesResponse.error) {
|
||||
console.error('获取分类失败:', categoriesResponse.error);
|
||||
return { categories: [] };
|
||||
}
|
||||
|
||||
const categories = categoriesResponse.data || [];
|
||||
const categories = categoriesResponse.data || [];
|
||||
|
||||
// 转换分类数据格式
|
||||
const categoriesWithCount = categories.map(transformCategory);
|
||||
|
||||
return { categories: categoriesWithCount };
|
||||
} catch (error) {
|
||||
console.error('加载分类数据失败:', error);
|
||||
return { categories: [] };
|
||||
}
|
||||
}, url.pathname);
|
||||
}
|
||||
|
||||
export default function ContractTemplateSearchIndex() {
|
||||
|
||||
@@ -616,10 +616,10 @@ export default function CrossCheckingResult() {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
onCancel: () => {
|
||||
// 用户取消时不需要做任何处理
|
||||
console.log('[完成评查] 用户取消了确认操作');
|
||||
}
|
||||
// onCancel: () => {
|
||||
// // 用户取消时不需要做任何处理
|
||||
// console.log('[完成评查] 用户取消了确认操作');
|
||||
// }
|
||||
});
|
||||
} catch (error) {
|
||||
isProcessingRef.current = false;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { type MetaFunction, type ActionFunctionArgs, type LoaderFunctionArgs, json } from "@remix-run/node";
|
||||
import { Form, useNavigate, useLoaderData } from "@remix-run/react";
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { type MetaFunction, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { useNavigate, useLoaderData } from "@remix-run/react";
|
||||
import { UploadArea, type UploadAreaRef } from "~/components/ui/UploadArea";
|
||||
import { Button } from "~/components/ui/Button";
|
||||
import { messageService } from "~/components/ui/MessageModal";
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
getOrganizationTree,
|
||||
convertToTreeData
|
||||
} from "~/api/user";
|
||||
import React from "react"; // Added for React.useState
|
||||
import { API_BASE_URL } from '~/config/api-config';
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
@@ -133,7 +132,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const { getUserSession } = await import("~/api/login/auth.server");
|
||||
const { userInfo, frontendJWT } = await getUserSession(request);
|
||||
|
||||
return json({
|
||||
return Response.json({
|
||||
userInfo,
|
||||
frontendJWT
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useSearchParams, useLoaderData, useFetcher, useNavigate,Link } from "@r
|
||||
import { type MetaFunction, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { Card } from "~/components/ui/Card";
|
||||
import { Button } from "~/components/ui/Button";
|
||||
import { Table } from "~/components/ui/Table";
|
||||
// import { Table } from "~/components/ui/Table";
|
||||
import { Pagination } from "~/components/ui/Pagination";
|
||||
import { FileTypeTag } from "~/components/ui/FileTypeTag";
|
||||
import { FileTag } from "~/components/ui/FileTag";
|
||||
@@ -19,7 +19,7 @@ import { appendContractAttachments, uploadContractTemplate } from "~/api/files/f
|
||||
import { toastService } from "~/components/ui/Toast";
|
||||
import { messageService } from "~/components/ui/MessageModal";
|
||||
import { loadingBarService } from "~/components/ui/LoadingBar";
|
||||
import { DOCUMENT_URL } from "~/api/axios-client";
|
||||
// import { DOCUMENT_URL } from "~/api/axios-client";
|
||||
|
||||
// 导入样式
|
||||
export function links() {
|
||||
|
||||
+17
-2
@@ -48,9 +48,15 @@ export const meta: MetaFunction = () => {
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
try {
|
||||
// 从根loader获取用户角色
|
||||
const { userRole, userInfo, frontendJWT } = await getUserSession(request);
|
||||
const { userRole, userInfo, frontendJWT, isAuthenticated } = await getUserSession(request);
|
||||
|
||||
// 🔑 检查用户是否已登录且有用户信息
|
||||
if (!isAuthenticated || !userInfo) {
|
||||
console.warn("⚠️ [Home Loader] 用户未登录或缺少用户信息,重定向到登录页");
|
||||
const url = new URL(request.url);
|
||||
return Response.redirect(`/login?redirect=${encodeURIComponent(url.pathname)}`, 302);
|
||||
}
|
||||
|
||||
|
||||
// 返回默认值,实际数据将在客户端根据 sessionStorage 加载
|
||||
return Response.json({
|
||||
homeData: {
|
||||
@@ -101,6 +107,15 @@ export default function Home() {
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
// const userRole = serverUserRole as UserRole;
|
||||
|
||||
// 🔑 防御性检查:如果 userInfo 不存在,重定向到登录页(理论上不应该发生,因为 loader 已经检查了)
|
||||
if (!userInfo) {
|
||||
console.error("❌ [Home] userInfo 不存在,重定向到登录页");
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 打印服务器端传递的用户角色
|
||||
useEffect(() => {
|
||||
|
||||
+40
-5
@@ -1,9 +1,9 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useLoaderData, useNavigate, useFetcher } from "@remix-run/react";
|
||||
import { type MetaFunction, type LoaderFunctionArgs, type ActionFunctionArgs, redirect } from "@remix-run/node";
|
||||
import { useLoaderData, useFetcher } from "@remix-run/react";
|
||||
import { type MetaFunction, type LoaderFunctionArgs, type ActionFunctionArgs } from "@remix-run/node";
|
||||
import { OAuthClient } from "~/api/login/oauth-client";
|
||||
import { CLIENT_OAUTH_CONFIG } from "~/config/api-config";
|
||||
import { getUserSession, getSession, createUserSession } from "~/api/login/auth.server";
|
||||
import { getSession, createUserSession } from "~/api/login/auth.server";
|
||||
import { loginWithPassword } from "~/api/login/login-client";
|
||||
import styles from "~/styles/pages/login.css?url";
|
||||
import { toastService } from "~/components/ui";
|
||||
@@ -88,7 +88,7 @@ export async function action({ request }: ActionFunctionArgs) {
|
||||
}, { status: 401 });
|
||||
}
|
||||
|
||||
const { access_token, user_info } = response.data;
|
||||
const { access_token, expires_in, issued_time, user_info } = response.data;
|
||||
|
||||
// 验证返回数据
|
||||
if (!access_token) {
|
||||
@@ -107,8 +107,22 @@ export async function action({ request }: ActionFunctionArgs) {
|
||||
}, { status: 500 });
|
||||
}
|
||||
|
||||
// 🔑 将后端返回的 issued_time 转换为时间戳(毫秒)
|
||||
let tokenIssuedAt = Date.now(); // 默认使用当前时间
|
||||
if (issued_time) {
|
||||
try {
|
||||
// 后端返回格式:"2025-11-18 17:41:06"
|
||||
// 转换为时间戳(毫秒)
|
||||
tokenIssuedAt = new Date(issued_time.replace(' ', 'T')).getTime();
|
||||
console.log("📅 [Login Action] 使用后端返回的签发时间:", issued_time, "→", tokenIssuedAt);
|
||||
} catch (error) {
|
||||
console.warn("⚠️ [Login Action] 无法解析 issued_time,使用当前时间:", error);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("✅ [Login Action] 登录成功,准备创建 session");
|
||||
console.log("👤 [Login Action] 用户角色:", user_info.user_role); // 应该是 "admin"
|
||||
console.log("⏰ [Login Action] Token 有效期:", expires_in, "秒 (", expires_in / 3600, "小时)");
|
||||
|
||||
// 获取当前 URL 用于构建 callback URL
|
||||
const url = new URL(request.url);
|
||||
@@ -137,6 +151,8 @@ export async function action({ request }: ActionFunctionArgs) {
|
||||
userRole: user_info.user_role,
|
||||
redirectTo: callbackUrl.toString(), // 先跳转到 callback 页面保存 token
|
||||
frontendJWT: access_token, // 保存到 Cookie Session
|
||||
tokenExpiresIn: expires_in,
|
||||
tokenIssuedAt: tokenIssuedAt, // 🔑 传递后端返回的签发时间
|
||||
userInfo: {
|
||||
user_id: user_info.user_id,
|
||||
username: user_info.username,
|
||||
@@ -161,7 +177,7 @@ export async function action({ request }: ActionFunctionArgs) {
|
||||
}
|
||||
|
||||
export default function Login() {
|
||||
const navigate = useNavigate();
|
||||
// const navigate = useNavigate();
|
||||
const loaderData = useLoaderData<typeof loader>();
|
||||
const fetcher = useFetcher<{ success: boolean; error?: string }>();
|
||||
const [isFlipped, setIsFlipped] = useState(false);
|
||||
@@ -268,6 +284,25 @@ export default function Login() {
|
||||
}, [fetcher.data]);
|
||||
|
||||
useEffect(() => {
|
||||
// 🔑 只在 token 过期时清理客户端存储
|
||||
// 检查 URL 参数中是否有 expired=true 标识
|
||||
if (typeof window !== 'undefined') {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const isExpired = urlParams.get('expired') === 'true';
|
||||
|
||||
if (isExpired) {
|
||||
// 只有在因为过期被重定向时才清除 localStorage
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('user_info');
|
||||
console.log("🧹 [Login] Token 已过期,已清除客户端 token 数据");
|
||||
|
||||
// 清除 URL 中的 expired 参数,避免刷新页面时重复清除
|
||||
urlParams.delete('expired');
|
||||
const newUrl = window.location.pathname + (urlParams.toString() ? '?' + urlParams.toString() : '');
|
||||
window.history.replaceState({}, '', newUrl);
|
||||
}
|
||||
}
|
||||
|
||||
// 检查OAuth配置是否完整(客户端不需要检查 clientSecret)
|
||||
if (!CLIENT_OAUTH_CONFIG.serverUrl || !CLIENT_OAUTH_CONFIG.clientId) {
|
||||
console.error("OAuth2.0配置不完整:", CLIENT_OAUTH_CONFIG);
|
||||
|
||||
@@ -499,7 +499,7 @@ export default function RuleGroupsIndex() {
|
||||
key: "ruleCount",
|
||||
width: "12%",
|
||||
render: (_: unknown, record: RuleGroup & { isParent?: boolean, parentId?: string }) => (
|
||||
<button onClick={() => navigate(`/rules?${!record.isParent ? `ruleType=${record.parentId}&groupId=${record.id}` : `ruleType=${record.id}`}`)} className="badge bg-primary text-white">
|
||||
<button onClick={() => navigate(`/rules/list?${!record.isParent ? `ruleType=${record.parentId}&groupId=${record.id}` : `ruleType=${record.id}`}`)} className="badge bg-primary text-white">
|
||||
<span className="text-xs hover:underline">{calculateTotalRuleCount(record)}</span>
|
||||
</button>
|
||||
)
|
||||
|
||||
@@ -504,7 +504,7 @@ export default function RulesIndex() {
|
||||
|
||||
// 复制评查点
|
||||
const handleCopy = (rule: Rule) => {
|
||||
navigate(`/rules-new?id=${rule.id}&mode=copy`);
|
||||
navigate(`/rules/new?id=${rule.id}&mode=copy`);
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
@@ -618,7 +618,7 @@ export default function RulesIndex() {
|
||||
{isDeveloper ? (
|
||||
// 开发者可以看到编辑、复制、删除
|
||||
<>
|
||||
<Link to={`/rules-new?id=${record.id}`} className="operation-btn">
|
||||
<Link to={`/rules/new?id=${record.id}`} className="operation-btn">
|
||||
<i className="ri-edit-line"></i> 编辑
|
||||
</Link>
|
||||
<button className="operation-btn" onClick={() => handleCopy(record)}>
|
||||
@@ -630,7 +630,7 @@ export default function RulesIndex() {
|
||||
</>
|
||||
) : (
|
||||
// 普通用户只能查看
|
||||
<Link to={`/rules-new?id=${record.id}&mode=view`} className="operation-btn">
|
||||
<Link to={`/rules/new?id=${record.id}&mode=view`} className="operation-btn">
|
||||
<i className="ri-eye-line"></i> 查看
|
||||
</Link>
|
||||
)}
|
||||
@@ -658,7 +658,7 @@ export default function RulesIndex() {
|
||||
)}
|
||||
</div>
|
||||
{isDeveloper && (
|
||||
<Button type="primary" icon="ri-add-line" to="/rules-new" className="btn-add-rule">
|
||||
<Button type="primary" icon="ri-add-line" to="/rules/new" className="btn-add-rule">
|
||||
新增评查点
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -68,21 +68,7 @@ export function links() {
|
||||
}
|
||||
|
||||
export const handle = {
|
||||
breadcrumb: "评查点管理",
|
||||
previousRoute: () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const mode = searchParams.get('mode');
|
||||
const id = searchParams.get('id');
|
||||
if (mode || id) {
|
||||
return {
|
||||
title: "评查点列表",
|
||||
to: `/rules`
|
||||
};
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
breadcrumb: "评查点管理"
|
||||
};
|
||||
|
||||
// 添加规则配置接口
|
||||
@@ -795,7 +781,7 @@ export default function RuleNew() {
|
||||
toastService.success(`评查点${isEditMode ? '更新' : '创建'}成功!`);
|
||||
|
||||
// 保存成功后跳转到编辑页面并重新加载数据
|
||||
navigate(`/rules-new?id=${savedPointId}`, { replace: true });
|
||||
navigate(`/rules/new?id=${savedPointId}`, { replace: true });
|
||||
// 重新获取评查点数据
|
||||
await fetchEvaluationPoint(savedPointId);
|
||||
} else {
|
||||
@@ -13,7 +13,8 @@ export const meta: MetaFunction = () => {
|
||||
};
|
||||
|
||||
export const handle = {
|
||||
breadcrumb: "评查点列表"
|
||||
breadcrumb: "评查点列表",
|
||||
to: "/rules/list" // 指定面包屑点击后跳转的路径
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user