This commit is contained in:
itcsx committed 2026-09-22 15:59:05 +08:00
1 parent d5d4a6f3bf
commit fb1a79a3e9
27 files changed
+708 -75

No files matched your search

+305
View File
@@ -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
View File
@@ -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;
+24 -4
View File
@@ -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(
"服务器器已停止工作,请联系管理员."
);
}
}
}
+7
View File
@@ -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
View File
@@ -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;
}
+51 -2
View File
@@ -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>
+49 -2
View File
@@ -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>
+49 -2
View File
@@ -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>
+10 -3
View File
@@ -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>
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -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>