修复尝试生产中的大量问题

This commit is contained in:
TermiNexus committed 2026-08-02 03:09:53 +08:00
1 parent dbf30a9bc0
commit e8ceb5b46b
29 files changed
+1377 -59

No files matched your search

+105
View File
@@ -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
+10
View File
@@ -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",
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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
+39 -4
View File
@@ -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;
+65
View File
@@ -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>
+44
View File
@@ -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>
+243
View File
@@ -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>
+10 -2
View File
@@ -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
View File
@@ -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>
+8
View File
@@ -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 图标为全局组件
+70 -14
View File
@@ -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,13 +235,15 @@ 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(.*)*',
path: ':pathMatch(.*)*',
name: 'NotFound',
component: () => import('@/views/NotFound.vue'),
meta: { title: '页面不存在', requiresAuth: false, layout: 'blank' },
meta: { title: '页面不存在', requiresAuth: true },
},
],
},
]
@@ -246,34 +251,72 @@ export const staticRoutes: RouteRecordRaw[] = [
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
+79
View File
@@ -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 }
})
+146
View File
@@ -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
View File
@@ -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
View File
@@ -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'"
+1
View File
@@ -60,6 +60,7 @@ function handleView(row: CompanyRow): void {
pk-field="CompanyUUID"
searchable
pageable
stretch
selectable
:on-view="handleView"
/>
+1
View File
@@ -60,6 +60,7 @@ function handleView(row: DepartmentRow): void {
pk-field="DeptUUID"
searchable
pageable
stretch
selectable
:on-view="handleView"
/>
+1
View File
@@ -60,6 +60,7 @@ function handleView(row: RoleRow): void {
pk-field="RoleUUID"
searchable
pageable
stretch
selectable
:on-view="handleView"
/>
+1
View File
@@ -55,6 +55,7 @@ function handleView(row: TenantRow): void {
pk-field="TenantUUID"
searchable
pageable
stretch
selectable
:on-view="handleView"
/>
+1
View File
@@ -56,6 +56,7 @@ function handleView(row: WorkgroupRow): void {
pk-field="WorkgroupUUID"
searchable
pageable
stretch
selectable
:on-view="handleView"
/>
+1
View File
@@ -59,6 +59,7 @@ function handleView(row: TemplateRow): void {
pk-field="TemplateUUID"
searchable
pageable
stretch
:selectable="isAdmin"
:on-view="handleView"
/>
+1
View File
@@ -59,6 +59,7 @@ function handleView(row: OrderRow): void {
pk-field="OrderUUID"
searchable
pageable
stretch
selectable
:on-view="handleView"
/>
+1
View File
@@ -114,6 +114,7 @@ onMounted(() => {
searchable
pageable
selectable
stretch
/>
<!--
+1
View File
@@ -43,6 +43,7 @@ function handleView(row: RoleRow): void {
pk-field="RoleUUID"
searchable
pageable
stretch
selectable
:on-view="handleView"
/>
+1
View File
@@ -81,6 +81,7 @@ function handleView(row: UserRow): void {
pk-field="UserUUID"
searchable
pageable
stretch
:selectable="!isMember"
:on-view="handleView"
/>
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -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>