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

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

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": { "dependencies": {
"@element-plus/icons-vue": "^2.3.2", "@element-plus/icons-vue": "^2.3.2",
"@formkit/vue": "^2.1.2", "@formkit/vue": "^2.1.2",
"@fortawesome/fontawesome-free": "^7.3.1",
"@vxe-ui/core": "^4.4.19", "@vxe-ui/core": "^4.4.19",
"@vxe-ui/plugin-render-element": "^4.4.1", "@vxe-ui/plugin-render-element": "^4.4.1",
"axios": "^1.19.0", "axios": "^1.19.0",
@@ -328,6 +329,15 @@
"vue": "^3.4.0" "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": { "node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13", "version": "0.3.13",
"resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+1
View File
@@ -11,6 +11,7 @@
"dependencies": { "dependencies": {
"@element-plus/icons-vue": "^2.3.2", "@element-plus/icons-vue": "^2.3.2",
"@formkit/vue": "^2.1.2", "@formkit/vue": "^2.1.2",
"@fortawesome/fontawesome-free": "^7.3.1",
"@vxe-ui/core": "^4.4.19", "@vxe-ui/core": "^4.4.19",
"@vxe-ui/plugin-render-element": "^4.4.1", "@vxe-ui/plugin-render-element": "^4.4.1",
"axios": "^1.19.0", "axios": "^1.19.0",
+7 -7
View File
@@ -1,11 +1,11 @@
import { get, post } from './request' import { get, post } from './request'
import type { UserInfo } from '@/types/api' import type { UserInfo } from '@/types/api'
/** 登录参数 */ /** 登录参数(对齐后端 LoginSerializer 字段:UserName / UserPassword) */
export interface LoginParams { export interface LoginParams {
Username: string UserName: string
Password: string UserPassword: string
/** 是否记住我(延长 Token 有效期) */ /** 是否记住我(延长 Token 有效期,后端当前忽略该字段) */
RememberMe?: boolean RememberMe?: boolean
/** 2FA 验证码(开启二步验证时必填) */ /** 2FA 验证码(开启二步验证时必填) */
TwoFactorCode?: string TwoFactorCode?: string
@@ -46,9 +46,9 @@ export const switchRole = (role: string): Promise<void> => {
return post<void>('/Auth/SwitchRole', { Role: role }) return post<void>('/Auth/SwitchRole', { Role: role })
} }
/** 获取当前登录用户信息 */ /** 获取当前登录用户信息(silent=true 时失败不弹全局提示,用于登录页会话恢复) */
export const getCurrentUser = (): Promise<UserInfo> => { export const getCurrentUser = (silent = false): Promise<UserInfo> => {
return get<UserInfo>('/Query/GetELTUserInfo') return get<UserInfo>('/Query/GetELTUserInfo', undefined, silent ? { silent: true } : undefined)
} }
/** 获取用户头像 URL(直接作为图片 src 使用) */ /** 获取用户头像 URL(直接作为图片 src 使用) */
+32 -5
View File
@@ -7,10 +7,18 @@ import axios, {
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import type { ApiResult } from '@/types/api' import type { ApiResult } from '@/types/api'
import { getServerScopeParams } from '@/utils/serverScope' import { getServerScopeParams } from '@/utils/serverScope'
import router from '@/router'
/** 后端响应统一格式 */ /** 后端响应统一格式 */
type BackendResponse<T> = AxiosResponse<ApiResult<T>> type BackendResponse<T> = AxiosResponse<ApiResult<T>>
/** 扩展请求配置:silent=true 时失败不弹全局提示(用于登录页会话静默恢复等场景) */
declare module 'axios' {
export interface AxiosRequestConfig {
silent?: boolean
}
}
/** 业务错误码:登录失效 */ /** 业务错误码:登录失效 */
const CODE_UNAUTHORIZED = 401 const CODE_UNAUTHORIZED = 401
/** 业务错误码:成功 */ /** 业务错误码:成功 */
@@ -60,7 +68,11 @@ request.interceptors.response.use(
} }
if (result.code === CODE_SUCCESS) { 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')) return Promise.reject(new Error(result.msg || 'Business Error'))
}, },
(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() handleLoginExpired()
} else { } else {
const msg = error?.response?.data?.msg || error?.message || '网络错误' const msg = error?.response?.data?.msg || error?.message || '网络错误'
@@ -81,6 +103,9 @@ request.interceptors.response.use(
/** 登录失效处理:弹窗提示并跳转登录页 */ /** 登录失效处理:弹窗提示并跳转登录页 */
function handleLoginExpired(): void { function handleLoginExpired(): void {
if (isLoginExpiredShown) return if (isLoginExpiredShown) return
// 已在登录页则不再跳转,避免 redirect 参数嵌套编码
// (login 页 restoreSession 等 API 若再次 401,会重复触发本函数,导致 fullPath 含旧 redirect 被再次编码)
if (router.currentRoute.value.name === 'Login') return
isLoginExpiredShown = true isLoginExpiredShown = true
ElMessageBox.alert('登录状态已失效,请重新登录', '提示', { ElMessageBox.alert('登录状态已失效,请重新登录', '提示', {
type: 'warning', type: 'warning',
@@ -90,9 +115,11 @@ function handleLoginExpired(): void {
.then(() => { .then(() => {
// 清除本地用户状态 // 清除本地用户状态
localStorage.removeItem('gerp-user') localStorage.removeItem('gerp-user')
// 跳转到登录页(保留原 URL 用于登录后回跳) // 使用 Vue Router 跳转登录页
const redirect = encodeURIComponent(window.location.pathname + window.location.search) // hash 路由模式下 window.location.href 会保留 #/path,导致 Vue Router 按 hash 误路由到业务页("直接显示后台")
window.location.href = `/login?redirect=${redirect}` // 改用 router.push 走标准导航,redirect 取当前完整路径(含 hash)用于登录后回跳
const redirect = router.currentRoute.value.fullPath
router.push({ name: 'Login', query: { redirect } })
}) })
.finally(() => { .finally(() => {
isLoginExpiredShown = false isLoginExpiredShown = false
+39 -4
View File
@@ -30,6 +30,12 @@ const props = defineProps<{
onView?: (row: T) => void onView?: (row: T) => void
/** 行点击回调 */ /** 行点击回调 */
onRowClick?: (row: T) => void onRowClick?: (row: T) => void
/**
* 是否撑满父容器(整页表格场景)。
* 开启时表格 height=100% + 容器 flex 撑满,产生内部滚动;
* 关闭时表格自适应内容高度(详情页嵌套场景,随页面滚动)。
*/
stretch?: boolean
}>() }>()
const options: UseCrudTableOptions<T> = { const options: UseCrudTableOptions<T> = {
@@ -118,7 +124,7 @@ function renderUuid(value: unknown): string {
</script> </script>
<template> <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-card v-if="searchable" shadow="never" class="search-bar">
<el-input <el-input
@@ -161,11 +167,11 @@ function renderUuid(value: unknown): string {
<vxe-table <vxe-table
ref="xTableRef" ref="xTableRef"
:data="dataList" :data="dataList"
:loading="loading" v-loading="loading"
border border
stripe stripe
round round
height="auto" :height="props.stretch ? '100%' : 'auto'"
:row-config="{ isHover: true }" :row-config="{ isHover: true }"
:column-config="{ resizable: true }" :column-config="{ resizable: true }"
:checkbox-config="{ range: true }" :checkbox-config="{ range: true }"
@@ -178,13 +184,14 @@ function renderUuid(value: unknown): string {
<vxe-column type="seq" width="60" title="#" /> <vxe-column type="seq" width="60" title="#" />
<!-- 数据列:根据 type/statusMap/entityPicker 分支渲染 --> <!-- 数据列:根据 type/statusMap/entityPicker 分支渲染 -->
<!-- 列宽对齐原生 TableEngine:无 width 时弹性填充,minWidth 默认 200 -->
<vxe-column <vxe-column
v-for="col in tableColumns" v-for="col in tableColumns"
:key="col.field" :key="col.field"
:field="col.field" :field="col.field"
:title="col.title" :title="col.title"
:width="col.width" :width="col.width"
:min-width="col.minWidth" :min-width="col.minWidth ?? 200"
:fixed="col.fixed" :fixed="col.fixed"
:sortable="col.sortable" :sortable="col.sortable"
> >
@@ -363,6 +370,34 @@ function renderUuid(value: unknown): string {
gap: 12px; 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 { .search-bar {
:deep(.el-card__body) { :deep(.el-card__body) {
display: flex; 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 { ref, reactive, onMounted, type Ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { formApi, type TableName, type QueryParams } from '@/api/form' import { formApi, type TableName, type QueryParams } from '@/api/form'
import { useProgressStore } from '@/stores/progress'
import type { ColumnSchema, PagedData } from '@/types/api' import type { ColumnSchema, PagedData } from '@/types/api'
/** CRUD 表格 composable 选项 */ /** 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 () => { onMounted(async () => {
await fetchColumns() // 并行加载列定义与数据,减少页面首屏等待(串行需两次网络往返)
await fetchData() try {
await Promise.all([fetchColumns(), fetchData()])
} finally {
// 数据加载完成(或失败),隐藏路由切换 loading
useProgressStore().done()
}
}) })
return { return {
+422 -14
View File
@@ -1,12 +1,27 @@
<script setup lang="ts"> <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 { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import { useTabsStore } from '@/stores/tabs'
import { getAvatarUrl } from '@/api/auth' 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 appStore = useAppStore()
const userStore = useUserStore() const userStore = useUserStore()
const tabsStore = useTabsStore()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -19,15 +34,67 @@ const activeMenu = computed(() => route.path)
// 头像 URL // 头像 URL
const avatarUrl = getAvatarUrl() 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(() => { onMounted(() => {
appStore.initTheme() 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 { function handleMenuSelect(index: string): void {
router.push(index) 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> { async function handleLogout(): Promise<void> {
tabsStore.reset()
await userStore.logout() await userStore.logout()
router.push('/login') router.push('/login')
} }
@@ -40,6 +107,16 @@ function handleToggleTheme(): void {
appStore.toggleTheme() 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 组件名区分 */ /** 判断图标是否为 FontAwesome 类名(如 'fas fa-chart-line'),与 Element Plus 组件名区分 */
const isFaIcon = (icon: string | undefined | null): boolean => { const isFaIcon = (icon: string | undefined | null): boolean => {
if (!icon) return false if (!icon) return false
@@ -51,6 +128,9 @@ const isFaIcon = (icon: string | undefined | null): boolean => {
<template> <template>
<div class="gerp-layout"> <div class="gerp-layout">
<!-- 路由切换进度条 -->
<RouteProgress />
<!-- 顶部导航 --> <!-- 顶部导航 -->
<header class="gerp-header"> <header class="gerp-header">
<div class="gerp-header-left"> <div class="gerp-header-left">
@@ -77,23 +157,62 @@ const isFaIcon = (icon: string | undefined | null): boolean => {
<el-icon><Bell /></el-icon> <el-icon><Bell /></el-icon>
</el-button> </el-button>
</el-badge> </el-badge>
<el-button text circle title="主题" @click="handleToggleTheme"> <el-button text circle title="主题切换" @click="handleToggleTheme">
<el-icon><Setting /></el-icon> <el-icon><Brush /></el-icon>
</el-button> </el-button>
</div> </div>
<el-dropdown trigger="click" @command="(cmd: string) => cmd === 'logout' && handleLogout()"> <el-dropdown trigger="click" @command="handleUserCommand">
<div class="gerp-user-info"> <div class="gerp-user-info">
<img :src="avatarUrl" alt="头像" class="gerp-user-avatar" /> <img :src="avatarUrl" alt="头像" class="gerp-user-avatar" />
<span class="gerp-user-name">{{ userStore.displayName }}</span> <span class="gerp-user-name">{{ userStore.displayName }}</span>
<el-icon><ArrowDown /></el-icon> <el-icon><ArrowDown /></el-icon>
</div> </div>
<template #dropdown> <template #dropdown>
<el-dropdown-menu> <el-dropdown-menu class="gerp-user-dropdown">
<el-dropdown-item @click="router.push('/profile')"> <!-- 用户信息头部 -->
<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-icon><User /></el-icon>个人中心
</el-dropdown-item> </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-icon><SwitchButton /></el-icon>退出登录
</el-dropdown-item> </el-dropdown-item>
</el-dropdown-menu> </el-dropdown-menu>
@@ -109,7 +228,7 @@ const isFaIcon = (icon: string | undefined | null): boolean => {
<el-menu <el-menu
:default-active="activeMenu" :default-active="activeMenu"
:collapse="appStore.sidebarCollapsed" :collapse="appStore.sidebarCollapsed"
:collapse-transition="false" :collapse-transition="true"
router router
@select="handleMenuSelect" @select="handleMenuSelect"
> >
@@ -141,13 +260,41 @@ const isFaIcon = (icon: string | undefined | null): boolean => {
</template> </template>
</el-menu> </el-menu>
</el-scrollbar> </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> </aside>
<!-- 主内容区 --> <!-- 主内容区 -->
<main class="gerp-content"> <main class="gerp-content">
<RouterView v-slot="{ Component }"> <!-- 多标签页栏 -->
<component :is="Component" /> <TabBar />
</RouterView> <!--
路由内容体: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> </main>
</div> </div>
</div> </div>
@@ -249,15 +396,103 @@ const isFaIcon = (icon: string | undefined | null): boolean => {
width: 220px; width: 220px;
background: #fff; background: #fff;
border-right: 1px solid var(--gerp-border-color); 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; overflow: hidden;
display: flex;
flex-direction: column;
&.collapsed { &.collapsed {
width: 64px; width: 64px;
} }
:deep(.el-scrollbar) {
flex: 1;
}
:deep(.el-menu) { :deep(.el-menu) {
border-right: none; 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 类名) // FontAwesome 图标对齐 el-icon 表现(菜单 Icon 字段可能存 FA 类名)
@@ -269,12 +504,185 @@ const isFaIcon = (icon: string | undefined | null): boolean => {
text-align: center; text-align: center;
vertical-align: middle; 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 { .gerp-content {
flex: 1; flex: 1;
overflow: auto; overflow: hidden;
padding: 16px; display: flex;
flex-direction: column;
background: var(--gerp-bg-color); 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> </style>
+8
View File
@@ -4,6 +4,7 @@ import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue' import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import VxeUITable from 'vxe-table' import VxeUITable from 'vxe-table'
import 'vxe-table/lib/style.css' import 'vxe-table/lib/style.css'
import '@fortawesome/fontawesome-free/css/all.min.css'
import { plugin as FormKitPlugin, defaultConfig } from '@formkit/vue' import { plugin as FormKitPlugin, defaultConfig } from '@formkit/vue'
import App from './App.vue' import App from './App.vue'
@@ -11,6 +12,13 @@ import router from './router'
import pinia from './stores' import pinia from './stores'
import './styles/global.scss' 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) const app = createApp(App)
// 注册 Element Plus 图标为全局组件 // 注册 Element Plus 图标为全局组件
+341 -285
View File
@@ -1,285 +1,341 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router' import { nextTick } from 'vue'
import { useUserStore } from '@/stores/user' import { createRouter, createWebHashHistory, type RouteRecordRaw } from 'vue-router'
import { useUserStore } from '@/stores/user'
/** 静态路由(无需权限) */ import { useProgressStore } from '@/stores/progress'
export const staticRoutes: RouteRecordRaw[] = [ import { useTabsStore } from '@/stores/tabs'
{
path: '/login', /** 静态路由(无需权限) */
name: 'Login', export const staticRoutes: RouteRecordRaw[] = [
component: () => import('@/views/Login.vue'), {
meta: { title: '登录', requiresAuth: false, layout: 'blank' }, path: '/login',
}, name: 'Login',
{ component: () => import('@/views/Login.vue'),
path: '/', meta: { title: '登录', requiresAuth: false, layout: 'blank' },
component: () => import('@/layouts/DefaultLayout.vue'), },
redirect: '/dashboard', {
children: [ path: '/',
{ component: () => import('@/layouts/DefaultLayout.vue'),
path: 'dashboard', redirect: '/dashboard',
name: 'Dashboard', children: [
component: () => import('@/views/Dashboard.vue'), {
meta: { title: '仪表盘', icon: 'Odometer', requiresAuth: true }, path: 'dashboard',
}, name: 'Dashboard',
{ component: () => import('@/views/Dashboard.vue'),
path: 'profile', meta: { title: '仪表盘', icon: 'Odometer', requiresAuth: true },
name: 'Profile', },
component: () => import('@/views/Profile.vue'), {
meta: { title: '个人中心', icon: 'User', requiresAuth: true }, path: 'profile',
}, name: 'Profile',
// === 阶段 1:CrudTable 落地验证 === component: () => import('@/views/Profile.vue'),
{ meta: { title: '个人中心', icon: 'User', requiresAuth: true },
path: 'workorder/admin', },
name: 'WorkorderAdmin', // === 阶段 1:CrudTable 落地验证 ===
component: () => import('@/views/workorder/Admin.vue'), {
meta: { title: '工单管理', icon: 'Tickets', requiresAuth: true }, path: 'workorder/admin',
}, name: 'WorkorderAdmin',
{ component: () => import('@/views/workorder/Admin.vue'),
path: 'workorder/my', meta: { title: '工单管理', icon: 'Tickets', requiresAuth: true },
name: 'WorkorderMy', },
component: () => import('@/views/workorder/My.vue'), {
meta: { title: '我的工单', icon: 'Document', requiresAuth: true }, path: 'workorder/my',
}, name: 'WorkorderMy',
{ component: () => import('@/views/workorder/My.vue'),
path: 'system/roles', meta: { title: '我的工单', icon: 'Document', requiresAuth: true },
name: 'SystemRoles', },
component: () => import('@/views/system/Roles.vue'), {
meta: { title: '角色管理', icon: 'UserFilled', requiresAuth: true }, path: 'system/roles',
}, name: 'SystemRoles',
{ component: () => import('@/views/system/Roles.vue'),
path: 'system/users', meta: { title: '角色管理', icon: 'UserFilled', requiresAuth: true },
name: 'SystemUsers', },
component: () => import('@/views/system/Users.vue'), {
meta: { title: '用户管理', icon: 'User', requiresAuth: true }, path: 'system/users',
}, name: 'SystemUsers',
{ component: () => import('@/views/system/Users.vue'),
path: 'inventory/products', meta: { title: '用户管理', icon: 'User', requiresAuth: true },
name: 'InventoryProducts', },
component: () => import('@/views/inventory/Products.vue'), {
meta: { title: '产品管理', icon: 'Goods', requiresAuth: true }, path: 'inventory/products',
}, name: 'InventoryProducts',
{ component: () => import('@/views/inventory/Products.vue'),
path: 'sales/orders', meta: { title: '产品管理', icon: 'Goods', requiresAuth: true },
name: 'SalesOrders', },
component: () => import('@/views/sales/Orders.vue'), {
meta: { title: '销售订单', icon: 'ShoppingCart', requiresAuth: true }, path: 'sales/orders',
}, name: 'SalesOrders',
// === 阶段 2:Company 列表页 === component: () => import('@/views/sales/Orders.vue'),
{ meta: { title: '销售订单', icon: 'ShoppingCart', requiresAuth: true },
path: 'company/tenant', },
name: 'CompanyTenant', // === 阶段 2:Company 列表页 ===
component: () => import('@/views/company/Tenant.vue'), {
meta: { title: '租户管理', icon: 'OfficeBuilding', requiresAuth: true }, path: 'company/tenant',
}, name: 'CompanyTenant',
{ component: () => import('@/views/company/Tenant.vue'),
path: 'company/company', meta: { title: '租户管理', icon: 'OfficeBuilding', requiresAuth: true },
name: 'CompanyCompany', },
component: () => import('@/views/company/Company.vue'), {
meta: { title: '公司管理', icon: 'Management', requiresAuth: true }, path: 'company/company',
}, name: 'CompanyCompany',
{ component: () => import('@/views/company/Company.vue'),
path: 'company/department', meta: { title: '公司管理', icon: 'Management', requiresAuth: true },
name: 'CompanyDepartment', },
component: () => import('@/views/company/Department.vue'), {
meta: { title: '部门管理', icon: 'Share', requiresAuth: true }, path: 'company/department',
}, name: 'CompanyDepartment',
{ component: () => import('@/views/company/Department.vue'),
path: 'company/workgroup', meta: { title: '部门管理', icon: 'Share', requiresAuth: true },
name: 'CompanyWorkgroup', },
component: () => import('@/views/company/Workgroup.vue'), {
meta: { title: '工作组管理', icon: 'UserFilled', requiresAuth: true }, path: 'company/workgroup',
}, name: 'CompanyWorkgroup',
{ component: () => import('@/views/company/Workgroup.vue'),
path: 'company/role', meta: { title: '工作组管理', icon: 'UserFilled', requiresAuth: true },
name: 'CompanyRole', },
component: () => import('@/views/company/Role.vue'), {
meta: { title: '公司角色', icon: 'Avatar', requiresAuth: true }, path: 'company/role',
}, name: 'CompanyRole',
// === 阶段 3:详情页 === component: () => import('@/views/company/Role.vue'),
{ meta: { title: '公司角色', icon: 'Avatar', requiresAuth: true },
path: 'system/user-detail', },
name: 'SystemUserDetail', // === 阶段 3:详情页 ===
component: () => import('@/views/system/UserDetail.vue'), {
meta: { title: '用户详情', requiresAuth: true }, path: 'system/user-detail',
}, name: 'SystemUserDetail',
{ component: () => import('@/views/system/UserDetail.vue'),
path: 'system/role-detail', meta: { title: '用户详情', requiresAuth: true },
name: 'SystemRoleDetail', },
component: () => import('@/views/system/RoleDetail.vue'), {
meta: { title: '角色详情', requiresAuth: true }, path: 'system/role-detail',
}, name: 'SystemRoleDetail',
{ component: () => import('@/views/system/RoleDetail.vue'),
path: 'system/role-permissions', meta: { title: '角色详情', requiresAuth: true },
name: 'SystemRolePermissions', },
component: () => import('@/views/system/RolePermissions.vue'), {
meta: { title: '权限分配', requiresAuth: true }, path: 'system/role-permissions',
}, name: 'SystemRolePermissions',
{ component: () => import('@/views/system/RolePermissions.vue'),
path: 'company/tenant-detail', meta: { title: '权限分配', requiresAuth: true },
name: 'CompanyTenantDetail', },
component: () => import('@/views/company/TenantDetail.vue'), {
meta: { title: '租户详情', requiresAuth: true }, path: 'company/tenant-detail',
}, name: 'CompanyTenantDetail',
{ component: () => import('@/views/company/TenantDetail.vue'),
path: 'company/workgroup-detail', meta: { title: '租户详情', requiresAuth: true },
name: 'CompanyWorkgroupDetail', },
component: () => import('@/views/company/WorkgroupDetail.vue'), {
meta: { title: '工作组详情', requiresAuth: true }, path: 'company/workgroup-detail',
}, name: 'CompanyWorkgroupDetail',
{ component: () => import('@/views/company/WorkgroupDetail.vue'),
path: 'company/department-detail', meta: { title: '工作组详情', requiresAuth: true },
name: 'CompanyDepartmentDetail', },
component: () => import('@/views/company/DepartmentDetail.vue'), {
meta: { title: '部门详情', requiresAuth: true }, path: 'company/department-detail',
}, name: 'CompanyDepartmentDetail',
// === 阶段 4:复杂详情页 === component: () => import('@/views/company/DepartmentDetail.vue'),
{ meta: { title: '部门详情', requiresAuth: true },
path: 'company/company-detail', },
name: 'CompanyCompanyDetail', // === 阶段 4:复杂详情页 ===
component: () => import('@/views/company/CompanyDetail.vue'), {
meta: { title: '公司详情', requiresAuth: true }, path: 'company/company-detail',
}, name: 'CompanyCompanyDetail',
{ component: () => import('@/views/company/CompanyDetail.vue'),
path: 'company/role-detail', meta: { title: '公司详情', requiresAuth: true },
name: 'CompanyRoleDetail', },
component: () => import('@/views/company/RoleDetail.vue'), {
meta: { title: '公司角色详情', requiresAuth: true }, path: 'company/role-detail',
}, name: 'CompanyRoleDetail',
{ component: () => import('@/views/company/RoleDetail.vue'),
path: 'company/employee-detail', meta: { title: '公司角色详情', requiresAuth: true },
name: 'CompanyEmployeeDetail', },
component: () => import('@/views/company/EmployeeDetail.vue'), {
meta: { title: '员工详情', requiresAuth: true }, path: 'company/employee-detail',
}, name: 'CompanyEmployeeDetail',
// === 阶段 5:业务功能页 === component: () => import('@/views/company/EmployeeDetail.vue'),
{ meta: { title: '员工详情', requiresAuth: true },
path: 'system/message', },
name: 'SystemMessage', // === 阶段 5:业务功能页 ===
component: () => import('@/views/system/Message.vue'), {
meta: { title: '站内信', icon: 'ChatDotRound', requiresAuth: true }, path: 'system/message',
}, name: 'SystemMessage',
{ component: () => import('@/views/system/Message.vue'),
path: 'system/tenant-create', meta: { title: '站内信', icon: 'ChatDotRound', requiresAuth: true },
name: 'SystemTenantCreate', },
component: () => import('@/views/system/TenantCreate.vue'), {
meta: { title: '租户入驻', requiresAuth: true }, path: 'system/tenant-create',
}, name: 'SystemTenantCreate',
{ component: () => import('@/views/system/TenantCreate.vue'),
path: 'system/tenant-review', meta: { title: '租户入驻', requiresAuth: true },
name: 'SystemTenantReview', },
component: () => import('@/views/system/TenantReview.vue'), {
meta: { title: '租户审核', requiresAuth: true }, path: 'system/tenant-review',
}, name: 'SystemTenantReview',
{ component: () => import('@/views/system/TenantReview.vue'),
path: 'system/payment-config', meta: { title: '租户审核', requiresAuth: true },
name: 'SystemPaymentConfig', },
component: () => import('@/views/system/PaymentConfig.vue'), {
meta: { title: '支付配置', requiresAuth: true }, path: 'system/payment-config',
}, name: 'SystemPaymentConfig',
{ component: () => import('@/views/system/PaymentConfig.vue'),
path: 'company/epcreate', meta: { title: '支付配置', requiresAuth: true },
name: 'CompanyEpCreate', },
component: () => import('@/views/company/EpCreate.vue'), {
meta: { title: '企业创建向导', requiresAuth: true }, path: 'company/epcreate',
}, name: 'CompanyEpCreate',
// === 阶段 6:独立工具 === component: () => import('@/views/company/EpCreate.vue'),
{ meta: { title: '企业创建向导', requiresAuth: true },
path: 'doc/editor', },
name: 'DocEditor', // === 阶段 6:独立工具 ===
component: () => import('@/views/doc/Editor.vue'), {
meta: { title: '文档编辑器', requiresAuth: true }, path: 'doc/editor',
}, name: 'DocEditor',
{ component: () => import('@/views/doc/Editor.vue'),
path: 'assets/manager', meta: { title: '文档编辑器', requiresAuth: true },
name: 'AssetsManager', },
component: () => import('@/views/assets/Manager.vue'), {
meta: { title: '资产管理', requiresAuth: true }, path: 'assets/manager',
}, name: 'AssetsManager',
// === 阶段 7:管理员工具 === component: () => import('@/views/assets/Manager.vue'),
{ meta: { title: '资产管理', requiresAuth: true },
path: 'system/audit-log', },
name: 'SystemAuditLog', // === 阶段 7:管理员工具 ===
component: () => import('@/views/system/AuditLog.vue'), {
meta: { title: '审计日志', requiresAuth: true }, path: 'system/audit-log',
}, name: 'SystemAuditLog',
{ component: () => import('@/views/system/AuditLog.vue'),
path: 'system/settings', meta: { title: '审计日志', requiresAuth: true },
name: 'SystemSettings', },
component: () => import('@/views/system/Settings.vue'), {
meta: { title: '系统设置', requiresAuth: true }, path: 'system/settings',
}, name: 'SystemSettings',
{ component: () => import('@/views/system/Settings.vue'),
path: 'system/menu', meta: { title: '系统设置', requiresAuth: true },
name: 'SystemMenu', },
component: () => import('@/views/system/Menu.vue'), {
meta: { title: '菜单管理', requiresAuth: true }, path: 'system/menu',
}, name: 'SystemMenu',
{ component: () => import('@/views/system/Menu.vue'),
path: 'system/database', meta: { title: '菜单管理', requiresAuth: true },
name: 'SystemDatabase', },
component: () => import('@/views/system/Database.vue'), {
meta: { title: '数据库管理', requiresAuth: true }, path: 'system/database',
}, name: 'SystemDatabase',
{ component: () => import('@/views/system/Database.vue'),
path: 'system/dbtable', meta: { title: '数据库管理', requiresAuth: true },
name: 'SystemDbTable', },
component: () => import('@/views/system/DbTable.vue'), {
meta: { title: '表结构管理', requiresAuth: true }, path: 'system/dbtable',
}, name: 'SystemDbTable',
// === 阶段 8:SKU(移动端 C 端,待决策是否拆分独立工程) === component: () => import('@/views/system/DbTable.vue'),
{ meta: { title: '表结构管理', requiresAuth: true },
path: 'sku', },
name: 'Sku', // === 阶段 8:SKU(移动端 C 端,待决策是否拆分独立工程) ===
component: () => import('@/views/SKU/Sku.vue'), {
meta: { title: '商品详情', requiresAuth: false }, path: 'sku',
}, name: 'Sku',
], component: () => import('@/views/SKU/Sku.vue'),
}, meta: { title: '商品详情', requiresAuth: false },
{ },
path: '/:pathMatch(.*)*', // === 兜底:未匹配路由在 DefaultLayout 内显示 404(保留 menu/nav/tab) ===
name: 'NotFound', // 作为 DefaultLayout 子路由,享有完整框架;requiresAuth: true 使其自动开 tab
component: () => import('@/views/NotFound.vue'), {
meta: { title: '页面不存在', requiresAuth: false, layout: 'blank' }, path: ':pathMatch(.*)*',
}, name: 'NotFound',
] component: () => import('@/views/NotFound.vue'),
meta: { title: '页面不存在', requiresAuth: true },
/** 动态路由(从后端菜单加载后注入) */ },
export const dynamicRoutes: RouteRecordRaw[] = [] ],
},
const router = createRouter({ ]
history: createWebHistory(),
routes: staticRoutes, /** 动态路由(从后端菜单加载后注入) */
scrollBehavior(_to, _from, savedPosition) { export const dynamicRoutes: RouteRecordRaw[] = []
return savedPosition || { top: 0 }
}, const router = createRouter({
}) // hash 模式:URL 形如 gerp.gvsds.com/#/dashboard
// 适配 frp 内网穿透部署,无需后端 rewrite 配置,避免刷新 404
/** 全局前置守卫:登录态校验、动态路由注入 */ history: createWebHashHistory(),
router.beforeEach(async (to, _from, next) => { routes: staticRoutes,
// 设置页面标题 scrollBehavior(_to, _from, savedPosition) {
document.title = (to.meta.title as string) || 'GERP 管理系统' return savedPosition || { top: 0 }
},
const userStore = useUserStore() })
// 不需要登录的页面直接放行 /** 标记本次导航是否为首次打开的新 tab(用于 afterEach 区分 loading 策略) */
if (to.meta.requiresAuth === false) { let justOpenedNewTab = false
next()
return /** 全局前置守卫:进度条启动、立即开 tab、登录态校验、动态路由注入 */
} router.beforeEach(async (to, _from, next) => {
const tabsStore = useTabsStore()
// 已登录但未加载用户信息:尝试加载 justOpenedNewTab = false
if (!userStore.isLoggedIn) { // 立即添加 tab:与 loading 同步出现,不等登录检查或组件加载
try { // addTab 返回 true=新 tab(首次打开,需 loading),false=已存在(已缓存,瞬时切换)
await userStore.fetchUserInfo() if (to.meta.requiresAuth === true && to.meta.title) {
await userStore.fetchMenus() justOpenedNewTab = tabsStore.addTab(to)
// TODO: 注入动态路由后 next({ ...to, replace: true }) }
} catch {
// 加载失败:跳转登录 // 设置页面标题
next({ name: 'Login', query: { redirect: to.fullPath } }) document.title = (to.meta.title as string) || 'GERP 管理系统'
return
} // progress 策略:
} // - 新 tab 或非 tab 页面(login 等):启动 loading(首次挂载组件 + 加载数据)
// - 已缓存 tab:不显示 loading(瞬时 v-show 切换,组件已挂载,零请求)
next() if (justOpenedNewTab || to.meta.requiresAuth !== true) {
}) useProgressStore().start()
} else {
export default router 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) {
// 使用 restoreSession(silent 模式):session 过期时不弹"登录状态已失效"提示
// 直接跳转登录页,避免 login 页面出现错误弹窗
if (await userStore.restoreSession()) {
// 恢复成功,继续导航
} else {
// 恢复失败:移除乐观添加的 tab,跳转登录
tabsStore.removeTab(to.path)
next({ name: 'Login', query: { redirect: to.fullPath } })
return
}
}
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 }
})
+205 -132
View File
@@ -1,133 +1,206 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import type { UserInfo, MenuItem } from '@/types/api' 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' import { getMenuList } from '@/api/menu'
/** 用户状态管理:当前登录用户、角色、菜单、权限 */ /** 用户状态管理:当前登录用户、角色、菜单、权限 */
export const useUserStore = defineStore('user', () => { export const useUserStore = defineStore('user', () => {
// === 状态 === // === 状态 ===
const userInfo = ref<UserInfo | null>(null) const userInfo = ref<UserInfo | null>(null)
const roles = ref<string[]>([]) const roles = ref<string[]>([])
const menus = ref<MenuItem[]>([]) const menus = ref<MenuItem[]>([])
const token = ref<string>('') const token = ref<string>('')
const loading = ref(false) const loading = ref(false)
// === 计算属性 === // === localStorage 持久化缓存 ===
const isLoggedIn = computed(() => !!userInfo.value) // 目的:刷新页面后从缓存恢复 userInfo/menus,避免重复请求 GetELTUserInfo/MenuList
const isAdmin = computed(() => userInfo.value?.IsAdmin === true) // 复用 gerp-user key(reset/handleLoginExpired 已删除此 key,形成写入→清除闭环)
const currentRole = computed(() => userInfo.value?.CurrentRole || '') const CACHE_KEY = 'gerp-user'
const avatar = computed(() => userInfo.value?.Avatar || '')
const displayName = computed(() => userInfo.value?.DisplayName || userInfo.value?.UserName || '未登录') /** 从 localStorage 恢复缓存到内存(刷新后内存丢失时调用) */
function loadFromCache(): void {
/** 扁平化后的菜单(用于面包屑、菜单搜索) */ try {
const flatMenus = computed<MenuItem[]>(() => { const raw = localStorage.getItem(CACHE_KEY)
const result: MenuItem[] = [] if (!raw) return
const walk = (items: MenuItem[]) => { const cached = JSON.parse(raw) as { userInfo?: UserInfo | null; menus?: MenuItem[] | null }
for (const item of items) { if (cached.userInfo) userInfo.value = cached.userInfo
result.push(item) if (cached.menus) menus.value = cached.menus
if (item.Children?.length) walk(item.Children) } catch {
} // 缓存损坏:清除避免反复解析失败
} localStorage.removeItem(CACHE_KEY)
walk(menus.value) }
return result }
})
/** 将内存态写入 localStorage(fetchUserInfo/fetchMenus 成功后调用) */
// === 动作 === function saveToCache(): void {
/** 登录 */ try {
async function login(params: LoginParams): Promise<void> { localStorage.setItem(
loading.value = true CACHE_KEY,
try { JSON.stringify({ userInfo: userInfo.value, menus: menus.value }),
await apiLogin(params) )
await fetchUserInfo() } catch {
await fetchMenus() // 写入失败(隐私模式/配额满):忽略,不影响功能
} finally { }
loading.value = false }
}
} // store 初始化时立即从缓存恢复:刷新后 isLoggedIn 立即为 true,
// router.beforeEach 不再触发 restoreSession,实现刷新零请求
/** 注销 */ loadFromCache()
async function logout(): Promise<void> {
try { // === 计算属性 ===
await apiLogout() const isLoggedIn = computed(() => !!userInfo.value)
} finally { const isAdmin = computed(() => userInfo.value?.IsAdmin === true)
reset() const currentRole = computed(() => userInfo.value?.CurrentRole || '')
} const avatar = computed(() => userInfo.value?.Avatar || '')
} const displayName = computed(() => userInfo.value?.DisplayName || userInfo.value?.UserName || '未登录')
/** 获取当前用户信息 */ /** 扁平化后的菜单(用于面包屑、菜单搜索) */
async function fetchUserInfo(): Promise<UserInfo> { const flatMenus = computed<MenuItem[]>(() => {
const info = await getCurrentUser() const result: MenuItem[] = []
userInfo.value = info const walk = (items: MenuItem[]) => {
return info for (const item of items) {
} result.push(item)
if (item.Children?.length) walk(item.Children)
/** 获取角色列表 */ }
async function fetchRoles(): Promise<string[]> { }
const list = await getUserRoles() walk(menus.value)
roles.value = list return result
return list })
}
// === 动作 ===
/** 将后端 Page(如 pages/workorder/admin.html)规范化为 Vue 路由(/workorder/admin) */ /** 登录 */
function normalizeMenuPath(page: string | undefined): string { async function login(params: LoginParams): Promise<void> {
if (!page) return '' loading.value = true
let p = page.trim() try {
if (!p) return '' await apiLogin(params)
if (/^https?:\/\//i.test(p)) return p await fetchUserInfo()
if (p.startsWith('#')) p = p.slice(1) await fetchMenus()
p = p.replace(/^\/+/, '') } finally {
if (p.startsWith('pages/')) p = p.slice(6) loading.value = false
p = p.replace(/\.html?$/i, '') }
return p ? '/' + p : '' }
}
/** 注销 */
/** 递归规范化菜单树:Page → Path */ async function logout(): Promise<void> {
function normalizeMenuTree(items: MenuItem[]): MenuItem[] { try {
return items.map((item) => ({ await apiLogout()
...item, } finally {
Path: normalizeMenuPath(item.Page), reset()
Children: item.Children?.length ? normalizeMenuTree(item.Children) : [], }
})) }
}
/** 获取当前用户信息 */
/** 获取菜单 */ async function fetchUserInfo(): Promise<UserInfo> {
async function fetchMenus(): Promise<MenuItem[]> { const info = await getCurrentUser()
const list = await getMenuList() userInfo.value = info
const normalized = normalizeMenuTree(list) saveToCache()
menus.value = normalized return info
return normalized }
}
/**
/** 重置状态(登出或登录失效时调用) */ * 静默恢复会话(路由守卫用):成功则填充用户信息,失败不弹全局提示
function reset(): void { * 优先级:内存态 > localStorage 缓存 > 后端请求
userInfo.value = null * - 内存已有 userInfo:直接返回(同会话内已恢复过,零请求)
roles.value = [] * - 缓存命中:从 localStorage 恢复(刷新后首次,零请求)
menus.value = [] * - 都没有:请求后端 GetELTUserInfo + MenuList(首次登录或缓存被清)
token.value = '' * 会话过期兜底:缓存命中后,首个业务请求 401 会触发 handleLoginExpired 清缓存并跳登录
localStorage.removeItem('gerp-user') */
} async function restoreSession(): Promise<boolean> {
// 内存已有:直接返回
return { if (userInfo.value) return true
// 状态 // 尝试从缓存恢复
userInfo, loadFromCache()
roles, if (userInfo.value) return true
menus, // 缓存也没有:请求后端
token, try {
loading, const info = await getCurrentUser(true)
// 计算属性 userInfo.value = info
isLoggedIn, await fetchMenus()
isAdmin, saveToCache()
currentRole, return true
avatar, } catch {
displayName, return false
flatMenus, }
// 动作 }
login,
logout, /** 获取角色列表 */
fetchUserInfo, async function fetchRoles(): Promise<string[]> {
fetchRoles, const list = await getUserRoles()
fetchMenus, roles.value = list
reset, 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 ''
let p = page.trim()
if (!p) return ''
if (/^https?:\/\//i.test(p)) return p
if (p.startsWith('#')) p = p.slice(1)
p = p.replace(/^\/+/, '')
if (p.startsWith('pages/')) p = p.slice(6)
p = p.replace(/\.html?$/i, '')
return p ? '/' + p : ''
}
/** 递归规范化菜单树:Page → Path */
function normalizeMenuTree(items: MenuItem[]): MenuItem[] {
return items.map((item) => ({
...item,
Path: normalizeMenuPath(item.Page),
Children: item.Children?.length ? normalizeMenuTree(item.Children) : [],
}))
}
/** 获取菜单 */
async function fetchMenus(): Promise<MenuItem[]> {
const list = await getMenuList()
const normalized = normalizeMenuTree(list)
menus.value = normalized
saveToCache()
return normalized
}
/** 重置状态(登出或登录失效时调用) */
function reset(): void {
userInfo.value = null
roles.value = []
menus.value = []
token.value = ''
localStorage.removeItem(CACHE_KEY)
}
return {
// 状态
userInfo,
roles,
menus,
token,
loading,
// 计算属性
isLoggedIn,
isAdmin,
currentRole,
avatar,
displayName,
flatMenus,
// 动作
login,
logout,
fetchUserInfo,
fetchRoles,
fetchMenus,
switchRole,
restoreSession,
reset,
}
}) })
+9 -9
View File
@@ -11,18 +11,18 @@ const userStore = useUserStore()
const loading = ref(false) const loading = ref(false)
const form = reactive<LoginParams>({ const form = reactive<LoginParams>({
Username: '', UserName: '',
Password: '', UserPassword: '',
RememberMe: false, RememberMe: false,
}) })
const rules = { const rules = {
Username: [{ required: true, message: '请输入用户名', trigger: 'blur' }], UserName: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
Password: [{ required: true, message: '请输入密码', trigger: 'blur' }], UserPassword: [{ required: true, message: '请输入密码', trigger: 'blur' }],
} }
async function handleLogin(): Promise<void> { async function handleLogin(): Promise<void> {
if (!form.Username || !form.Password) { if (!form.UserName || !form.UserPassword) {
ElMessage.warning('请输入用户名和密码') ElMessage.warning('请输入用户名和密码')
return return
} }
@@ -49,13 +49,13 @@ async function handleLogin(): Promise<void> {
</div> </div>
<el-form :model="form" :rules="rules" label-position="top" size="large" @submit.prevent="handleLogin"> <el-form :model="form" :rules="rules" label-position="top" size="large" @submit.prevent="handleLogin">
<el-form-item label="用户名" prop="Username"> <el-form-item label="用户名" prop="UserName">
<el-input v-model="form.Username" placeholder="请输入用户名" :prefix-icon="'User'" clearable /> <el-input v-model="form.UserName" placeholder="请输入用户名" :prefix-icon="'User'" clearable />
</el-form-item> </el-form-item>
<el-form-item label="密码" prop="Password"> <el-form-item label="密码" prop="UserPassword">
<el-input <el-input
v-model="form.Password" v-model="form.UserPassword"
type="password" type="password"
placeholder="请输入密码" placeholder="请输入密码"
:prefix-icon="'Lock'" :prefix-icon="'Lock'"
+1
View File
@@ -60,6 +60,7 @@ function handleView(row: CompanyRow): void {
pk-field="CompanyUUID" pk-field="CompanyUUID"
searchable searchable
pageable pageable
stretch
selectable selectable
:on-view="handleView" :on-view="handleView"
/> />
+1
View File
@@ -60,6 +60,7 @@ function handleView(row: DepartmentRow): void {
pk-field="DeptUUID" pk-field="DeptUUID"
searchable searchable
pageable pageable
stretch
selectable selectable
:on-view="handleView" :on-view="handleView"
/> />
+1
View File
@@ -60,6 +60,7 @@ function handleView(row: RoleRow): void {
pk-field="RoleUUID" pk-field="RoleUUID"
searchable searchable
pageable pageable
stretch
selectable selectable
:on-view="handleView" :on-view="handleView"
/> />
+1
View File
@@ -55,6 +55,7 @@ function handleView(row: TenantRow): void {
pk-field="TenantUUID" pk-field="TenantUUID"
searchable searchable
pageable pageable
stretch
selectable selectable
:on-view="handleView" :on-view="handleView"
/> />
+1
View File
@@ -56,6 +56,7 @@ function handleView(row: WorkgroupRow): void {
pk-field="WorkgroupUUID" pk-field="WorkgroupUUID"
searchable searchable
pageable pageable
stretch
selectable selectable
:on-view="handleView" :on-view="handleView"
/> />
+1
View File
@@ -59,6 +59,7 @@ function handleView(row: TemplateRow): void {
pk-field="TemplateUUID" pk-field="TemplateUUID"
searchable searchable
pageable pageable
stretch
:selectable="isAdmin" :selectable="isAdmin"
:on-view="handleView" :on-view="handleView"
/> />
+1
View File
@@ -59,6 +59,7 @@ function handleView(row: OrderRow): void {
pk-field="OrderUUID" pk-field="OrderUUID"
searchable searchable
pageable pageable
stretch
selectable selectable
:on-view="handleView" :on-view="handleView"
/> />
+1
View File
@@ -114,6 +114,7 @@ onMounted(() => {
searchable searchable
pageable pageable
selectable selectable
stretch
/> />
<!-- <!--
+1
View File
@@ -43,6 +43,7 @@ function handleView(row: RoleRow): void {
pk-field="RoleUUID" pk-field="RoleUUID"
searchable searchable
pageable pageable
stretch
selectable selectable
:on-view="handleView" :on-view="handleView"
/> />
+1
View File
@@ -81,6 +81,7 @@ function handleView(row: UserRow): void {
pk-field="UserUUID" pk-field="UserUUID"
searchable searchable
pageable pageable
stretch
:selectable="!isMember" :selectable="!isMember"
:on-view="handleView" :on-view="handleView"
/> />
+1 -1
View File
@@ -16,7 +16,7 @@ setPageTitle('工单管理')
<template> <template>
<div class="wo-admin-page"> <div class="wo-admin-page">
<CrudTable table="WorkOrder" title="工单管理" searchable pageable selectable /> <CrudTable table="WorkOrder" title="工单管理" searchable pageable selectable stretch />
</div> </div>
</template> </template>
+1 -1
View File
@@ -29,7 +29,7 @@ const query = computed(() => {
<template> <template>
<div class="wo-my-page"> <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> </div>
</template> </template>