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

@@ -1435,19 +1435,21 @@ namespace CloudBuilder.Master.Service
Directory.CreateDirectory(targetDir);
}
para = string.Format(service.Configuration["Ffmpeg:Arguments"], mp4Url, fullName);
para = string.Format(service.Configuration[ServiceSettingsPolicy.FfmpegArguments], mp4Url, "", fullName);
IRepository<SecurityScheduleTaskQueueEntity> logRepository = repository.GetRepository<IRepository<SecurityScheduleTaskQueueEntity>>();
SecurityScheduleTaskQueueEntity task = new SecurityScheduleTaskQueueEntity();
task.DocumentId = documentId;
task.TaskId = service.Configuration["Ffmpeg:Path"];
task.TaskId = service.Configuration[ServiceSettingsPolicy.FfmpegPath];
task.TaskParam = para;
logRepository.InsertNow(task);
para = string.Format(service.Configuration[ServiceSettingsPolicy.FfmpegArguments], mp4Url, " -t 10", fullName);
//直接转码视频文件10秒,影响服务器性能,改为在闭时自动生成全部
RunMyProcess(task.TaskId, task.TaskParam);
RunMyProcess(task.TaskId, para);
}
public void ImageToScare(string fullName, DmsFileEntity dmsFile)
@@ -447,14 +447,23 @@ namespace CloudBuilder.Security.Service
IRepository<SecurityUserWebLoginEntity> repositoryLog = service.GetRepository<IRepository<SecurityUserWebLoginEntity>>();
SecurityUserWebLoginEntity log = repositoryLog.DetachedEntities.Where(x => x.Username == username && x.SessionId == clainmName).FirstOrDefault()!;
SecurityUserWebLoginEntity log = repositoryLog.DetachedEntities.Where(x => x.Username == username && x.SessionId == clainmName).FirstOrDefault();
//不是最后登录的令牌失效
DateTime? LoginTime = repositoryLog.DetachedEntities.Where(x => x.Username == username).Max(x => x.LoginTime);
if (LoginTime.HasValue && log.LoginTime < LoginTime.Value) throw new ServiceErrorException("无效的令牌,请重新登录.");
// 先判空:原来判空写在后面,却已经先用了 log.LoginTime,
// 会话记录查不到时(日志被清理、拿了旧 token 来刷新)会抛 NullReference,
// 而不是返回友好的"无效的令牌,请重新登录."。
if (log == null) throw new ServiceErrorException("无效的令牌,请重新登录.");
if (log == null || log.RefreshToken != jwtInfo.RefreshToken || log.LogoutTime.HasValue ||
log.RefreshToken != jwtInfo.RefreshToken || log.RefreshTokenRefreshedTime.AddMinutes(Convert.ToInt32(service.CloudBuilder.CloudBuilderOptions.Jwt.RefreshExpireMins)) < DateTime.Now)
// 不是最后一次登录的令牌失效。
// 用 Any(存在更晚的登录) 代替 Max(LoginTime) 聚合:语义完全一致
// (只失效"登录时间更晚"的会话,同一时刻的并列登录依旧有效),
// 而且 Any 命中索引即可短路返回,比每次刷新都算一遍 Max 便宜。
bool hasLaterLogin = repositoryLog.DetachedEntities.Any(x => x.Username == username && x.LoginTime > log.LoginTime);
if (hasLaterLogin) throw new ServiceErrorException("无效的令牌,请重新登录.");
// 原来 RefreshToken 的比较写了两遍,去掉重复
if (log.RefreshToken != jwtInfo.RefreshToken || log.LogoutTime.HasValue ||
log.RefreshTokenRefreshedTime.AddMinutes(Convert.ToInt32(service.CloudBuilder.CloudBuilderOptions.Jwt.RefreshExpireMins)) < DateTime.Now)
throw new ServiceErrorException("无效的令牌,请重新登录.");
JwtGenerate jwtGenerate = service.ServiceProvider.GetService<JwtGenerate>()!;
@@ -470,10 +479,6 @@ namespace CloudBuilder.Security.Service
log.RefreshToken = jwtGenerate.GenerateRefreshToken();
log.RefreshTokenRefreshedTime = DateTime.Now;
if (httpContextAccessor.HttpContext.Request.Headers.ContainsKey("Authorization"))
httpContextAccessor.HttpContext.Request.Headers.Remove("Authorization");
httpContextAccessor.HttpContext.Request.Headers.Add("Authorization", "Bearer " + accessToken);
repositoryLog.UpdateNow(log);
jwtInfo.AccessToken = accessToken;
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="Current" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup>
<NameOfLastUsedPublishProfile>D:\Docker\CloudBuilder\CloudBuilder.Web.Entry\Properties\PublishProfiles\FolderProfile3.pubxml</NameOfLastUsedPublishProfile>
<NameOfLastUsedPublishProfile>D:\Docker\CloudBuilder\CloudBuilder.Web.Entry\Properties\PublishProfiles\FolderProfile4.pubxml</NameOfLastUsedPublishProfile>
<ActiveDebugProfile>http</ActiveDebugProfile>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
@@ -3,7 +3,7 @@
<Project>
<PropertyGroup>
<_PublishTargetUrl>C:\inetpub\wwwroot\dockwebapi2</_PublishTargetUrl>
<History>True|2026-09-21T14:40:07.1232044Z||;True|2026-09-21T22:35:06.9446571+08:00||;True|2026-09-21T22:27:42.1146825+08:00||;False|2026-09-21T22:27:17.6066573+08:00||;True|2026-09-21T21:01:22.8888913+08:00||;True|2026-09-21T19:15:03.4753747+08:00||;True|2026-09-21T18:08:30.0866986+08:00||;True|2026-09-21T16:51:19.8137872+08:00||;True|2026-09-21T16:30:01.0950896+08:00||;True|2026-09-21T15:09:00.2326737+08:00||;True|2026-09-21T14:47:09.6120256+08:00||;True|2026-09-21T14:16:35.3194409+08:00||;True|2026-09-21T13:54:58.2829712+08:00||;True|2026-09-21T13:36:45.3134499+08:00||;True|2026-09-19T09:48:52.0085130+08:00||;False|2026-09-19T09:47:56.1658214+08:00||;True|2026-09-17T18:50:15.9493380+08:00||;True|2026-09-17T18:11:11.5364547+08:00||;True|2026-09-17T18:07:27.7830758+08:00||;True|2026-09-17T17:58:16.5798560+08:00||;True|2026-09-17T15:37:30.9025614+08:00||;True|2026-09-17T15:25:46.9116292+08:00||;True|2026-09-17T15:05:18.6592403+08:00||;True|2026-09-17T14:57:28.7997437+08:00||;True|2026-09-17T14:36:00.6339386+08:00||;True|2026-09-17T13:56:43.2376873+08:00||;True|2026-09-16T13:56:51.8724546+08:00||;True|2026-09-15T20:44:46.6559899+08:00||;True|2026-09-13T19:02:59.5654245+08:00||;True|2026-09-10T09:14:56.2914193+08:00||;True|2026-09-09T16:40:16.6461154+08:00||;True|2026-09-08T13:55:23.1502435+08:00||;True|2026-09-08T08:04:53.4279263+08:00||;True|2026-09-07T08:48:17.4682304+08:00||;True|2026-09-04T14:39:08.7878670+08:00||;True|2026-09-04T14:35:48.2937877+08:00||;True|2026-09-04T11:45:16.1372876+08:00||;True|2026-09-04T11:42:59.9356436+08:00||;True|2026-09-03T16:05:52.5270232+08:00||;True|2026-09-03T14:52:56.8559144+08:00||;True|2026-09-02T11:08:23.8720038+08:00||;True|2026-09-02T10:58:16.8081797+08:00||;True|2026-08-26T13:34:12.3805892+08:00||;True|2026-08-25T08:31:30.7787234+08:00||;True|2026-08-20T13:10:46.4725468+08:00||;True|2026-08-20T13:10:28.3142576+08:00||;True|2026-08-19T16:10:27.3362887+08:00||;True|2026-08-19T16:01:12.6234971+08:00||;True|2026-08-19T15:42:46.6576265+08:00||;True|2026-08-19T13:39:59.9363522+08:00||;True|2026-08-19T13:36:53.0379313+08:00||;True|2026-08-19T13:31:36.0841398+08:00||;True|2026-08-19T11:44:37.5319626+08:00||;True|2026-08-19T09:36:45.6000047+08:00||;True|2026-08-19T09:34:36.8468631+08:00||;True|2026-08-19T08:35:41.2202300+08:00||;True|2026-08-18T16:20:59.0170916+08:00||;</History>
<History>True|2026-09-22T03:19:19.6087109Z||;True|2026-09-22T11:09:24.9165065+08:00||;True|2026-09-21T22:40:07.1232044+08:00||;True|2026-09-21T22:35:06.9446571+08:00||;True|2026-09-21T22:27:42.1146825+08:00||;False|2026-09-21T22:27:17.6066573+08:00||;True|2026-09-21T21:01:22.8888913+08:00||;True|2026-09-21T19:15:03.4753747+08:00||;True|2026-09-21T18:08:30.0866986+08:00||;True|2026-09-21T16:51:19.8137872+08:00||;True|2026-09-21T16:30:01.0950896+08:00||;True|2026-09-21T15:09:00.2326737+08:00||;True|2026-09-21T14:47:09.6120256+08:00||;True|2026-09-21T14:16:35.3194409+08:00||;True|2026-09-21T13:54:58.2829712+08:00||;True|2026-09-21T13:36:45.3134499+08:00||;True|2026-09-19T09:48:52.0085130+08:00||;False|2026-09-19T09:47:56.1658214+08:00||;True|2026-09-17T18:50:15.9493380+08:00||;True|2026-09-17T18:11:11.5364547+08:00||;True|2026-09-17T18:07:27.7830758+08:00||;True|2026-09-17T17:58:16.5798560+08:00||;True|2026-09-17T15:37:30.9025614+08:00||;True|2026-09-17T15:25:46.9116292+08:00||;True|2026-09-17T15:05:18.6592403+08:00||;True|2026-09-17T14:57:28.7997437+08:00||;True|2026-09-17T14:36:00.6339386+08:00||;True|2026-09-17T13:56:43.2376873+08:00||;True|2026-09-16T13:56:51.8724546+08:00||;True|2026-09-15T20:44:46.6559899+08:00||;True|2026-09-13T19:02:59.5654245+08:00||;True|2026-09-10T09:14:56.2914193+08:00||;True|2026-09-09T16:40:16.6461154+08:00||;True|2026-09-08T13:55:23.1502435+08:00||;True|2026-09-08T08:04:53.4279263+08:00||;True|2026-09-07T08:48:17.4682304+08:00||;True|2026-09-04T14:39:08.7878670+08:00||;True|2026-09-04T14:35:48.2937877+08:00||;True|2026-09-04T11:45:16.1372876+08:00||;True|2026-09-04T11:42:59.9356436+08:00||;True|2026-09-03T16:05:52.5270232+08:00||;True|2026-09-03T14:52:56.8559144+08:00||;True|2026-09-02T11:08:23.8720038+08:00||;True|2026-09-02T10:58:16.8081797+08:00||;True|2026-08-26T13:34:12.3805892+08:00||;True|2026-08-25T08:31:30.7787234+08:00||;True|2026-08-20T13:10:46.4725468+08:00||;True|2026-08-20T13:10:28.3142576+08:00||;True|2026-08-19T16:10:27.3362887+08:00||;True|2026-08-19T16:01:12.6234971+08:00||;True|2026-08-19T15:42:46.6576265+08:00||;True|2026-08-19T13:39:59.9363522+08:00||;True|2026-08-19T13:36:53.0379313+08:00||;True|2026-08-19T13:31:36.0841398+08:00||;True|2026-08-19T11:44:37.5319626+08:00||;True|2026-08-19T09:36:45.6000047+08:00||;True|2026-08-19T09:34:36.8468631+08:00||;True|2026-08-19T08:35:41.2202300+08:00||;True|2026-08-18T16:20:59.0170916+08:00||;</History>
<LastFailureDetails />
</PropertyGroup>
</Project>
@@ -3,7 +3,7 @@
<Project>
<PropertyGroup>
<_PublishTargetUrl>C:\inetpub\wwwroot\windows</_PublishTargetUrl>
<History>True|2026-09-19T01:55:09.0653660Z||;True|2026-09-19T09:53:13.8933944+08:00||;</History>
<History>True|2026-09-22T07:18:53.4935889Z||;True|2026-09-22T11:24:43.8593406+08:00||;True|2026-09-19T09:55:09.0653660+08:00||;True|2026-09-19T09:53:13.8933944+08:00||;</History>
<LastFailureDetails />
</PropertyGroup>
</Project>
@@ -38,8 +38,8 @@
"Issuer": "Owen Chen",
"Audience": "Cloud Builder",
"Secret": "{8619F7C3-B53C-4B85-99F0-983D351ECD82}",
"AccessExpireMins": 480, //8小时过期
"RefreshExpireMins": 1440, // 1天
"AccessExpireMins": 120, //2小时过期
"RefreshExpireMins": 86400, // 60天重刷新过期时间
"IsRefreshAbsoluteExpired": false
},
"Redis": {
@@ -61,8 +61,7 @@
"ScaleFileDirectory": "D:\\Net8\\FileServer\\Scale",
"RequestPath": "/FileServer", //如果是IIS反向代理来的,那么要在本地给这个文件添加IUSR,IIS_IUSRS权限
"DmsFileDirectory": "DmsFile",
"ExcelOutputDirectory": "D:\\Net8\\FileServer\\Output",
"MaxFileSize": 1000//M
"ExcelOutputDirectory": "D:\\Net8\\FileServer\\Output"
},
"ScheduleTask": {
"Ffmpeg": "RunTask"
@@ -70,7 +69,7 @@
"Ffmpeg": {
"Path": "D:\\NET8\\Exe\\ffmpeg\\bin\\ffmpeg.exe", //视频转码
"Pictrue": " -i {0} -ss 00:00:2.000 -frames:v 1 -vf \"scale=1280:-2\" -q:v 4 {1}", //" -i {0} -ss 00:00:2.000 -frames:v 1 {1}",
"Arguments": "-i {0} -t 10 -c:v libx264 -preset veryfast -crf 26 -profile:v main -level 3.1 -vf \"scale='min(1280,iw)':-2\" -maxrate 1500k -bufsize 2400k -c:a aac -b:a 96k -ac 2 -ar 44100 -start_number 0 -hls_time 3 -hls_list_size 0 -hls_flags independent_segments -f hls {1}" //0-源路径,1-目标路径
"Arguments": "-i {0}{1} -c:v libx264 -preset veryfast -crf 26 -profile:v main -level 3.1 -vf \"scale='min(1280,iw)':-2\" -maxrate 1500k -bufsize 2400k -c:a aac -b:a 96k -ac 2 -ar 44100 -start_number 0 -hls_time 3 -hls_list_size 0 -hls_flags independent_segments -f hls {2}" //0-源路径,1-切片秒数,2-目标路径
},
"CloudBuilder.Topshelf": {
"Path": "D:\\MAFAI\\CloudBuilder.Topshelf\\bin\\Debug\\net8.0\\CloudBuilder.Topshelf.exe" //图片压缩
+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>