修复尝试生产中的大量问题
This commit is contained in:
1 parent
dbf30a9bc0
commit
e8ceb5b46b
29 files changed
+1379
-61
No files matched your search
+105
@@ -0,0 +1,105 @@
|
||||
# GERP 前端部署脚本:全量编译并同步产物到生产目录(TempLate/GERP)
|
||||
#
|
||||
# 用途:
|
||||
# 1. 清理旧构建产物(dist 与 TempLate/GERP/assets),保证无 hash 残留
|
||||
# 2. 全量重新构建(vue-tsc 类型检查 + vite build)
|
||||
# 3. 同步 dist 产物到 TempLate/GERP(index.html + assets)
|
||||
# 4. 校验 TempLate 与 dist 完全一致
|
||||
#
|
||||
# 用法:
|
||||
# PowerShell 中执行: .\deploy.ps1
|
||||
# (若报执行策略限制: Set-ExecutionPolicy -Scope Process Bypass 后再执行)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$ProjectRoot = Split-Path -Parent $MyInvocation.MyCommand.Path # d:\SMANAGE\GERP
|
||||
$DistDir = Join-Path $ProjectRoot 'dist'
|
||||
$ProdRoot = Join-Path (Split-Path -Parent $ProjectRoot) 'TempLate\GERP' # d:\SMANAGE\TempLate\GERP
|
||||
$ProdAssetsDir = Join-Path $ProdRoot 'assets'
|
||||
$IndexHtml = Join-Path $ProdRoot 'index.html'
|
||||
|
||||
Write-Host "`n===== GERP 部署开始 =====" -ForegroundColor Cyan
|
||||
Write-Host "工程目录: $ProjectRoot"
|
||||
Write-Host "生产目录: $ProdRoot"
|
||||
Write-Host ""
|
||||
|
||||
# 1. 清理旧产物(dist + 生产 assets),保证全量构建无 hash 残留
|
||||
Write-Host '[1/4] 清理旧构建产物...' -ForegroundColor Yellow
|
||||
if (Test-Path $DistDir) {
|
||||
Remove-Item -Recurse -Force $DistDir
|
||||
Write-Host " 已删除: $DistDir"
|
||||
}
|
||||
if (Test-Path $ProdAssetsDir) {
|
||||
Remove-Item -Recurse -Force $ProdAssetsDir
|
||||
Write-Host " 已删除: $ProdAssetsDir"
|
||||
}
|
||||
if (Test-Path $IndexHtml) {
|
||||
Remove-Item -Force $IndexHtml
|
||||
Write-Host " 已删除: $IndexHtml"
|
||||
}
|
||||
Write-Host ''
|
||||
|
||||
# 2. 全量构建(vue-tsc 类型检查 + vite build)
|
||||
Write-Host '[2/4] 执行 npm run build(类型检查 + 全量编译)...' -ForegroundColor Yellow
|
||||
Push-Location $ProjectRoot
|
||||
try {
|
||||
npm run build
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "构建失败,exit code = $LASTEXITCODE"
|
||||
}
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
Write-Host ' 构建成功'
|
||||
Write-Host ''
|
||||
|
||||
# 3. 同步产物到生产目录
|
||||
Write-Host '[3/4] 同步产物到生产目录...' -ForegroundColor Yellow
|
||||
if (-not (Test-Path $ProdRoot)) {
|
||||
New-Item -ItemType Directory -Path $ProdRoot -Force | Out-Null
|
||||
}
|
||||
Copy-Item -Recurse -Force (Join-Path $DistDir '*') $ProdRoot
|
||||
Copy-Item -Force (Join-Path $DistDir 'index.html') $IndexHtml
|
||||
Write-Host ' 同步完成'
|
||||
Write-Host ''
|
||||
|
||||
# 4. 校验生产目录与 dist 完全一致(文件名集合)
|
||||
Write-Host '[4/4] 校验产物一致性...' -ForegroundColor Yellow
|
||||
function Get-AssetNames([string]$dir) {
|
||||
if (-not (Test-Path $dir)) { return @() }
|
||||
return @(Get-ChildItem -Path $dir -Recurse -File | ForEach-Object { $_.FullName.Substring($dir.Length) })
|
||||
}
|
||||
$distNames = Get-AssetNames (Join-Path $DistDir 'assets')
|
||||
$prodNames = Get-AssetNames $ProdAssetsDir
|
||||
$distSet = @($distNames | Sort-Object -Unique)
|
||||
$prodSet = @($prodNames | Sort-Object -Unique)
|
||||
|
||||
$onlyDist = @($distSet | Where-Object { $_ -notin $prodSet })
|
||||
$onlyProd = @($prodSet | Where-Object { $_ -notin $distSet })
|
||||
|
||||
if ($onlyDist.Count -eq 0 -and $onlyProd.Count -eq 0) {
|
||||
Write-Host " 校验通过: assets 文件数 $($distSet.Count) 个,生产与 dist 完全一致" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host ' 校验异常:' -ForegroundColor Red
|
||||
if ($onlyDist.Count) { Write-Host " 仅在 dist : $onlyDist" -ForegroundColor Red }
|
||||
if ($onlyProd.Count) { Write-Host " 仅在生产: $onlyProd" -ForegroundColor Red }
|
||||
throw '产物校验失败,请检查!'
|
||||
}
|
||||
|
||||
# 校验 index.html 引用的入口 JS 存在
|
||||
$indexContent = Get-Content $IndexHtml -Raw
|
||||
$entryMatch = [regex]::Match($indexContent, 'src="([^"]+\.js)"')
|
||||
if ($entryMatch.Success) {
|
||||
$entryPath = Join-Path $ProdRoot ($entryMatch.Groups[1].Value.TrimStart('/'))
|
||||
if (Test-Path $entryPath) {
|
||||
Write-Host " 入口 JS 校验通过: $($entryMatch.Groups[1].Value)" -ForegroundColor Green
|
||||
} else {
|
||||
throw "index.html 引用的入口 JS 不存在: $entryPath"
|
||||
}
|
||||
} else {
|
||||
throw 'index.html 中未找到入口 JS 引用'
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
Write-Host "===== GERP 部署完成 =====" -ForegroundColor Cyan
|
||||
Write-Host '请浏览器强制刷新(Ctrl+F5)加载新产物。' -ForegroundColor Cyan
|
||||
Generated
+10
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"@formkit/vue": "^2.1.2",
|
||||
"@fortawesome/fontawesome-free": "^7.3.1",
|
||||
"@vxe-ui/core": "^4.4.19",
|
||||
"@vxe-ui/plugin-render-element": "^4.4.1",
|
||||
"axios": "^1.19.0",
|
||||
@@ -328,6 +329,15 @@
|
||||
"vue": "^3.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fortawesome/fontawesome-free": {
|
||||
"version": "7.3.1",
|
||||
"resolved": "https://registry.npmmirror.com/@fortawesome/fontawesome-free/-/fontawesome-free-7.3.1.tgz",
|
||||
"integrity": "sha512-wmglKKPDIkgV3aWlZzWECCPoGIkYCulzBwxG9+w7rc5BGapZ6cPMpoPOT8k36J0Ni7PPX6c/rsoMWfS4d1MUMg==",
|
||||
"license": "(CC-BY-4.0 AND OFL-1.1 AND MIT)",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"@formkit/vue": "^2.1.2",
|
||||
"@fortawesome/fontawesome-free": "^7.3.1",
|
||||
"@vxe-ui/core": "^4.4.19",
|
||||
"@vxe-ui/plugin-render-element": "^4.4.1",
|
||||
"axios": "^1.19.0",
|
||||
|
||||
+7
-7
@@ -1,11 +1,11 @@
|
||||
import { get, post } from './request'
|
||||
import type { UserInfo } from '@/types/api'
|
||||
|
||||
/** 登录参数 */
|
||||
/** 登录参数(对齐后端 LoginSerializer 字段:UserName / UserPassword) */
|
||||
export interface LoginParams {
|
||||
Username: string
|
||||
Password: string
|
||||
/** 是否记住我(延长 Token 有效期) */
|
||||
UserName: string
|
||||
UserPassword: string
|
||||
/** 是否记住我(延长 Token 有效期,后端当前忽略该字段) */
|
||||
RememberMe?: boolean
|
||||
/** 2FA 验证码(开启二步验证时必填) */
|
||||
TwoFactorCode?: string
|
||||
@@ -46,9 +46,9 @@ export const switchRole = (role: string): Promise<void> => {
|
||||
return post<void>('/Auth/SwitchRole', { Role: role })
|
||||
}
|
||||
|
||||
/** 获取当前登录用户信息 */
|
||||
export const getCurrentUser = (): Promise<UserInfo> => {
|
||||
return get<UserInfo>('/Query/GetELTUserInfo')
|
||||
/** 获取当前登录用户信息(silent=true 时失败不弹全局提示,用于登录页会话恢复) */
|
||||
export const getCurrentUser = (silent = false): Promise<UserInfo> => {
|
||||
return get<UserInfo>('/Query/GetELTUserInfo', undefined, silent ? { silent: true } : undefined)
|
||||
}
|
||||
|
||||
/** 获取用户头像 URL(直接作为图片 src 使用) */
|
||||
|
||||
+32
-5
@@ -7,10 +7,18 @@ import axios, {
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import type { ApiResult } from '@/types/api'
|
||||
import { getServerScopeParams } from '@/utils/serverScope'
|
||||
import router from '@/router'
|
||||
|
||||
/** 后端响应统一格式 */
|
||||
type BackendResponse<T> = AxiosResponse<ApiResult<T>>
|
||||
|
||||
/** 扩展请求配置:silent=true 时失败不弹全局提示(用于登录页会话静默恢复等场景) */
|
||||
declare module 'axios' {
|
||||
export interface AxiosRequestConfig {
|
||||
silent?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
/** 业务错误码:登录失效 */
|
||||
const CODE_UNAUTHORIZED = 401
|
||||
/** 业务错误码:成功 */
|
||||
@@ -60,7 +68,11 @@ request.interceptors.response.use(
|
||||
}
|
||||
|
||||
if (result.code === CODE_SUCCESS) {
|
||||
return result.data as unknown as T
|
||||
// 标准 {code, data} 结构:返回 data
|
||||
if ('data' in result) return result.data as unknown as T
|
||||
// 兼容扁平结构(如 GetELTUserInfo 返回 {code, UserUUID, UserName, ...} 无 data 字段)
|
||||
// 返回整个 result(含 code,调用方按需取业务字段),避免拿到 undefined 导致缓存写不进 userInfo
|
||||
return result as unknown as T
|
||||
}
|
||||
|
||||
// 业务错误:弹出提示并拒绝
|
||||
@@ -68,7 +80,17 @@ request.interceptors.response.use(
|
||||
return Promise.reject(new Error(result.msg || 'Business Error'))
|
||||
},
|
||||
(error) => {
|
||||
if (error?.response?.status === CODE_UNAUTHORIZED) {
|
||||
const status = error?.response?.status
|
||||
const url: string = error?.config?.url || ''
|
||||
// 静默模式:调用方自行处理失败(如登录页会话恢复),不弹全局提示
|
||||
const silent = (error?.config as InternalAxiosRequestConfig | undefined)?.silent === true
|
||||
if (silent) {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
// 登录接口自身的 401 是"凭证错误"(如用户名或密码错误),不是登录失效,
|
||||
// 直接展示后端 msg,不弹登录失效窗、不跳转。
|
||||
const isLoginRequest = /\/Auth\/Login/i.test(url)
|
||||
if (status === CODE_UNAUTHORIZED && !isLoginRequest) {
|
||||
handleLoginExpired()
|
||||
} else {
|
||||
const msg = error?.response?.data?.msg || error?.message || '网络错误'
|
||||
@@ -81,6 +103,9 @@ request.interceptors.response.use(
|
||||
/** 登录失效处理:弹窗提示并跳转登录页 */
|
||||
function handleLoginExpired(): void {
|
||||
if (isLoginExpiredShown) return
|
||||
// 已在登录页则不再跳转,避免 redirect 参数嵌套编码
|
||||
// (login 页 restoreSession 等 API 若再次 401,会重复触发本函数,导致 fullPath 含旧 redirect 被再次编码)
|
||||
if (router.currentRoute.value.name === 'Login') return
|
||||
isLoginExpiredShown = true
|
||||
ElMessageBox.alert('登录状态已失效,请重新登录', '提示', {
|
||||
type: 'warning',
|
||||
@@ -90,9 +115,11 @@ function handleLoginExpired(): void {
|
||||
.then(() => {
|
||||
// 清除本地用户状态
|
||||
localStorage.removeItem('gerp-user')
|
||||
// 跳转到登录页(保留原 URL 用于登录后回跳)
|
||||
const redirect = encodeURIComponent(window.location.pathname + window.location.search)
|
||||
window.location.href = `/login?redirect=${redirect}`
|
||||
// 使用 Vue Router 跳转登录页
|
||||
// hash 路由模式下 window.location.href 会保留 #/path,导致 Vue Router 按 hash 误路由到业务页("直接显示后台")
|
||||
// 改用 router.push 走标准导航,redirect 取当前完整路径(含 hash)用于登录后回跳
|
||||
const redirect = router.currentRoute.value.fullPath
|
||||
router.push({ name: 'Login', query: { redirect } })
|
||||
})
|
||||
.finally(() => {
|
||||
isLoginExpiredShown = false
|
||||
|
||||
@@ -30,6 +30,12 @@ const props = defineProps<{
|
||||
onView?: (row: T) => void
|
||||
/** 行点击回调 */
|
||||
onRowClick?: (row: T) => void
|
||||
/**
|
||||
* 是否撑满父容器(整页表格场景)。
|
||||
* 开启时表格 height=100% + 容器 flex 撑满,产生内部滚动;
|
||||
* 关闭时表格自适应内容高度(详情页嵌套场景,随页面滚动)。
|
||||
*/
|
||||
stretch?: boolean
|
||||
}>()
|
||||
|
||||
const options: UseCrudTableOptions<T> = {
|
||||
@@ -118,7 +124,7 @@ function renderUuid(value: unknown): string {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="crud-table">
|
||||
<div class="crud-table" :class="{ 'crud-table-stretch': props.stretch }">
|
||||
<!-- 搜索栏 -->
|
||||
<el-card v-if="searchable" shadow="never" class="search-bar">
|
||||
<el-input
|
||||
@@ -161,11 +167,11 @@ function renderUuid(value: unknown): string {
|
||||
<vxe-table
|
||||
ref="xTableRef"
|
||||
:data="dataList"
|
||||
:loading="loading"
|
||||
v-loading="loading"
|
||||
border
|
||||
stripe
|
||||
round
|
||||
height="auto"
|
||||
:height="props.stretch ? '100%' : 'auto'"
|
||||
:row-config="{ isHover: true }"
|
||||
:column-config="{ resizable: true }"
|
||||
:checkbox-config="{ range: true }"
|
||||
@@ -178,13 +184,14 @@ function renderUuid(value: unknown): string {
|
||||
<vxe-column type="seq" width="60" title="#" />
|
||||
|
||||
<!-- 数据列:根据 type/statusMap/entityPicker 分支渲染 -->
|
||||
<!-- 列宽对齐原生 TableEngine:无 width 时弹性填充,minWidth 默认 200 -->
|
||||
<vxe-column
|
||||
v-for="col in tableColumns"
|
||||
:key="col.field"
|
||||
:field="col.field"
|
||||
:title="col.title"
|
||||
:width="col.width"
|
||||
:min-width="col.minWidth"
|
||||
:min-width="col.minWidth ?? 200"
|
||||
:fixed="col.fixed"
|
||||
:sortable="col.sortable"
|
||||
>
|
||||
@@ -363,6 +370,34 @@ function renderUuid(value: unknown): string {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
// stretch 模式:撑满父容器,表格内部滚动(整页列表场景)
|
||||
// 用 flex:1 而非 height:100%:父容器均为 flex column,
|
||||
// 当存在兄弟元素(AdminBadge / scope-banner)时,height:100% 会取父容器全高导致溢出,
|
||||
// flex:1 则正确分配剩余空间;无兄弟元素时两者等效。
|
||||
.crud-table-stretch {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
// 仅 .table-card 撑满;.search-bar 保持自然高度(flex-shrink:0 防压缩)
|
||||
.crud-table-stretch .table-card {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
:deep(.el-card__body) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.crud-table-stretch .search-bar {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
:deep(.el-card__body) {
|
||||
display: flex;
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 路由切换遮罩层
|
||||
*
|
||||
* 路由开始切换时淡入覆盖旧页面(半透明 + 模糊 + loading),
|
||||
* 切换完成后淡出露出新页面。由 stores/progress.ts 的 visible 驱动,
|
||||
* 与顶部 RouteProgress 同步显隐。
|
||||
*
|
||||
* 视觉效果:旧页面被遮罩"盖住"→ 新页面就位 → 遮罩淡出揭开
|
||||
*/
|
||||
import { useProgressStore } from '@/stores/progress'
|
||||
|
||||
const progressStore = useProgressStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-mask" :class="{ show: progressStore.visible }">
|
||||
<div class="page-mask-content">
|
||||
<el-icon class="page-mask-icon is-loading"><Loading /></el-icon>
|
||||
<span class="page-mask-text">加载中</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-mask {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
// 半透明白色 + 背景模糊,营造"遮罩旧页面"效果
|
||||
background: rgba(255, 255, 255, 0.65);
|
||||
backdrop-filter: blur(3px);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.15s ease, visibility 0.15s ease;
|
||||
pointer-events: none;
|
||||
|
||||
&.show {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
// 加载期间拦截点击,防止误操作
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.page-mask-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.page-mask-icon {
|
||||
font-size: 32px;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.page-mask-text {
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 路由切换进度条
|
||||
*
|
||||
* 固定在视口顶部的轻量进度条,由 stores/progress.ts 驱动显隐与百分比。
|
||||
* router 守卫 beforeEach → start(),afterEach → done()。
|
||||
* 替代 NProgress,无第三方依赖。
|
||||
*/
|
||||
import { useProgressStore } from '@/stores/progress'
|
||||
|
||||
const progressStore = useProgressStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="route-progress" :class="{ 'is-visible': progressStore.visible }">
|
||||
<div class="route-progress-bar" :style="{ width: progressStore.percent + '%' }" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.route-progress {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
|
||||
&.is-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.route-progress-bar {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--el-color-primary), var(--el-color-primary-light-3));
|
||||
box-shadow: 0 0 8px rgba(0, 0, 0, 0.15);
|
||||
transition: width 0.2s ease;
|
||||
border-radius: 0 3px 3px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,243 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 多标签页栏
|
||||
*
|
||||
* 对齐原生 GERP 的 tab 切换能力:
|
||||
* - 横向标签栏,点击切换路由,关闭按钮关闭 tab
|
||||
* - 右键菜单:关闭其他 / 关闭所有
|
||||
* - 首页 Dashboard 常驻不可关闭
|
||||
* - 超出宽度时横向滚动(el-scrollbar)
|
||||
*
|
||||
* 数据由 stores/tabs.ts 管理,路由 afterEach 自动 addTab。
|
||||
*/
|
||||
import { ref, onBeforeUnmount } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useTabsStore } from '@/stores/tabs'
|
||||
|
||||
const tabsStore = useTabsStore()
|
||||
const router = useRouter()
|
||||
|
||||
// === 右键菜单 ===
|
||||
const ctxMenuVisible = ref(false)
|
||||
const ctxMenuX = ref(0)
|
||||
const ctxMenuY = ref(0)
|
||||
const ctxMenuPath = ref('')
|
||||
|
||||
/** 点击 tab 切换路由 */
|
||||
function handleClick(path: string): void {
|
||||
if (tabsStore.activePath === path) return
|
||||
const tab = tabsStore.tabs.find((t) => t.path === path)
|
||||
if (tab) router.push(tab.fullPath)
|
||||
}
|
||||
|
||||
/** 关闭 tab(点击关闭按钮) */
|
||||
function handleClose(path: string, e: Event): void {
|
||||
e.stopPropagation()
|
||||
const redirect = tabsStore.removeTab(path)
|
||||
if (redirect) router.push(redirect)
|
||||
}
|
||||
|
||||
/** 右键打开上下文菜单 */
|
||||
function openCtxMenu(path: string, e: MouseEvent): void {
|
||||
e.preventDefault()
|
||||
const tab = tabsStore.tabs.find((t) => t.path === path)
|
||||
if (!tab?.closable) return // 首页不弹右键菜单
|
||||
ctxMenuPath.value = path
|
||||
ctxMenuX.value = e.clientX
|
||||
ctxMenuY.value = e.clientY
|
||||
ctxMenuVisible.value = true
|
||||
document.addEventListener('click', closeCtxMenu)
|
||||
}
|
||||
|
||||
/** 关闭右键菜单 */
|
||||
function closeCtxMenu(): void {
|
||||
ctxMenuVisible.value = false
|
||||
document.removeEventListener('click', closeCtxMenu)
|
||||
}
|
||||
|
||||
/** 关闭其他 tab */
|
||||
function handleCloseOthers(): void {
|
||||
tabsStore.removeOthers(ctxMenuPath.value)
|
||||
const tab = tabsStore.tabs.find((t) => t.path === ctxMenuPath.value)
|
||||
if (tab) router.push(tab.fullPath)
|
||||
closeCtxMenu()
|
||||
}
|
||||
|
||||
/** 关闭所有可关闭 tab */
|
||||
function handleCloseAll(): void {
|
||||
tabsStore.removeAll()
|
||||
router.push('/dashboard')
|
||||
closeCtxMenu()
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', closeCtxMenu)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tab-bar">
|
||||
<el-scrollbar class="tab-scroll">
|
||||
<div class="tab-list">
|
||||
<div
|
||||
v-for="tab in tabsStore.tabs"
|
||||
:key="tab.path"
|
||||
class="tab-item"
|
||||
:class="{ active: tabsStore.activePath === tab.path }"
|
||||
@click="handleClick(tab.path)"
|
||||
@contextmenu="openCtxMenu(tab.path, $event)"
|
||||
>
|
||||
<!-- 图标:Element Plus 组件名或 FontAwesome 类名 -->
|
||||
<el-icon v-if="tab.icon && !tabsStore.isFaIcon(tab.icon)" class="tab-icon">
|
||||
<component :is="tab.icon" />
|
||||
</el-icon>
|
||||
<i v-else-if="tab.icon && tabsStore.isFaIcon(tab.icon)" :class="tab.icon" class="tab-icon-fa" />
|
||||
<span class="tab-title">{{ tab.title }}</span>
|
||||
<!-- 关闭按钮(仅 closable tab 显示) -->
|
||||
<el-icon v-if="tab.closable" class="tab-close" @click="handleClose(tab.path, $event)">
|
||||
<Close />
|
||||
</el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
|
||||
<!-- 右键上下文菜单 -->
|
||||
<teleport to="body">
|
||||
<ul
|
||||
v-if="ctxMenuVisible"
|
||||
class="tab-ctx-menu"
|
||||
:style="{ left: ctxMenuX + 'px', top: ctxMenuY + 'px' }"
|
||||
>
|
||||
<li @click="handleCloseOthers">
|
||||
<el-icon><CircleClose /></el-icon>关闭其他
|
||||
</li>
|
||||
<li @click="handleCloseAll">
|
||||
<el-icon><Delete /></el-icon>关闭所有
|
||||
</li>
|
||||
</ul>
|
||||
</teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.tab-bar {
|
||||
height: 36px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid var(--gerp-border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tab-scroll {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
:deep(.el-scrollbar__wrap) {
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
:deep(.el-scrollbar__bar.is-vertical) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.tab-list {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 36px;
|
||||
padding: 0 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
margin-right: 4px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-regular);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
border: 1px solid transparent;
|
||||
|
||||
&:hover {
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--el-color-primary-light-9);
|
||||
color: var(--el-color-primary);
|
||||
border-color: var(--el-color-primary-light-5);
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.tab-icon {
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tab-icon-fa {
|
||||
font-size: 14px;
|
||||
width: 14px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tab-title {
|
||||
max-width: 140px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tab-close {
|
||||
font-size: 12px;
|
||||
border-radius: 50%;
|
||||
padding: 2px;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--el-color-danger);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
/* 右键菜单(teleport 到 body,需非 scoped) */
|
||||
.tab-ctx-menu {
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
margin: 0;
|
||||
padding: 4px 0;
|
||||
list-style: none;
|
||||
background: #fff;
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.12);
|
||||
min-width: 120px;
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-regular);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: var(--el-fill-color-light);
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ref, reactive, onMounted, type Ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { formApi, type TableName, type QueryParams } from '@/api/form'
|
||||
import { useProgressStore } from '@/stores/progress'
|
||||
import type { ColumnSchema, PagedData } from '@/types/api'
|
||||
|
||||
/** CRUD 表格 composable 选项 */
|
||||
@@ -261,9 +262,16 @@ export function useCrudTable<T extends Record<string, unknown> = Record<string,
|
||||
}
|
||||
|
||||
// === 初始化 ===
|
||||
// v-show 多实例方案:组件首次挂载时加载数据并隐藏路由 loading
|
||||
// 之后 tab 切换仅 v-show(display),不重新挂载、不重新请求、不触发本钩子
|
||||
onMounted(async () => {
|
||||
await fetchColumns()
|
||||
await fetchData()
|
||||
// 并行加载列定义与数据,减少页面首屏等待(串行需两次网络往返)
|
||||
try {
|
||||
await Promise.all([fetchColumns(), fetchData()])
|
||||
} finally {
|
||||
// 数据加载完成(或失败),隐藏路由切换 loading
|
||||
useProgressStore().done()
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
+422
-14
@@ -1,12 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
/**
|
||||
* 主布局
|
||||
*
|
||||
* 结构:顶部 header(logo + 标题 + 操作 + 头像) + 侧边栏菜单 + 主内容区
|
||||
* 新增能力(对齐原生 GERP):
|
||||
* - RouteProgress:路由切换顶部进度条
|
||||
* - TabBar:多标签页切换栏
|
||||
* - 头像下拉菜单:用户信息头部 + 个人中心 + 系统设置 + 主题切换 + 退出登录
|
||||
*/
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { useTabsStore } from '@/stores/tabs'
|
||||
import { getAvatarUrl } from '@/api/auth'
|
||||
import TabBar from '@/components/TabBar.vue'
|
||||
import RouteProgress from '@/components/RouteProgress.vue'
|
||||
import PageMask from '@/components/PageMask.vue'
|
||||
|
||||
const appStore = useAppStore()
|
||||
const userStore = useUserStore()
|
||||
const tabsStore = useTabsStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
@@ -19,15 +34,67 @@ const activeMenu = computed(() => route.path)
|
||||
// 头像 URL
|
||||
const avatarUrl = getAvatarUrl()
|
||||
|
||||
// 头像下拉菜单用户信息
|
||||
const userInfo = computed(() => userStore.userInfo)
|
||||
const roleLabel = computed(() => {
|
||||
if (userStore.isAdmin) return '管理员'
|
||||
return userStore.currentRole || '普通用户'
|
||||
})
|
||||
|
||||
// === 身份切换 ===
|
||||
const roleSwitching = ref(false)
|
||||
const roles = computed(() => userStore.roles)
|
||||
|
||||
onMounted(() => {
|
||||
appStore.initTheme()
|
||||
// 获取角色列表(用于身份切换)
|
||||
if (userStore.isLoggedIn) {
|
||||
userStore.fetchRoles().catch(() => {
|
||||
// 获取角色失败静默处理
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
/** 切换身份/角色 */
|
||||
async function handleSwitchRole(role: string): Promise<void> {
|
||||
if (role === userStore.currentRole || roleSwitching.value) return
|
||||
roleSwitching.value = true
|
||||
try {
|
||||
await userStore.switchRole(role)
|
||||
tabsStore.reset()
|
||||
ElMessage.success(`已切换到:${role}`)
|
||||
router.push('/dashboard')
|
||||
} catch {
|
||||
ElMessage.error('身份切换失败')
|
||||
} finally {
|
||||
roleSwitching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleMenuSelect(index: string): void {
|
||||
router.push(index)
|
||||
}
|
||||
|
||||
/** 头像下拉菜单命令统一处理 */
|
||||
function handleUserCommand(cmd: string): void {
|
||||
switch (cmd) {
|
||||
case 'logout':
|
||||
handleLogout()
|
||||
break
|
||||
case 'theme':
|
||||
handleToggleTheme()
|
||||
break
|
||||
case 'profile':
|
||||
router.push('/profile')
|
||||
break
|
||||
case 'settings':
|
||||
router.push('/system/settings')
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout(): Promise<void> {
|
||||
tabsStore.reset()
|
||||
await userStore.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
@@ -40,6 +107,16 @@ function handleToggleTheme(): void {
|
||||
appStore.toggleTheme()
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 tab.path 解析对应路由组件
|
||||
* 取路由匹配链中最后一个(最具体的子路由)组件定义,用于 v-show 多实例渲染
|
||||
*/
|
||||
function resolveTabComponent(path: string) {
|
||||
const resolved = router.resolve(path)
|
||||
const matched = resolved.matched[resolved.matched.length - 1]
|
||||
return matched?.components?.default
|
||||
}
|
||||
|
||||
/** 判断图标是否为 FontAwesome 类名(如 'fas fa-chart-line'),与 Element Plus 组件名区分 */
|
||||
const isFaIcon = (icon: string | undefined | null): boolean => {
|
||||
if (!icon) return false
|
||||
@@ -51,6 +128,9 @@ const isFaIcon = (icon: string | undefined | null): boolean => {
|
||||
|
||||
<template>
|
||||
<div class="gerp-layout">
|
||||
<!-- 路由切换进度条 -->
|
||||
<RouteProgress />
|
||||
|
||||
<!-- 顶部导航 -->
|
||||
<header class="gerp-header">
|
||||
<div class="gerp-header-left">
|
||||
@@ -77,23 +157,62 @@ const isFaIcon = (icon: string | undefined | null): boolean => {
|
||||
<el-icon><Bell /></el-icon>
|
||||
</el-button>
|
||||
</el-badge>
|
||||
<el-button text circle title="主题" @click="handleToggleTheme">
|
||||
<el-icon><Setting /></el-icon>
|
||||
<el-button text circle title="主题切换" @click="handleToggleTheme">
|
||||
<el-icon><Brush /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-dropdown trigger="click" @command="(cmd: string) => cmd === 'logout' && handleLogout()">
|
||||
<el-dropdown trigger="click" @command="handleUserCommand">
|
||||
<div class="gerp-user-info">
|
||||
<img :src="avatarUrl" alt="头像" class="gerp-user-avatar" />
|
||||
<span class="gerp-user-name">{{ userStore.displayName }}</span>
|
||||
<el-icon><ArrowDown /></el-icon>
|
||||
</div>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item @click="router.push('/profile')">
|
||||
<el-dropdown-menu class="gerp-user-dropdown">
|
||||
<!-- 用户信息头部 -->
|
||||
<div class="user-dropdown-header">
|
||||
<img :src="avatarUrl" alt="头像" class="user-dropdown-avatar" />
|
||||
<div class="user-dropdown-info">
|
||||
<div class="user-dropdown-name">{{ userStore.displayName }}</div>
|
||||
<div class="user-dropdown-role">
|
||||
<el-tag size="small" :type="userStore.isAdmin ? 'danger' : 'info'">{{ roleLabel }}</el-tag>
|
||||
</div>
|
||||
<div v-if="userInfo?.Email" class="user-dropdown-email">{{ userInfo.Email }}</div>
|
||||
<div v-if="userInfo?.UserName" class="user-dropdown-account">@{{ userInfo.UserName }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 身份切换区域(仅多角色用户显示) -->
|
||||
<div v-if="roles.length > 1" class="user-dropdown-roles">
|
||||
<div class="user-dropdown-roles-title">
|
||||
<el-icon><Switch /></el-icon>
|
||||
<span>切换身份</span>
|
||||
</div>
|
||||
<div class="user-dropdown-roles-list">
|
||||
<div
|
||||
v-for="role in roles"
|
||||
:key="role"
|
||||
class="user-dropdown-role-item"
|
||||
:class="{ active: role === userStore.currentRole }"
|
||||
@click="handleSwitchRole(role)"
|
||||
>
|
||||
<el-icon v-if="role === userStore.currentRole" class="role-check"><Check /></el-icon>
|
||||
<el-icon v-else class="role-placeholder"><CircleCheck /></el-icon>
|
||||
<span>{{ role }}</span>
|
||||
<el-icon v-if="roleSwitching" class="is-loading role-loading"><Loading /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-dropdown-item divided command="profile">
|
||||
<el-icon><User /></el-icon>个人中心
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item divided command="logout">
|
||||
<el-dropdown-item command="settings">
|
||||
<el-icon><Setting /></el-icon>系统设置
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item command="theme">
|
||||
<el-icon><Brush /></el-icon>切换主题
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item divided command="logout" class="danger-item">
|
||||
<el-icon><SwitchButton /></el-icon>退出登录
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
@@ -109,7 +228,7 @@ const isFaIcon = (icon: string | undefined | null): boolean => {
|
||||
<el-menu
|
||||
:default-active="activeMenu"
|
||||
:collapse="appStore.sidebarCollapsed"
|
||||
:collapse-transition="false"
|
||||
:collapse-transition="true"
|
||||
router
|
||||
@select="handleMenuSelect"
|
||||
>
|
||||
@@ -141,13 +260,41 @@ const isFaIcon = (icon: string | undefined | null): boolean => {
|
||||
</template>
|
||||
</el-menu>
|
||||
</el-scrollbar>
|
||||
<!-- 侧边栏底部:版本号 -->
|
||||
<div class="gerp-sidebar-footer">
|
||||
<span class="gerp-version">v1.0.0</span>
|
||||
<el-button text circle size="small" title="设置" @click="router.push('/system/settings')">
|
||||
<el-icon><Setting /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<main class="gerp-content">
|
||||
<RouterView v-slot="{ Component }">
|
||||
<component :is="Component" />
|
||||
</RouterView>
|
||||
<!-- 多标签页栏 -->
|
||||
<TabBar />
|
||||
<!--
|
||||
路由内容体:v-show 多实例常驻
|
||||
每个已打开 tab 的组件挂载一次后常驻 DOM,切换 tab 仅切换 v-show(display),
|
||||
不销毁、不重新挂载、不重新请求数据 —— 实现"仅 DOM 位置切换"的瞬时切换。
|
||||
首次打开 tab 时组件挂载触发 onMounted 加载数据;之后切换完全瞬时。
|
||||
-->
|
||||
<div class="gerp-content-body">
|
||||
<div
|
||||
v-for="tab in tabsStore.tabs"
|
||||
:key="tab.path"
|
||||
v-show="tab.path === route.path"
|
||||
class="tab-pane"
|
||||
>
|
||||
<component
|
||||
:is="resolveTabComponent(tab.path)"
|
||||
v-if="tab.loaded"
|
||||
:key="tab.path"
|
||||
/>
|
||||
</div>
|
||||
<!-- 路由切换遮罩层(首次加载 tab 时显示,已缓存 tab 切换瞬时无遮罩) -->
|
||||
<PageMask />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
@@ -249,15 +396,103 @@ const isFaIcon = (icon: string | undefined | null): boolean => {
|
||||
width: 220px;
|
||||
background: #fff;
|
||||
border-right: 1px solid var(--gerp-border-color);
|
||||
transition: width 0.2s;
|
||||
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&.collapsed {
|
||||
width: 64px;
|
||||
}
|
||||
|
||||
:deep(.el-scrollbar) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
:deep(.el-menu) {
|
||||
border-right: none;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
// 菜单项:对齐原版 GERP 的左侧指示条 + hover/active 效果
|
||||
:deep(.el-menu-item) {
|
||||
position: relative;
|
||||
margin: 2px 8px;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
// 左侧指示条(hover/active 时显示)
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 3px;
|
||||
height: 60%;
|
||||
background: var(--el-color-primary);
|
||||
border-radius: 0 3px 3px 0;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--el-fill-color-light);
|
||||
|
||||
&::before {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
background: var(--el-color-primary-light-9);
|
||||
color: var(--el-color-primary);
|
||||
font-weight: 500;
|
||||
|
||||
&::before {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 子菜单标题
|
||||
:deep(.el-sub-menu__title) {
|
||||
margin: 2px 8px;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
}
|
||||
|
||||
// 子菜单项:层级缩进 + 字号略小 + 左侧圆点指示,明确层级
|
||||
:deep(.el-menu--inline .el-menu-item) {
|
||||
font-size: 13px;
|
||||
min-width: auto;
|
||||
padding-left: 52px !important;
|
||||
color: var(--el-text-color-secondary);
|
||||
position: relative;
|
||||
|
||||
// 子项左侧圆点指示(区分层级)
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 32px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: var(--el-text-color-placeholder);
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
color: var(--el-color-primary);
|
||||
&::after {
|
||||
background: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// FontAwesome 图标对齐 el-icon 表现(菜单 Icon 字段可能存 FA 类名)
|
||||
@@ -269,12 +504,185 @@ const isFaIcon = (icon: string | undefined | null): boolean => {
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
// 折叠态:图标居中,去除右边距
|
||||
:deep(.el-menu--collapse) .gerp-menu-icon {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 侧边栏底部(版本号 + 设置按钮)
|
||||
.gerp-sidebar-footer {
|
||||
padding: 8px 16px;
|
||||
border-top: 1px solid var(--gerp-border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 40px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.gerp-version {
|
||||
font-size: 11px;
|
||||
color: var(--el-text-color-disabled);
|
||||
}
|
||||
}
|
||||
|
||||
// 主内容区:TabBar + 内容体纵向排列
|
||||
.gerp-content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--gerp-bg-color);
|
||||
}
|
||||
|
||||
// 路由内容体:撑满剩余空间,内部滚动
|
||||
// 多 tab-pane 叠放,active 的撑满并独立滚动,非 active 的 v-show:none 不占空间
|
||||
.gerp-content-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding: 16px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
// 单个 tab 面板:撑满父容器,内部独立滚动(切换不重置滚动位置)
|
||||
// v-show display 切换时播放轻量淡入,增强切换感知(0.12s,不影响速度)
|
||||
.tab-pane {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
animation: tab-pane-fade 0.12s ease;
|
||||
}
|
||||
|
||||
@keyframes tab-pane-fade {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- 用户下拉菜单样式(非 scoped,作用于 el-dropdown 弹层) -->
|
||||
<style lang="scss">
|
||||
/* 下拉菜单整体宽度 */
|
||||
.gerp-user-dropdown {
|
||||
min-width: 260px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 用户信息头部:主色淡背景 + 大头像 */
|
||||
.user-dropdown-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
background: var(--el-color-primary-light-9);
|
||||
|
||||
.user-dropdown-avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
border: 2px solid var(--el-color-primary-light-7);
|
||||
}
|
||||
|
||||
.user-dropdown-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.user-dropdown-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.user-dropdown-email,
|
||||
.user-dropdown-account {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
/* 身份切换区域 */
|
||||
.user-dropdown-roles {
|
||||
padding: 8px 0;
|
||||
|
||||
.user-dropdown-roles-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 20px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.user-dropdown-roles-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.user-dropdown-role-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-regular);
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--el-color-primary-light-9);
|
||||
color: var(--el-color-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.role-check {
|
||||
color: var(--el-color-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.role-placeholder {
|
||||
color: var(--el-text-color-disabled);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.role-loading {
|
||||
margin-left: auto;
|
||||
font-size: 14px;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 退出登录红色高亮 */
|
||||
.gerp-user-dropdown .danger-item {
|
||||
color: var(--el-color-danger);
|
||||
|
||||
.el-icon {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--el-color-danger-light-9);
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -4,6 +4,7 @@ import 'element-plus/dist/index.css'
|
||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||
import VxeUITable from 'vxe-table'
|
||||
import 'vxe-table/lib/style.css'
|
||||
import '@fortawesome/fontawesome-free/css/all.min.css'
|
||||
import { plugin as FormKitPlugin, defaultConfig } from '@formkit/vue'
|
||||
|
||||
import App from './App.vue'
|
||||
@@ -11,6 +12,13 @@ import router from './router'
|
||||
import pinia from './stores'
|
||||
import './styles/global.scss'
|
||||
|
||||
// hash 路由模式兜底:pathname 应恒为 /
|
||||
// 若 pathname 非 /(如旧版 /login?redirect=...#/xxx、书签、或异常跳转残留),
|
||||
// 则修正为 / + 原 hash,避免 path 与 hash 矛盾导致 Vue Router 按 hash 误路由到业务页("直接显示后台")
|
||||
if (window.location.pathname !== '/' && window.location.hash) {
|
||||
window.history.replaceState({}, '', '/' + window.location.hash)
|
||||
}
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
// 注册 Element Plus 图标为全局组件
|
||||
|
||||
+72
-16
@@ -1,5 +1,8 @@
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||
import { nextTick } from 'vue'
|
||||
import { createRouter, createWebHashHistory, type RouteRecordRaw } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { useProgressStore } from '@/stores/progress'
|
||||
import { useTabsStore } from '@/stores/tabs'
|
||||
|
||||
/** 静态路由(无需权限) */
|
||||
export const staticRoutes: RouteRecordRaw[] = [
|
||||
@@ -232,48 +235,88 @@ export const staticRoutes: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/SKU/Sku.vue'),
|
||||
meta: { title: '商品详情', requiresAuth: false },
|
||||
},
|
||||
// === 兜底:未匹配路由在 DefaultLayout 内显示 404(保留 menu/nav/tab) ===
|
||||
// 作为 DefaultLayout 子路由,享有完整框架;requiresAuth: true 使其自动开 tab
|
||||
{
|
||||
path: ':pathMatch(.*)*',
|
||||
name: 'NotFound',
|
||||
component: () => import('@/views/NotFound.vue'),
|
||||
meta: { title: '页面不存在', requiresAuth: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'NotFound',
|
||||
component: () => import('@/views/NotFound.vue'),
|
||||
meta: { title: '页面不存在', requiresAuth: false, layout: 'blank' },
|
||||
},
|
||||
]
|
||||
|
||||
/** 动态路由(从后端菜单加载后注入) */
|
||||
export const dynamicRoutes: RouteRecordRaw[] = []
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
// hash 模式:URL 形如 gerp.gvsds.com/#/dashboard
|
||||
// 适配 frp 内网穿透部署,无需后端 rewrite 配置,避免刷新 404
|
||||
history: createWebHashHistory(),
|
||||
routes: staticRoutes,
|
||||
scrollBehavior(_to, _from, savedPosition) {
|
||||
return savedPosition || { top: 0 }
|
||||
},
|
||||
})
|
||||
|
||||
/** 全局前置守卫:登录态校验、动态路由注入 */
|
||||
/** 标记本次导航是否为首次打开的新 tab(用于 afterEach 区分 loading 策略) */
|
||||
let justOpenedNewTab = false
|
||||
|
||||
/** 全局前置守卫:进度条启动、立即开 tab、登录态校验、动态路由注入 */
|
||||
router.beforeEach(async (to, _from, next) => {
|
||||
const tabsStore = useTabsStore()
|
||||
justOpenedNewTab = false
|
||||
// 立即添加 tab:与 loading 同步出现,不等登录检查或组件加载
|
||||
// addTab 返回 true=新 tab(首次打开,需 loading),false=已存在(已缓存,瞬时切换)
|
||||
if (to.meta.requiresAuth === true && to.meta.title) {
|
||||
justOpenedNewTab = tabsStore.addTab(to)
|
||||
}
|
||||
|
||||
// 设置页面标题
|
||||
document.title = (to.meta.title as string) || 'GERP 管理系统'
|
||||
|
||||
// progress 策略:
|
||||
// - 新 tab 或非 tab 页面(login 等):启动 loading(首次挂载组件 + 加载数据)
|
||||
// - 已缓存 tab:不显示 loading(瞬时 v-show 切换,组件已挂载,零请求)
|
||||
if (justOpenedNewTab || to.meta.requiresAuth !== true) {
|
||||
useProgressStore().start()
|
||||
} else {
|
||||
useProgressStore().done()
|
||||
}
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 登录页:若已处于登录状态(内存态或 Cookie 会话可恢复),跳转面板
|
||||
if (to.name === 'Login') {
|
||||
if (userStore.isLoggedIn) {
|
||||
next({ path: (to.query.redirect as string) || '/dashboard', replace: true })
|
||||
return
|
||||
}
|
||||
// 尝试从 Cookie 会话静默恢复登录态:成功则跳面板,失败则停留登录页
|
||||
if (await userStore.restoreSession()) {
|
||||
next({ path: (to.query.redirect as string) || '/dashboard', replace: true })
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 不需要登录的页面直接放行
|
||||
if (to.meta.requiresAuth === false) {
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
// 已登录但未加载用户信息:尝试加载
|
||||
// 已登录但未加载用户信息:尝试静默恢复会话
|
||||
if (!userStore.isLoggedIn) {
|
||||
try {
|
||||
await userStore.fetchUserInfo()
|
||||
await userStore.fetchMenus()
|
||||
// TODO: 注入动态路由后 next({ ...to, replace: true })
|
||||
} catch {
|
||||
// 加载失败:跳转登录
|
||||
// 使用 restoreSession(silent 模式):session 过期时不弹"登录状态已失效"提示
|
||||
// 直接跳转登录页,避免 login 页面出现错误弹窗
|
||||
if (await userStore.restoreSession()) {
|
||||
// 恢复成功,继续导航
|
||||
} else {
|
||||
// 恢复失败:移除乐观添加的 tab,跳转登录
|
||||
tabsStore.removeTab(to.path)
|
||||
next({ name: 'Login', query: { redirect: to.fullPath } })
|
||||
return
|
||||
}
|
||||
@@ -282,4 +325,17 @@ router.beforeEach(async (to, _from, next) => {
|
||||
next()
|
||||
})
|
||||
|
||||
/** 全局后置钩子:根据新 tab/已缓存 tab 采取不同 loading 收尾策略 */
|
||||
router.afterEach(async () => {
|
||||
// nextTick 确保新组件已挂载到 DOM
|
||||
await nextTick()
|
||||
if (justOpenedNewTab) {
|
||||
// 新 tab:等页面组件 onMounted 主动 done(),500ms 兜底防止遗漏
|
||||
useProgressStore().setFallbackTimeout(500)
|
||||
} else {
|
||||
// 已缓存 tab(瞬时 v-show 切换)或非 tab 页面:立即隐藏 loading
|
||||
useProgressStore().done()
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,79 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
/**
|
||||
* 路由进度条状态
|
||||
*
|
||||
* 自实现轻量顶部进度条,无第三方依赖(替代 NProgress)。
|
||||
* 原理:start() 启动定时器模拟进度递增(上限 80%),done() 置 100% 后淡出。
|
||||
* 配合 components/RouteProgress.vue 渲染固定顶部进度条。
|
||||
*/
|
||||
export const useProgressStore = defineStore('progress', () => {
|
||||
/** 是否显示进度条 */
|
||||
const visible = ref(false)
|
||||
/** 当前进度百分比 0-100 */
|
||||
const percent = ref(0)
|
||||
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
/** 最小显示时间(ms),避免快速加载时 loading 闪都没闪就消失 */
|
||||
const MIN_DISPLAY_TIME = 300
|
||||
let startTime = 0
|
||||
let doneTimer: ReturnType<typeof setTimeout> | null = null
|
||||
/** 超时后备定时器:页面组件未主动 done() 时自动隐藏 */
|
||||
let fallbackTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
/** 开始进度(路由 beforeEach 调用) */
|
||||
function start(): void {
|
||||
// 清除上次可能残留的定时器
|
||||
if (doneTimer) { clearTimeout(doneTimer); doneTimer = null }
|
||||
if (fallbackTimer) { clearTimeout(fallbackTimer); fallbackTimer = null }
|
||||
visible.value = true
|
||||
percent.value = 0
|
||||
startTime = Date.now()
|
||||
if (timer) clearInterval(timer)
|
||||
// 模拟进度递增:80% 上限,接近完成时减速,营造"正在加载"感
|
||||
timer = setInterval(() => {
|
||||
if (percent.value < 80) {
|
||||
percent.value += Math.random() * 10 + 5
|
||||
if (percent.value > 80) percent.value = 80
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
|
||||
/** 完成进度(由页面组件 onMounted 数据加载完成后调用) */
|
||||
function done(): void {
|
||||
if (timer) { clearInterval(timer); timer = null }
|
||||
if (fallbackTimer) { clearTimeout(fallbackTimer); fallbackTimer = null }
|
||||
percent.value = 100
|
||||
|
||||
// 最小显示时间保护:如果加载太快(< 300ms),补齐到 300ms 再隐藏
|
||||
// 确保用户总能看到 loading,感知到页面切换
|
||||
const elapsed = Date.now() - startTime
|
||||
const remaining = Math.max(0, MIN_DISPLAY_TIME - elapsed)
|
||||
|
||||
if (remaining > 0) {
|
||||
doneTimer = setTimeout(() => {
|
||||
visible.value = false
|
||||
percent.value = 0
|
||||
doneTimer = null
|
||||
}, remaining)
|
||||
} else {
|
||||
visible.value = false
|
||||
percent.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置超时后备(路由 afterEach 调用)
|
||||
* 页面组件未主动 done() 时,超时后自动隐藏 loading
|
||||
* 防止某些页面忘记调用 done() 导致 loading 永不消失
|
||||
*/
|
||||
function setFallbackTimeout(ms: number): void {
|
||||
if (fallbackTimer) clearTimeout(fallbackTimer)
|
||||
fallbackTimer = setTimeout(() => {
|
||||
done()
|
||||
}, ms)
|
||||
}
|
||||
|
||||
return { visible, percent, start, done, setFallbackTimeout }
|
||||
})
|
||||
@@ -0,0 +1,146 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import type { RouteLocationNormalized } from 'vue-router'
|
||||
|
||||
/**
|
||||
* 标签页项
|
||||
*
|
||||
* 每个 tab 对应一个已打开的路由页面,path 作为唯一标识。
|
||||
* 详情页带 query 参数时,同一 path 视为同一 tab(刷新 query,不重复开)。
|
||||
*/
|
||||
export interface TabItem {
|
||||
/** 路由路径(唯一标识,不含 query) */
|
||||
path: string
|
||||
/** 路由 name */
|
||||
name: string
|
||||
/** 显示标题(取自 route.meta.title) */
|
||||
title: string
|
||||
/** 图标(Element Plus Icon 名或 FontAwesome 类名) */
|
||||
icon?: string
|
||||
/** 是否可关闭(首页 Dashboard 不可关闭) */
|
||||
closable: boolean
|
||||
/** 路由 query(详情页带参数时保留,用于关闭后恢复) */
|
||||
query: Record<string, string>
|
||||
/** 完整 fullPath(含 query,用于路由跳转恢复) */
|
||||
fullPath: string
|
||||
/** 组件是否已挂载(首次打开后置 true,常驻 DOM 不销毁,切换仅 v-show) */
|
||||
loaded: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 多标签页状态管理
|
||||
*
|
||||
* 对齐原生 GERP 的多 tab 切换能力:
|
||||
* - 点击菜单自动开 tab(重复点击激活已有,不重复开)
|
||||
* - tab 可关闭、可切换、右键菜单(关闭其他/全部关闭/刷新)
|
||||
* - 首页 Dashboard 常驻不可关闭
|
||||
* - 不持久化(刷新后重置为仅首页,避免脏状态)
|
||||
*/
|
||||
export const useTabsStore = defineStore('tabs', () => {
|
||||
/** 首页常驻 tab */
|
||||
const HOME_TAB: TabItem = {
|
||||
path: '/dashboard',
|
||||
name: 'Dashboard',
|
||||
title: '仪表盘',
|
||||
icon: 'Odometer',
|
||||
closable: false,
|
||||
query: {},
|
||||
fullPath: '/dashboard',
|
||||
loaded: true,
|
||||
}
|
||||
|
||||
/** 已打开的标签页列表 */
|
||||
const tabs = ref<TabItem[]>([{ ...HOME_TAB }])
|
||||
/** 当前激活的 tab path */
|
||||
const activePath = ref('/dashboard')
|
||||
|
||||
/** 判断图标是否 FontAwesome 类名(与 DefaultLayout.isFaIcon 一致) */
|
||||
function isFaIcon(icon: string | undefined | null): boolean {
|
||||
if (!icon) return false
|
||||
const t = icon.trim()
|
||||
return t.includes(' ') || t.startsWith('fa-') || /^fa[brsl]?\b/.test(t)
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增/激活 tab(路由 beforeEach 调用)
|
||||
* - 已存在:更新 query/fullPath/title(详情页带不同参数时刷新),返回 false
|
||||
* - 不存在:追加新 tab(loaded=true 立即挂载组件)并激活,返回 true
|
||||
* @returns true=新 tab(首次打开,需 loading);false=已存在(已缓存,瞬时切换)
|
||||
*/
|
||||
function addTab(route: RouteLocationNormalized): boolean {
|
||||
const title = (route.meta.title as string) || (route.name as string) || route.path
|
||||
const icon = route.meta.icon as string | undefined
|
||||
const path = route.path
|
||||
const fullPath = route.fullPath
|
||||
|
||||
// 提取 string 类型的 query
|
||||
const query: Record<string, string> = {}
|
||||
for (const k of Object.keys(route.query)) {
|
||||
const v = route.query[k]
|
||||
if (typeof v === 'string') query[k] = v
|
||||
}
|
||||
|
||||
const existing = tabs.value.find((t) => t.path === path)
|
||||
if (existing) {
|
||||
existing.query = query
|
||||
existing.fullPath = fullPath
|
||||
existing.title = title
|
||||
activePath.value = path
|
||||
return false
|
||||
}
|
||||
tabs.value.push({
|
||||
path,
|
||||
name: String(route.name || ''),
|
||||
title,
|
||||
icon,
|
||||
closable: path !== '/dashboard',
|
||||
query,
|
||||
fullPath,
|
||||
loaded: true,
|
||||
})
|
||||
activePath.value = path
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭指定 tab
|
||||
* @returns 若关闭的是当前激活 tab,返回应跳转的 fullPath;否则返回 null
|
||||
*/
|
||||
function removeTab(path: string): string | null {
|
||||
const idx = tabs.value.findIndex((t) => t.path === path)
|
||||
if (idx === -1) return null
|
||||
if (!tabs.value[idx].closable) return null // 不可关闭
|
||||
|
||||
tabs.value.splice(idx, 1)
|
||||
|
||||
// 若关闭的是当前激活 tab,激活相邻 tab
|
||||
if (activePath.value === path) {
|
||||
const next = tabs.value[idx] || tabs.value[idx - 1] || tabs.value[tabs.value.length - 1]
|
||||
if (next) {
|
||||
activePath.value = next.path
|
||||
return next.fullPath
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** 关闭其他所有可关闭 tab(保留首页与当前) */
|
||||
function removeOthers(path: string): void {
|
||||
tabs.value = tabs.value.filter((t) => !t.closable || t.path === path)
|
||||
activePath.value = path
|
||||
}
|
||||
|
||||
/** 关闭所有可关闭 tab(仅保留首页) */
|
||||
function removeAll(): void {
|
||||
tabs.value = tabs.value.filter((t) => !t.closable)
|
||||
activePath.value = '/dashboard'
|
||||
}
|
||||
|
||||
/** 重置(登出时调用) */
|
||||
function reset(): void {
|
||||
tabs.value = [{ ...HOME_TAB }]
|
||||
activePath.value = '/dashboard'
|
||||
}
|
||||
|
||||
return { tabs, activePath, addTab, removeTab, removeOthers, removeAll, reset, isFaIcon }
|
||||
})
|
||||
+75
-2
@@ -1,7 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { UserInfo, MenuItem } from '@/types/api'
|
||||
import { getCurrentUser, getUserRoles, login as apiLogin, logout as apiLogout, type LoginParams } from '@/api/auth'
|
||||
import { getCurrentUser, getUserRoles, switchRole as apiSwitchRole, login as apiLogin, logout as apiLogout, type LoginParams } from '@/api/auth'
|
||||
import { getMenuList } from '@/api/menu'
|
||||
|
||||
/** 用户状态管理:当前登录用户、角色、菜单、权限 */
|
||||
@@ -13,6 +13,41 @@ export const useUserStore = defineStore('user', () => {
|
||||
const token = ref<string>('')
|
||||
const loading = ref(false)
|
||||
|
||||
// === localStorage 持久化缓存 ===
|
||||
// 目的:刷新页面后从缓存恢复 userInfo/menus,避免重复请求 GetELTUserInfo/MenuList
|
||||
// 复用 gerp-user key(reset/handleLoginExpired 已删除此 key,形成写入→清除闭环)
|
||||
const CACHE_KEY = 'gerp-user'
|
||||
|
||||
/** 从 localStorage 恢复缓存到内存(刷新后内存丢失时调用) */
|
||||
function loadFromCache(): void {
|
||||
try {
|
||||
const raw = localStorage.getItem(CACHE_KEY)
|
||||
if (!raw) return
|
||||
const cached = JSON.parse(raw) as { userInfo?: UserInfo | null; menus?: MenuItem[] | null }
|
||||
if (cached.userInfo) userInfo.value = cached.userInfo
|
||||
if (cached.menus) menus.value = cached.menus
|
||||
} catch {
|
||||
// 缓存损坏:清除避免反复解析失败
|
||||
localStorage.removeItem(CACHE_KEY)
|
||||
}
|
||||
}
|
||||
|
||||
/** 将内存态写入 localStorage(fetchUserInfo/fetchMenus 成功后调用) */
|
||||
function saveToCache(): void {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
CACHE_KEY,
|
||||
JSON.stringify({ userInfo: userInfo.value, menus: menus.value }),
|
||||
)
|
||||
} catch {
|
||||
// 写入失败(隐私模式/配额满):忽略,不影响功能
|
||||
}
|
||||
}
|
||||
|
||||
// store 初始化时立即从缓存恢复:刷新后 isLoggedIn 立即为 true,
|
||||
// router.beforeEach 不再触发 restoreSession,实现刷新零请求
|
||||
loadFromCache()
|
||||
|
||||
// === 计算属性 ===
|
||||
const isLoggedIn = computed(() => !!userInfo.value)
|
||||
const isAdmin = computed(() => userInfo.value?.IsAdmin === true)
|
||||
@@ -59,9 +94,36 @@ export const useUserStore = defineStore('user', () => {
|
||||
async function fetchUserInfo(): Promise<UserInfo> {
|
||||
const info = await getCurrentUser()
|
||||
userInfo.value = info
|
||||
saveToCache()
|
||||
return info
|
||||
}
|
||||
|
||||
/**
|
||||
* 静默恢复会话(路由守卫用):成功则填充用户信息,失败不弹全局提示
|
||||
* 优先级:内存态 > localStorage 缓存 > 后端请求
|
||||
* - 内存已有 userInfo:直接返回(同会话内已恢复过,零请求)
|
||||
* - 缓存命中:从 localStorage 恢复(刷新后首次,零请求)
|
||||
* - 都没有:请求后端 GetELTUserInfo + MenuList(首次登录或缓存被清)
|
||||
* 会话过期兜底:缓存命中后,首个业务请求 401 会触发 handleLoginExpired 清缓存并跳登录
|
||||
*/
|
||||
async function restoreSession(): Promise<boolean> {
|
||||
// 内存已有:直接返回
|
||||
if (userInfo.value) return true
|
||||
// 尝试从缓存恢复
|
||||
loadFromCache()
|
||||
if (userInfo.value) return true
|
||||
// 缓存也没有:请求后端
|
||||
try {
|
||||
const info = await getCurrentUser(true)
|
||||
userInfo.value = info
|
||||
await fetchMenus()
|
||||
saveToCache()
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取角色列表 */
|
||||
async function fetchRoles(): Promise<string[]> {
|
||||
const list = await getUserRoles()
|
||||
@@ -69,6 +131,14 @@ export const useUserStore = defineStore('user', () => {
|
||||
return list
|
||||
}
|
||||
|
||||
/** 切换当前角色(身份切换) */
|
||||
async function switchRole(role: string): Promise<void> {
|
||||
await apiSwitchRole(role)
|
||||
// 刷新用户信息和菜单(不同角色可能有不同菜单权限)
|
||||
await fetchUserInfo()
|
||||
await fetchMenus()
|
||||
}
|
||||
|
||||
/** 将后端 Page(如 pages/workorder/admin.html)规范化为 Vue 路由(/workorder/admin) */
|
||||
function normalizeMenuPath(page: string | undefined): string {
|
||||
if (!page) return ''
|
||||
@@ -96,6 +166,7 @@ export const useUserStore = defineStore('user', () => {
|
||||
const list = await getMenuList()
|
||||
const normalized = normalizeMenuTree(list)
|
||||
menus.value = normalized
|
||||
saveToCache()
|
||||
return normalized
|
||||
}
|
||||
|
||||
@@ -105,7 +176,7 @@ export const useUserStore = defineStore('user', () => {
|
||||
roles.value = []
|
||||
menus.value = []
|
||||
token.value = ''
|
||||
localStorage.removeItem('gerp-user')
|
||||
localStorage.removeItem(CACHE_KEY)
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -128,6 +199,8 @@ export const useUserStore = defineStore('user', () => {
|
||||
fetchUserInfo,
|
||||
fetchRoles,
|
||||
fetchMenus,
|
||||
switchRole,
|
||||
restoreSession,
|
||||
reset,
|
||||
}
|
||||
})
|
||||
+9
-9
@@ -11,18 +11,18 @@ const userStore = useUserStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const form = reactive<LoginParams>({
|
||||
Username: '',
|
||||
Password: '',
|
||||
UserName: '',
|
||||
UserPassword: '',
|
||||
RememberMe: false,
|
||||
})
|
||||
|
||||
const rules = {
|
||||
Username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||
Password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
|
||||
UserName: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||
UserPassword: [{ required: true, message: '请输入密码', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
async function handleLogin(): Promise<void> {
|
||||
if (!form.Username || !form.Password) {
|
||||
if (!form.UserName || !form.UserPassword) {
|
||||
ElMessage.warning('请输入用户名和密码')
|
||||
return
|
||||
}
|
||||
@@ -49,13 +49,13 @@ async function handleLogin(): Promise<void> {
|
||||
</div>
|
||||
|
||||
<el-form :model="form" :rules="rules" label-position="top" size="large" @submit.prevent="handleLogin">
|
||||
<el-form-item label="用户名" prop="Username">
|
||||
<el-input v-model="form.Username" placeholder="请输入用户名" :prefix-icon="'User'" clearable />
|
||||
<el-form-item label="用户名" prop="UserName">
|
||||
<el-input v-model="form.UserName" placeholder="请输入用户名" :prefix-icon="'User'" clearable />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="密码" prop="Password">
|
||||
<el-form-item label="密码" prop="UserPassword">
|
||||
<el-input
|
||||
v-model="form.Password"
|
||||
v-model="form.UserPassword"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
:prefix-icon="'Lock'"
|
||||
|
||||
@@ -60,6 +60,7 @@ function handleView(row: CompanyRow): void {
|
||||
pk-field="CompanyUUID"
|
||||
searchable
|
||||
pageable
|
||||
stretch
|
||||
selectable
|
||||
:on-view="handleView"
|
||||
/>
|
||||
|
||||
@@ -60,6 +60,7 @@ function handleView(row: DepartmentRow): void {
|
||||
pk-field="DeptUUID"
|
||||
searchable
|
||||
pageable
|
||||
stretch
|
||||
selectable
|
||||
:on-view="handleView"
|
||||
/>
|
||||
|
||||
@@ -60,6 +60,7 @@ function handleView(row: RoleRow): void {
|
||||
pk-field="RoleUUID"
|
||||
searchable
|
||||
pageable
|
||||
stretch
|
||||
selectable
|
||||
:on-view="handleView"
|
||||
/>
|
||||
|
||||
@@ -55,6 +55,7 @@ function handleView(row: TenantRow): void {
|
||||
pk-field="TenantUUID"
|
||||
searchable
|
||||
pageable
|
||||
stretch
|
||||
selectable
|
||||
:on-view="handleView"
|
||||
/>
|
||||
|
||||
@@ -56,6 +56,7 @@ function handleView(row: WorkgroupRow): void {
|
||||
pk-field="WorkgroupUUID"
|
||||
searchable
|
||||
pageable
|
||||
stretch
|
||||
selectable
|
||||
:on-view="handleView"
|
||||
/>
|
||||
|
||||
@@ -59,6 +59,7 @@ function handleView(row: TemplateRow): void {
|
||||
pk-field="TemplateUUID"
|
||||
searchable
|
||||
pageable
|
||||
stretch
|
||||
:selectable="isAdmin"
|
||||
:on-view="handleView"
|
||||
/>
|
||||
|
||||
@@ -59,6 +59,7 @@ function handleView(row: OrderRow): void {
|
||||
pk-field="OrderUUID"
|
||||
searchable
|
||||
pageable
|
||||
stretch
|
||||
selectable
|
||||
:on-view="handleView"
|
||||
/>
|
||||
|
||||
@@ -114,6 +114,7 @@ onMounted(() => {
|
||||
searchable
|
||||
pageable
|
||||
selectable
|
||||
stretch
|
||||
/>
|
||||
|
||||
<!--
|
||||
|
||||
@@ -43,6 +43,7 @@ function handleView(row: RoleRow): void {
|
||||
pk-field="RoleUUID"
|
||||
searchable
|
||||
pageable
|
||||
stretch
|
||||
selectable
|
||||
:on-view="handleView"
|
||||
/>
|
||||
|
||||
@@ -81,6 +81,7 @@ function handleView(row: UserRow): void {
|
||||
pk-field="UserUUID"
|
||||
searchable
|
||||
pageable
|
||||
stretch
|
||||
:selectable="!isMember"
|
||||
:on-view="handleView"
|
||||
/>
|
||||
|
||||
@@ -16,7 +16,7 @@ setPageTitle('工单管理')
|
||||
|
||||
<template>
|
||||
<div class="wo-admin-page">
|
||||
<CrudTable table="WorkOrder" title="工单管理" searchable pageable selectable />
|
||||
<CrudTable table="WorkOrder" title="工单管理" searchable pageable selectable stretch />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ const query = computed(() => {
|
||||
|
||||
<template>
|
||||
<div class="wo-my-page">
|
||||
<CrudTable table="WorkOrder" title="我的工单" :query="query" searchable pageable :selectable="false" />
|
||||
<CrudTable table="WorkOrder" title="我的工单" :query="query" searchable pageable stretch :selectable="false" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
Reference in new issue
Block a user