V001
This commit is contained in:
1 parent
d5d4a6f3bf
commit
fb1a79a3e9
27 files changed
+708
-75
No files matched your search
@@ -0,0 +1,305 @@
|
||||
<template>
|
||||
<div class="ep" :class="'ep--' + tone">
|
||||
<!-- 顶部渐变头图:品牌 + 大号状态码,延续首页 hero 的视觉语言 -->
|
||||
<header class="ep-hero">
|
||||
<div class="ep-hero-inner">
|
||||
<div class="ep-brand">
|
||||
<a-avatar shape="square" :size="26" src="/youjia.svg" />
|
||||
<span>{{ siteTitle }}</span>
|
||||
</div>
|
||||
<div class="ep-code">{{ code }}</div>
|
||||
<div class="ep-tag">{{ badge }}</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 悬浮卡片:图标 + 说明 + 可能原因 + 操作 -->
|
||||
<section class="ep-card">
|
||||
<div class="ep-icon">
|
||||
<slot name="icon" />
|
||||
</div>
|
||||
|
||||
<h1 class="ep-title">{{ title }}</h1>
|
||||
<p class="ep-desc">{{ description }}</p>
|
||||
|
||||
<div v-if="reasons && reasons.length" class="ep-reasons">
|
||||
<div class="ep-reasons-title">可能的原因</div>
|
||||
<ul>
|
||||
<li v-for="(item, i) in reasons" :key="i">{{ item }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p v-if="from" class="ep-from">
|
||||
出错的地址:<code>{{ from }}</code>
|
||||
</p>
|
||||
|
||||
<div class="ep-actions">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
|
||||
<p v-if="hint" class="ep-hint">{{ hint }}</p>
|
||||
</section>
|
||||
|
||||
<footer class="ep-footer">
|
||||
<span>{{ copy }}</span>
|
||||
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener">{{ beian }}</a>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { WebSitePolicy } from "@/policy/custom/webSitePolicy";
|
||||
|
||||
/**
|
||||
* 错误页通用外壳(401 / 403 / 404 共用)。
|
||||
* 只负责排版与主题,具体文案、图标、按钮由三个错误页各自提供。
|
||||
*/
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
/** 状态码,如 401 */
|
||||
code: string;
|
||||
/** 状态码下方的短标签,如「未认证」 */
|
||||
badge?: string;
|
||||
/** 主题色:blue(品牌蓝青,默认)/ amber(警示)/ violet(找不到) */
|
||||
tone?: "blue" | "amber" | "violet";
|
||||
/** 主标题:一句话说清出了什么问题 */
|
||||
title: string;
|
||||
/** 副标题:补充说明 */
|
||||
description?: string;
|
||||
/** 可能的原因清单 */
|
||||
reasons?: string[];
|
||||
/** 出错的地址(404 用 ?from= 带过来) */
|
||||
from?: string;
|
||||
/** 兜底提示,如「如需权限请联系管理员」 */
|
||||
hint?: string;
|
||||
}>(),
|
||||
{ badge: "", tone: "blue", description: "", reasons: () => [], from: "", hint: "" },
|
||||
);
|
||||
|
||||
const siteTitle = WebSitePolicy.Title;
|
||||
const copy = WebSitePolicy.Copy;
|
||||
const beian = WebSitePolicy.Beian;
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.ep {
|
||||
/* 主题色:默认品牌蓝→青渐变,amber / violet 用于区分不同错误类型 */
|
||||
--ep-from: #1677ff;
|
||||
--ep-to: #36cfc9;
|
||||
--ep-shadow: rgba(22, 119, 255, 0.18);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
background: #f5f6f8;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.ep--amber {
|
||||
--ep-from: #fa8c16;
|
||||
--ep-to: #ffc53d;
|
||||
--ep-shadow: rgba(250, 140, 22, 0.18);
|
||||
}
|
||||
|
||||
.ep--violet {
|
||||
--ep-from: #722ed1;
|
||||
--ep-to: #b37feb;
|
||||
--ep-shadow: rgba(114, 46, 209, 0.18);
|
||||
}
|
||||
|
||||
/* ---------- 头图 ---------- */
|
||||
.ep-hero {
|
||||
padding: 16px 16px 44px;
|
||||
border-radius: 0 0 22px 22px;
|
||||
background: linear-gradient(135deg, var(--ep-from) 0%, var(--ep-to) 100%);
|
||||
color: #fff;
|
||||
box-shadow: 0 6px 18px var(--ep-shadow);
|
||||
}
|
||||
|
||||
.ep-hero-inner {
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.ep-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.ep-code {
|
||||
margin-top: 8px;
|
||||
font-size: 52px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
letter-spacing: 2px;
|
||||
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.ep-tag {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
letter-spacing: 1px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* ---------- 悬浮卡片 ---------- */
|
||||
.ep-card {
|
||||
flex: 0 0 auto;
|
||||
margin: -28px 16px 18px;
|
||||
padding: 18px 16px 20px;
|
||||
border-radius: 20px;
|
||||
background: #fff;
|
||||
box-shadow: 0 8px 24px rgba(15, 35, 60, 0.08);
|
||||
}
|
||||
|
||||
.ep-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-bottom: 12px;
|
||||
border-radius: 14px;
|
||||
font-size: 24px;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, var(--ep-from) 0%, var(--ep-to) 100%);
|
||||
box-shadow: 0 2px 8px var(--ep-shadow);
|
||||
}
|
||||
|
||||
.ep-title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
color: #1f2329;
|
||||
}
|
||||
|
||||
.ep-desc {
|
||||
margin: 6px 0 0;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.65;
|
||||
color: #646a73;
|
||||
}
|
||||
|
||||
.ep-reasons {
|
||||
margin-top: 14px;
|
||||
padding: 12px 12px 10px;
|
||||
border: 1px solid #eef0f4;
|
||||
border-radius: 14px;
|
||||
background: #f7f9fc;
|
||||
}
|
||||
|
||||
.ep-reasons-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1f2329;
|
||||
}
|
||||
|
||||
.ep-reasons-title::before {
|
||||
content: "";
|
||||
width: 3px;
|
||||
height: 12px;
|
||||
border-radius: 2px;
|
||||
background: var(--ep-from);
|
||||
}
|
||||
|
||||
.ep-reasons ul {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
.ep-reasons li {
|
||||
margin: 4px 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: #646a73;
|
||||
}
|
||||
|
||||
.ep-from {
|
||||
margin: 12px 0 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: #8a919f;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.ep-from code {
|
||||
padding: 1px 6px;
|
||||
border-radius: 6px;
|
||||
background: #f2f4f8;
|
||||
color: #1f2329;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ep-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.ep-hint {
|
||||
margin: 14px 0 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: #8a919f;
|
||||
}
|
||||
|
||||
/* ---------- 底部信息 ---------- */
|
||||
.ep-footer {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
padding: 0 16px 14px;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
color: #8a919f;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ep-footer a {
|
||||
color: #8a919f;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
color: #1677ff;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 宽屏(手机优先,这里只是放大与居中) ---------- */
|
||||
@media (min-width: 576px) {
|
||||
.ep-hero {
|
||||
padding: 22px 24px 56px;
|
||||
}
|
||||
|
||||
.ep-code {
|
||||
font-size: 64px;
|
||||
}
|
||||
|
||||
.ep-card {
|
||||
width: 100%;
|
||||
max-width: 460px;
|
||||
margin: -34px auto 20px;
|
||||
padding: 22px 22px 24px;
|
||||
}
|
||||
|
||||
/* 宽屏按钮横排,主操作在左,更符合桌面习惯 */
|
||||
.ep-actions {
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+112
-6
@@ -2,8 +2,82 @@ import { createRouter, createWebHistory } from "vue-router";
|
||||
import type { RouteRecordRaw } from "vue-router";
|
||||
import type { App } from "vue";
|
||||
import outsideRoute from "./outsideLayout";
|
||||
import packChildren from "./insideLayout";
|
||||
import packChildren, { isMenuAuthFailed } from "./insideLayout";
|
||||
import { bsInfo } from "@/utils/browser";
|
||||
import { useLoginProfileStoreWithOut } from "@/store/modules/loginProfile";
|
||||
import { PermissionContext } from "@/hooks/PermissionContext";
|
||||
|
||||
/**
|
||||
* 不需要登录就能访问的页面。
|
||||
* 错误页也在白名单里:401 页就是给"没登录/登录失效"的用户看的,
|
||||
* 拦掉它反而只能看到登录框,看不到出错原因。
|
||||
*/
|
||||
const WHITE_LIST = [
|
||||
"/login",
|
||||
"/loginAgent",
|
||||
"/register",
|
||||
"/error/page401",
|
||||
"/error/page403",
|
||||
"/error/page404",
|
||||
];
|
||||
|
||||
/**
|
||||
* 全局登录守卫。
|
||||
*
|
||||
* 页面里的接口不一定会返回 401(比如首页用到的 FindMenus / FindMenuByUsername
|
||||
* 都是 AllowAnonymous,无 token 时返回 200 + null),
|
||||
* 只靠 utils/request.ts 的 401 拦截器跳登录页是不够的——
|
||||
* 本地没有令牌时就必须在路由层拦住,否则用户会卡在内页看到空白内容。
|
||||
*/
|
||||
function setupLoginGuard() {
|
||||
router.beforeEach((to) => {
|
||||
const token = useLoginProfileStoreWithOut().getToken();
|
||||
if (!token && !WHITE_LIST.includes(to.path)) {
|
||||
return {
|
||||
path: "/login",
|
||||
query: to.fullPath === "/" ? undefined : { redirect: to.fullPath },
|
||||
};
|
||||
}
|
||||
// 已经登录过:不用再看首页动画,直接进工作台。
|
||||
// 首页按屏幕尺寸二选一加载 index.vue / indexSmall.vue,在路由层拦掉就不用改两个组件。
|
||||
if (token && to.name === "home") {
|
||||
return { path: "/dashboard" };
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 免检路由(按路由名)。
|
||||
* 这几个是从别的页面跳进来的预览页,不在菜单里单独作为功能入口:
|
||||
* 能打开来源页面就能预览,路由层不再按 meta.permission 拦,
|
||||
* 否则没被单独授权的用户会连预览都打不开(接口层的 [PermissionOperate] 仍会校验)。
|
||||
*/
|
||||
const PERMISSION_FREE_ROUTE_NAMES = ["Preview", "PreviewAudio", "PreviewVideo"];
|
||||
|
||||
/**
|
||||
* 权限守卫。
|
||||
*
|
||||
* 路由表是按功能全量注册的(/SecurityFunction/FindMenus 不区分用户),
|
||||
* 任何人从地址栏都能输到任意页面,所以导航时再按 meta.permission 校验一次:
|
||||
* 没有该功能的查看权限就跳 403,并把原地址带过去,用户能一眼看出是哪个页面被拦了。
|
||||
*/
|
||||
function setupPermissionGuard() {
|
||||
router.beforeEach((to) => {
|
||||
const permission = to.meta.permission as string | undefined;
|
||||
// 没绑定权限名的页面(欢迎页、错误页等)不拦
|
||||
if (!permission) return true;
|
||||
if (PERMISSION_FREE_ROUTE_NAMES.includes(to.name as string)) return true;
|
||||
|
||||
const permissionInfo = useLoginProfileStoreWithOut().getPermissionInfo();
|
||||
// 本地拿不到权限信息时放行:宁可少拦,也不要因为本地缓存异常把所有人挡在门外。
|
||||
// 真正的安全边界在后端——每个接口都有 [PermissionOperate] 校验。
|
||||
if (!permissionInfo) return true;
|
||||
if (new PermissionContext(permissionInfo).canView(permission)) return true;
|
||||
|
||||
return { path: "/error/page403", query: { from: to.fullPath } };
|
||||
});
|
||||
}
|
||||
|
||||
export const routes: Array<RouteRecordRaw> = [...outsideRoute];
|
||||
|
||||
@@ -14,6 +88,11 @@ export const router = createRouter({
|
||||
});
|
||||
|
||||
export async function setupRouter(app: App) {
|
||||
// 守卫要在装 router 之前注册好:应用启动时的第一次导航也要受它约束
|
||||
setupLoginGuard();
|
||||
// 登录态之后:按 meta.permission 拦「没有该功能查看权限」的页面(跳 403)
|
||||
setupPermissionGuard();
|
||||
|
||||
let chs: Array<RouteRecordRaw> = await packChildren();
|
||||
|
||||
if (chs && chs.length > 0) {
|
||||
@@ -75,22 +154,49 @@ export async function setupRouter(app: App) {
|
||||
});
|
||||
}
|
||||
|
||||
// 错误页路由:地址不存在(拼错 / 菜单里没有)时统一跳到 404,不再白屏
|
||||
// 三个错误页:401 未登录 / 403 无权限 / 404 地址不存在
|
||||
router.addRoute({
|
||||
path: "/error/page401",
|
||||
name: "page401",
|
||||
component: () => import("@/views/error/page401.vue"),
|
||||
meta: {
|
||||
title: "需要登录401",
|
||||
},
|
||||
});
|
||||
router.addRoute({
|
||||
path: "/error/page403",
|
||||
name: "page403",
|
||||
component: () => import("@/views/error/page403.vue"),
|
||||
props: true,
|
||||
meta: {
|
||||
title: "页面不存在403",
|
||||
title: "无权限403",
|
||||
},
|
||||
});
|
||||
// catch-all 必须最后注册,匹配所有未定义路径
|
||||
router.addRoute({
|
||||
path: "/error/page404",
|
||||
name: "page404",
|
||||
component: () => import("@/views/error/page404.vue"),
|
||||
meta: {
|
||||
title: "页面不存在404",
|
||||
},
|
||||
});
|
||||
// catch-all 必须最后注册,匹配所有未定义路径。
|
||||
// 原来跳的是 403(无权限),语义不对:地址不存在应该是 404;
|
||||
// 同时把原地址通过 ?from= 带过去,用户能一眼看出是哪个地址出错了。
|
||||
router.addRoute({
|
||||
path: "/:pathMatch(.*)*",
|
||||
redirect: "/error/page403",
|
||||
redirect: (to) => ({
|
||||
path: "/error/page404",
|
||||
query: { from: to.fullPath },
|
||||
}),
|
||||
});
|
||||
|
||||
app.use(router);
|
||||
|
||||
// 菜单都没能加载出来(加载菜单时拿到 401/403,说明登录态已失效):
|
||||
// 清掉本地登录态回登录页,避免停在 404/空白页上
|
||||
if (isMenuAuthFailed()) {
|
||||
useLoginProfileStoreWithOut().clearStorage();
|
||||
router.replace({ path: "/login" });
|
||||
}
|
||||
}
|
||||
export default router;
|
||||
@@ -3,6 +3,7 @@ import { SecurityFunctionDisplayData } from "@/data/auto/securityFunctionDisplay
|
||||
import type { RouteRecordRaw } from "vue-router";
|
||||
import axios from "axios";
|
||||
import { getRoutes, setRoutes } from "@/store/modules/loadRoutes";
|
||||
import { useLoginProfileStoreWithOut } from "@/store/modules/loginProfile";
|
||||
import { notification } from "ant-design-vue";
|
||||
import { WebSitePolicy } from "@/policy/custom/webSitePolicy";
|
||||
|
||||
@@ -14,6 +15,16 @@ const errorNotificationWithIcon = (message: string) => {
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 加载菜单时是否发现登录态失效(401/403)。
|
||||
* 这里走的是独立 axios,绕开了 utils/request.ts 的 401 拦截器,
|
||||
* 所以把结果暴露给 router:由它清登录态并跳登录页,
|
||||
* 否则登录态失效时会静默退化成 404 页面(一条路由都注册不上)。
|
||||
*/
|
||||
let authFailed = false;
|
||||
|
||||
export const isMenuAuthFailed = () => authFailed;
|
||||
|
||||
async function packChildren() {
|
||||
let data: SecurityFunctionDisplayData[] = [];
|
||||
let routes: Array<RouteRecordRaw> = [];
|
||||
@@ -30,19 +41,28 @@ async function packChildren() {
|
||||
axios.defaults.baseURL = WebSitePolicy.WebApiRoot;
|
||||
}
|
||||
try {
|
||||
// 带上令牌:以前这个请求是裸的,只是因为接口恰好是 AllowAnonymous 才没事
|
||||
const token = useLoginProfileStoreWithOut().getToken();
|
||||
let response = await axios.create().request({
|
||||
url: "/SecurityFunction/FindMenus",
|
||||
method: "post",
|
||||
headers: token ? { Authorization: token } : {},
|
||||
});
|
||||
|
||||
if (response.status == 200) {
|
||||
data = response.data;
|
||||
setRoutes(data);
|
||||
}
|
||||
} catch (error) {
|
||||
errorNotificationWithIcon(
|
||||
"服务器器已停止工作,请联系管理员."
|
||||
);
|
||||
} catch (error: any) {
|
||||
const status = error?.response?.status;
|
||||
if (status === 401 || status === 403) {
|
||||
// 登录态失效:交给 router 清态跳登录,不再提示"服务器停止工作"
|
||||
authFailed = true;
|
||||
} else {
|
||||
errorNotificationWithIcon(
|
||||
"服务器器已停止工作,请联系管理员."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,13 @@ export const useLoginProfileStore = defineStore("loginProfile", {
|
||||
updateJwtInfo(jwtInfo: JwtInfo) {
|
||||
storage.set(JWT_STORAGE_ID, "Bearer " + jwtInfo.AccessToken);
|
||||
storage.set(JWT_REFRESHKEY_STORAGE_ID, jwtInfo.RefreshToken);
|
||||
// 同时同步内存里的登录信息:否则别处读 loginProfile.Token 拿到的还是旧令牌
|
||||
if (this.loginProfile?.Token) {
|
||||
this.loginProfile.Token.AccessToken = jwtInfo.AccessToken;
|
||||
this.loginProfile.Token.RefreshToken = jwtInfo.RefreshToken;
|
||||
this.loginProfile.Token.CreatedTime = jwtInfo.CreatedTime;
|
||||
storage.set(LOGIN_PROFILE_STORAGE_ID, JSON.stringify(this.loginProfile));
|
||||
}
|
||||
},
|
||||
getToken() {
|
||||
return storage.get(JWT_STORAGE_ID);
|
||||
|
||||
+61
-24
@@ -54,6 +54,48 @@ service.interceptors.request.use(
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 正在进行的刷新请求。
|
||||
* 多个请求同时 401 时共用同一次刷新:后端每次刷新都会换新 RefreshToken
|
||||
* (AuthService.RefreshToken 里 log.RefreshToken = GenerateRefreshToken()),
|
||||
* 各自刷新会让先完成的那个把其它请求携带的旧 RefreshToken 作废,
|
||||
* 结果被判定为「无效的令牌,请重新登录.」而把人踢下线。
|
||||
*/
|
||||
let refreshPromise: Promise<JwtInfo> | null = null;
|
||||
|
||||
/** 认证失败统一收尾:必要时清登录态,并跳登录页(已在登录页就不再跳,避免死循环) */
|
||||
const goLogin = (clearLoginState: boolean) => {
|
||||
if (clearLoginState) useLoginProfileStore().clearStorage();
|
||||
if (router.currentRoute.value.path !== "/login") {
|
||||
router.push({ path: "/login" });
|
||||
}
|
||||
};
|
||||
|
||||
/** 真正发起刷新(调用方保证同一时刻只有一次) */
|
||||
const doRefreshToken = async (): Promise<JwtInfo> => {
|
||||
const data = useLoginProfileStore().getJwtInfo()!;
|
||||
// _retry 标记:刷新请求自己 401 时不要再进刷新分支,避免无限递归
|
||||
const config: any = {
|
||||
url: "/Auth/RefreshToken",
|
||||
method: "post",
|
||||
data,
|
||||
_retry: true,
|
||||
};
|
||||
const jwt = await request<JwtInfo>(config);
|
||||
useLoginProfileStore().updateJwtInfo(jwt);
|
||||
return jwt;
|
||||
};
|
||||
|
||||
/** 取得「共用的刷新动作」:并发 401 只刷新一次 */
|
||||
const refreshTokenOnce = (): Promise<JwtInfo> => {
|
||||
if (!refreshPromise) {
|
||||
refreshPromise = doRefreshToken().finally(() => {
|
||||
refreshPromise = null;
|
||||
});
|
||||
}
|
||||
return refreshPromise;
|
||||
};
|
||||
|
||||
service.interceptors.response.use(
|
||||
(response) => {
|
||||
// if the custom code is not 200, it is judged as an error.
|
||||
@@ -69,31 +111,25 @@ service.interceptors.response.use(
|
||||
//第一次出现401,可以重新刷新令牌一次
|
||||
if (error.response.status == 401 && !originalConfig._retry) {
|
||||
originalConfig._retry = true;
|
||||
let data: JwtInfo = new JwtInfo();
|
||||
data = useLoginProfileStore().getJwtInfo()!;
|
||||
if (
|
||||
data == null ||
|
||||
data.AccessToken == null ||
|
||||
data.AccessToken.length == 0
|
||||
) {
|
||||
// errorNotificationWithIcon(
|
||||
// t("request.notification.identityAuthenticationFailure")
|
||||
// );
|
||||
router.push({ path: "/login" });
|
||||
return Promise.resolve();
|
||||
|
||||
const jwtInfo: JwtInfo = useLoginProfileStore().getJwtInfo()!;
|
||||
if (!jwtInfo || !jwtInfo.AccessToken || jwtInfo.AccessToken.length == 0) {
|
||||
// 本来就没有登录态,直接去登录页
|
||||
goLogin(true);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
try {
|
||||
let jwt: JwtInfo = await request<JwtInfo>({
|
||||
url: "/Auth/RefreshToken",
|
||||
method: "post",
|
||||
data,
|
||||
});
|
||||
useLoginProfileStore().updateJwtInfo(jwt);
|
||||
//令牌成功刷新后,再次调用之前请求
|
||||
const res = await service.request(originalConfig);
|
||||
return res;
|
||||
} catch (error) {
|
||||
return Promise.resolve();
|
||||
await refreshTokenOnce();
|
||||
//令牌成功刷新后,再次调用之前请求(请求拦截器会带上新令牌)
|
||||
return await service.request(originalConfig);
|
||||
} catch (refreshError) {
|
||||
// 刷新失败必须 reject:原来 return Promise.resolve() 会让业务拿到 undefined
|
||||
// 当成功处理;这里清登录态跳登录页,并把错误抛给调用方。
|
||||
// 网络类异常(没有响应)保留登录态,避免一次抖动就把用户踢出去。
|
||||
const e: any = refreshError;
|
||||
goLogin(!!(e && (e.status || e.response)));
|
||||
return Promise.reject(refreshError ?? error);
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
@@ -135,7 +171,8 @@ export const request = async <T = any>(
|
||||
}
|
||||
|
||||
|
||||
return isGetDataDirectly ? res.data : res;
|
||||
// res 是 AxiosResponse,取 data 才是业务数据;这里断言成 T 消除类型报错(原代码就有)
|
||||
return (isGetDataDirectly ? res.data : res) as T;
|
||||
} else {
|
||||
return res;
|
||||
}
|
||||
|
||||
@@ -1,2 +1,51 @@
|
||||
<template>error 401</template>
|
||||
<script setup lang="ts"></script>
|
||||
<template>
|
||||
<ErrorPanel code="401" badge="UNAUTHORIZED · 未认证" tone="blue" title="需要登录后才能访问"
|
||||
description="你的登录状态已经失效,或者还没有登录。重新登录一次即可继续。" :reasons="reasons" :from="fromPath" hint="提示:登录后在其它设备退出登录,也会让当前设备失效。">
|
||||
<template #icon>
|
||||
<LockOutlined />
|
||||
</template>
|
||||
<template #actions>
|
||||
<a-button type="primary" size="large" block @click="goLogin">
|
||||
<template #icon><LoginOutlined /></template>
|
||||
去登录
|
||||
</a-button>
|
||||
<a-button size="large" block @click="goBack">
|
||||
<template #icon><ArrowLeftOutlined /></template>
|
||||
返回上一页
|
||||
</a-button>
|
||||
</template>
|
||||
</ErrorPanel>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { ArrowLeftOutlined, LockOutlined, LoginOutlined } from "@ant-design/icons-vue";
|
||||
import ErrorPanel from "@/components/basic/ErrorPanel.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
/** 出错的地址:由守卫/拦截器跳转时用 ?from= 带过来 */
|
||||
const fromPath = computed(() =>
|
||||
typeof route.query.from === "string" ? route.query.from : ""
|
||||
);
|
||||
|
||||
const reasons = [
|
||||
"登录已过期:长时间没有操作,或访问令牌到期",
|
||||
"在其它设备或标签页退出了登录,当前设备随之失效",
|
||||
"浏览器清理了本地数据(无痕模式、清理缓存、换浏览器)",
|
||||
"管理员刚修改过你的账号信息(如密码、手机号)",
|
||||
];
|
||||
|
||||
/** 去登录:带上原地址,登录成功后能直接回到这里 */
|
||||
const goLogin = () => {
|
||||
const redirect = fromPath.value || "/dashboard";
|
||||
router.replace({ path: "/login", query: { redirect } });
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
if (window.history.length > 1) router.back();
|
||||
else router.replace("/login");
|
||||
};
|
||||
</script>
|
||||
@@ -1,2 +1,49 @@
|
||||
<template>error 403</template>
|
||||
<script setup lang="ts"> </script>
|
||||
<template>
|
||||
<ErrorPanel code="403" badge="FORBIDDEN · 无权限" tone="amber" title="你没有这个页面的访问权限"
|
||||
description="账号是正常的,但你所在的角色/权限组没有被授予这个功能。" :reasons="reasons" :from="fromPath" hint="提示:权限由管理员分配,变更后需要重新登录才会生效。">
|
||||
<template #icon>
|
||||
<SafetyCertificateOutlined />
|
||||
</template>
|
||||
<template #actions>
|
||||
<a-button type="primary" size="large" block @click="goRelogin">
|
||||
<template #icon><LoginOutlined /></template>
|
||||
重新登录
|
||||
</a-button>
|
||||
<a-button size="large" block @click="goHome">
|
||||
<template #icon><HomeOutlined /></template>
|
||||
返回首页
|
||||
</a-button>
|
||||
</template>
|
||||
</ErrorPanel>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { HomeOutlined, LoginOutlined, SafetyCertificateOutlined } from "@ant-design/icons-vue";
|
||||
import ErrorPanel from "@/components/basic/ErrorPanel.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const fromPath = computed(() =>
|
||||
typeof route.query.from === "string" ? route.query.from : ""
|
||||
);
|
||||
|
||||
const reasons = [
|
||||
"当前账号所属的角色 / 权限组没有该功能的权限",
|
||||
"该功能还没在后台授权,虽然能看到入口但打不开",
|
||||
"权限刚被调整过,浏览器里还是旧的权限数据",
|
||||
"把别人的链接发给了你,而你的账号没有对应权限",
|
||||
];
|
||||
|
||||
/** 重新登录:拿一次最新权限(权限变更后必须重登才生效) */
|
||||
const goRelogin = () => {
|
||||
router.replace({ path: "/login", query: fromPath.value ? { redirect: fromPath.value } : undefined });
|
||||
};
|
||||
|
||||
/** 回首页:首页与菜单是匿名接口,即使没权限也能正常打开 */
|
||||
const goHome = () => {
|
||||
router.replace(router.hasRoute("dashboard") ? "/dashboard" : "/");
|
||||
};
|
||||
</script>
|
||||
@@ -1,2 +1,49 @@
|
||||
<template>error 404</template>
|
||||
<script setup lang="ts"> </script>
|
||||
<template>
|
||||
<ErrorPanel code="404" badge="NOT FOUND · 找不到页面" tone="violet" title="这个页面不存在"
|
||||
description="地址可能写错了,或者对应的页面已经不在系统里。" :reasons="reasons" :from="fromPath" hint="提示:没有权限的功能不会出现在菜单里,地址直接打开也会落到这里。">
|
||||
<template #icon>
|
||||
<CompassOutlined />
|
||||
</template>
|
||||
<template #actions>
|
||||
<a-button type="primary" size="large" block @click="goHome">
|
||||
<template #icon><HomeOutlined /></template>
|
||||
返回首页
|
||||
</a-button>
|
||||
<a-button size="large" block @click="goBack">
|
||||
<template #icon><ArrowLeftOutlined /></template>
|
||||
返回上一页
|
||||
</a-button>
|
||||
</template>
|
||||
</ErrorPanel>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { ArrowLeftOutlined, CompassOutlined, HomeOutlined } from "@ant-design/icons-vue";
|
||||
import ErrorPanel from "@/components/basic/ErrorPanel.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
/** 出错的地址:路由兜底跳转时用 ?from= 带过来 */
|
||||
const fromPath = computed(() =>
|
||||
typeof route.query.from === "string" ? route.query.from : ""
|
||||
);
|
||||
|
||||
const reasons = [
|
||||
"地址拼错了,或从聊天/邮件里复制时被截断",
|
||||
"该功能没有在菜单里开放(系统里查不到这条路由)",
|
||||
"页面已下线或改名,收藏夹里还是旧地址",
|
||||
"服务器刚重启、菜单还没加载出来,刷新一次即可",
|
||||
];
|
||||
|
||||
const goHome = () => {
|
||||
router.replace(router.hasRoute("dashboard") ? "/dashboard" : "/");
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
if (window.history.length > 1) router.back();
|
||||
else router.replace(router.hasRoute("dashboard") ? "/dashboard" : "/");
|
||||
};
|
||||
</script>
|
||||
@@ -75,7 +75,7 @@
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, computed, watch } from 'vue'
|
||||
import { useRouter } from "vue-router";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { UserOutlined, LockOutlined } from "@ant-design/icons-vue";
|
||||
import authService from "@/service/security/authService";
|
||||
import { LoginData } from "@/data/auto/loginData";
|
||||
@@ -95,8 +95,15 @@ const { t } = i18n.global;
|
||||
//const props = defineProps(['id'])
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const userLoginProfile = useLoginProfileStore();
|
||||
|
||||
/** 登录成功后的落点:路由守卫拦截时会带上 ?redirect=,没有就回首页 */
|
||||
const afterLoginPath = (): string =>
|
||||
typeof route.query.redirect === "string" && route.query.redirect
|
||||
? route.query.redirect
|
||||
: "/dashboard";
|
||||
|
||||
const state = reactive({
|
||||
loading: false,
|
||||
LoginData: {
|
||||
@@ -149,7 +156,7 @@ const handleSubmit = async () => {
|
||||
|
||||
userLoginProfile.updateLoginProfile(profile);
|
||||
|
||||
router.push({ path: "/dashboard" });
|
||||
router.push(afterLoginPath());
|
||||
} catch (error) {
|
||||
} finally {
|
||||
state.loading = false;
|
||||
@@ -281,7 +288,7 @@ const onBiometricLogin = async () => {
|
||||
// same call LoginView makes after a successful password login.
|
||||
userLoginProfile.updateLoginProfile(profile);
|
||||
|
||||
router.push({ path: "/dashboard" });
|
||||
router.push(afterLoginPath());
|
||||
} catch (err: any) {
|
||||
// Best-effort error extraction — handles ALL of:
|
||||
// 1. Axios error: err.response.data is the server body (string or object)
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
<a-button type="primary" :disabled="isEdit" :icon="h(FileSearchOutlined)"
|
||||
@click.prevent="onSearch">查询</a-button>
|
||||
<a-button :disabled="isEdit" :icon="h(ClearOutlined)" @click.prevent="onReset">重置</a-button>
|
||||
<a-button type="primary" :disabled="isEdit" :icon="h(FileAddOutlined)" @click.prevent="onCreate">新增</a-button>
|
||||
<a-button type="primary" v-if="canCreate" :disabled="isEdit" :icon="h(FileAddOutlined)"
|
||||
@click.prevent="onCreate">新增</a-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="page-main ft-page" :class="{ 'ft-readonly': !isEdit }" :style="{ height: resultH }">
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
@click.prevent="onSearch">查询</a-button>
|
||||
<a-button type="primary" :disabled="isEdit" :icon="h(ClearOutlined)"
|
||||
@click.prevent="onReset">重置</a-button>
|
||||
<a-button type="primary" :disabled="isEdit" :icon="h(FileAddOutlined)"
|
||||
<a-button type="primary" v-if="canCreate" :disabled="isEdit" :icon="h(FileAddOutlined)"
|
||||
@click.prevent="onCreate">新增</a-button>
|
||||
</a-space>
|
||||
</a-col>
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
>
|
||||
<a-button
|
||||
type="primary"
|
||||
v-if="canCreate"
|
||||
:disabled="isEdit"
|
||||
:icon="h(FileAddOutlined)"
|
||||
@click.prevent="onCreate"
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
@click.prevent="onSearch">查询</a-button>
|
||||
<a-button type="primary" :disabled="isEdit" :icon="h(ClearOutlined)"
|
||||
@click.prevent="onReset">重置</a-button>
|
||||
<a-button type="primary" :disabled="isEdit" :icon="h(FileAddOutlined)"
|
||||
<a-button type="primary" v-if="canCreate" :disabled="isEdit" :icon="h(FileAddOutlined)"
|
||||
@click.prevent="onCreate">新增</a-button>
|
||||
</a-space>
|
||||
</a-col>
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
@click.prevent="onSearch">查询</a-button>
|
||||
<a-button type="primary" :disabled="isEdit" :icon="h(ClearOutlined)"
|
||||
@click.prevent="onReset">重置</a-button>
|
||||
<a-button type="primary" :disabled="isEdit" :icon="h(FileAddOutlined)"
|
||||
<a-button type="primary" v-if="canCreate" :disabled="isEdit" :icon="h(FileAddOutlined)"
|
||||
@click.prevent="onCreate">新增</a-button>
|
||||
</a-space>
|
||||
</a-col>
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
@click.prevent="onSearch">查询</a-button>
|
||||
<a-button type="primary" :disabled="isEdit" :icon="h(ClearOutlined)"
|
||||
@click.prevent="onReset">重置</a-button>
|
||||
<a-button type="primary" :disabled="isEdit" :icon="h(FileAddOutlined)"
|
||||
<a-button type="primary" v-if="canCreate" :disabled="isEdit" :icon="h(FileAddOutlined)"
|
||||
@click.prevent="onCreate">新增</a-button>
|
||||
</a-space>
|
||||
</a-col>
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
@click.prevent="onSearch">查询</a-button>
|
||||
<a-button type="primary" :disabled="isEdit" :icon="h(ClearOutlined)"
|
||||
@click.prevent="onReset">重置</a-button>
|
||||
<a-button type="primary" :disabled="isEdit" :icon="h(FileAddOutlined)"
|
||||
<a-button type="primary" v-if="canCreate" :disabled="isEdit" :icon="h(FileAddOutlined)"
|
||||
@click.prevent="onCreate">新增</a-button>
|
||||
</a-space>
|
||||
</a-col>
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
@click.prevent="onSearch">查询</a-button>
|
||||
<a-button type="primary" :disabled="isEdit" :icon="h(ClearOutlined)"
|
||||
@click.prevent="onReset">重置</a-button>
|
||||
<a-button type="primary" :disabled="isEdit" :icon="h(FileAddOutlined)"
|
||||
<a-button type="primary" v-if="canCreate" :disabled="isEdit" :icon="h(FileAddOutlined)"
|
||||
@click.prevent="onCreate">新增</a-button>
|
||||
</a-space>
|
||||
</a-col>
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
@click.prevent="onSearch">查询</a-button>
|
||||
<a-button type="primary" :disabled="isEdit" :icon="h(ClearOutlined)"
|
||||
@click.prevent="onReset">重置</a-button>
|
||||
<a-button type="primary" :disabled="isEdit" :icon="h(FileAddOutlined)"
|
||||
<a-button type="primary" v-if="canCreate" :disabled="isEdit" :icon="h(FileAddOutlined)"
|
||||
@click.prevent="onCreate">新增</a-button>
|
||||
</a-space>
|
||||
</a-col>
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
@click.prevent="onSearch">查询</a-button>
|
||||
<a-button type="primary" :disabled="isEdit" :icon="h(ClearOutlined)"
|
||||
@click.prevent="onReset">重置</a-button>
|
||||
<a-button type="primary" :disabled="isEdit" :icon="h(FileAddOutlined)"
|
||||
<a-button type="primary" v-if="canCreate" :disabled="isEdit" :icon="h(FileAddOutlined)"
|
||||
@click.prevent="onCreate">新增</a-button>
|
||||
</a-space>
|
||||
</a-col>
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
@click.prevent="onSearch">查询</a-button>
|
||||
<a-button type="primary" :disabled="isEdit" :icon="h(ClearOutlined)"
|
||||
@click.prevent="onReset">重置</a-button>
|
||||
<a-button type="primary" :disabled="isEdit" :icon="h(FileAddOutlined)"
|
||||
<a-button type="primary" v-if="canCreate" :disabled="isEdit" :icon="h(FileAddOutlined)"
|
||||
@click.prevent="onCreate">新增</a-button>
|
||||
</a-space>
|
||||
</a-col>
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
@click.prevent="onSearch">查询</a-button>
|
||||
<a-button type="primary" :disabled="isEdit" :icon="h(ClearOutlined)"
|
||||
@click.prevent="onReset">重置</a-button>
|
||||
<a-button type="primary" :disabled="isEdit" :icon="h(FileAddOutlined)"
|
||||
<a-button type="primary" v-if="canCreate" :disabled="isEdit" :icon="h(FileAddOutlined)"
|
||||
@click.prevent="onCreate">新增</a-button>
|
||||
</a-space>
|
||||
</a-col>
|
||||
|
||||
Reference in new issue
Block a user