diff --git a/deploy.ps1 b/deploy.ps1 new file mode 100644 index 0000000..3b06205 --- /dev/null +++ b/deploy.ps1 @@ -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 diff --git a/package-lock.json b/package-lock.json index 8e3baef..ab2dec5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 54a42b4..7410a03 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/api/auth.ts b/src/api/auth.ts index 5de50e3..69aadbe 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -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 => { return post('/Auth/SwitchRole', { Role: role }) } -/** 获取当前登录用户信息 */ -export const getCurrentUser = (): Promise => { - return get('/Query/GetELTUserInfo') +/** 获取当前登录用户信息(silent=true 时失败不弹全局提示,用于登录页会话恢复) */ +export const getCurrentUser = (silent = false): Promise => { + return get('/Query/GetELTUserInfo', undefined, silent ? { silent: true } : undefined) } /** 获取用户头像 URL(直接作为图片 src 使用) */ diff --git a/src/api/request.ts b/src/api/request.ts index 42892c3..a5ea874 100644 --- a/src/api/request.ts +++ b/src/api/request.ts @@ -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 = AxiosResponse> +/** 扩展请求配置: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 diff --git a/src/components/CrudTable.vue b/src/components/CrudTable.vue index cc4d969..d5eb251 100644 --- a/src/components/CrudTable.vue +++ b/src/components/CrudTable.vue @@ -30,6 +30,12 @@ const props = defineProps<{ onView?: (row: T) => void /** 行点击回调 */ onRowClick?: (row: T) => void + /** + * 是否撑满父容器(整页表格场景)。 + * 开启时表格 height=100% + 容器 flex 撑满,产生内部滚动; + * 关闭时表格自适应内容高度(详情页嵌套场景,随页面滚动)。 + */ + stretch?: boolean }>() const options: UseCrudTableOptions = { @@ -118,7 +124,7 @@ function renderUuid(value: unknown): string {