| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199 |
- <script setup lang="ts">
- import { onMounted, onBeforeUnmount, ref } from 'vue'
- import { useRouter } from 'vue-router'
- import { ElMessage } from 'element-plus'
- import { Loading, Right, RefreshRight } from '@element-plus/icons-vue'
- import { jwtDecode } from 'jwt-decode'
- import MockLogin from '../Login.vue'
- import { api } from '../../api'
- import {
- clearAuthInfo, clearOAuthTemp, consumeOAuthTransaction,
- getCachedOAuthState, getCachedVerifier, getOAuthLoginUrl,
- getToken, mockLogin, setAuthInfo
- } from '../../utils/auth'
- const faviconUrl = '/favicon.ico'
- const router = useRouter()
- const busy = ref(true)
- const message = ref('正在检查登录状态...')
- const failed = ref(false)
- const manualRetryVisible = ref(false)
- let cancelled = false
- onBeforeUnmount(() => { cancelled = true })
- interface DecodedAccessToken {
- displayName?: string
- role?: string | string[]
- }
- let exchangingCode: string | null = null
- let exchangingPromise: Promise<any> | null = null
- /** 同一 code 并发换取令牌时复用请求,避免重复调用。 */
- function exchangeTokenOnce(code: string, verifier: string, redirectURI: string) {
- if (exchangingPromise && exchangingCode === code) return exchangingPromise
- exchangingCode = code
- exchangingPromise = api.exchangeToken(code, verifier, redirectURI).finally(() => {
- exchangingCode = null
- exchangingPromise = null
- })
- return exchangingPromise
- }
- async function authorize() {
- busy.value = true
- failed.value = false
- manualRetryVisible.value = false
- message.value = '正在跳转认证中心...'
- try {
- const url = getOAuthLoginUrl()
- if (cancelled) return
- sessionStorage.removeItem('warehouse-auth-paused')
- sessionStorage.removeItem('oauth_no_auto_redirect')
- location.assign(url)
- } catch (error) {
- showError(error)
- }
- }
- function handleRetryAuth() {
- sessionStorage.removeItem('oauth_no_auto_redirect')
- authorize()
- }
- function showError(error: unknown) {
- clearOAuthTemp()
- if (cancelled) return
- busy.value = false
- failed.value = true
- manualRetryVisible.value = true
- message.value = error instanceof Error ? error.message : '登录失败,请重新认证'
- }
- async function handleCallback(code: string, state: string | null, authError: string | null) {
- // 清理 URL 中的 OAuth 临时参数,避免刷新页面重复使用
- // const query = new URLSearchParams(location.search)
- // for (const key of ['code', 'state', 'error', 'error_description', 'session_state', 'iss']) {
- // query.delete(key)
- // }
- // const remaining = query.toString()
- // history.replaceState(history.state, '', `${location.pathname}${remaining ? `?${remaining}` : ''}`)
- // sessionStorage.removeItem('oauth_no_auto_redirect')
- // clearAuthInfo()
- // const cachedState = getCachedOAuthState()
- // if (!state || !cachedState || state !== cachedState) {
- // ElMessage.warning('登录状态校验失败,准备重新发起认证')
- // clearOAuthTemp()
- // authorize()
- // return
- // }
- const codeVerifier = getCachedVerifier()
- if (!codeVerifier) {
- ElMessage.warning('登录会话已失效,准备重新发起认证')
- clearOAuthTemp()
- authorize()
- return
- }
- try {
- message.value = '正在换取访问令牌...'
- busy.value = true
- manualRetryVisible.value = false
- const transaction = consumeOAuthTransaction(state)
- if (authError) throw new Error('认证被取消或拒绝,请重新认证')
- const result = await exchangeTokenOnce(code, transaction.verifier, transaction.redirectURI)
- if (typeof result.access_token !== 'string' || !result.access_token.trim()) {
- throw new Error('认证服务未返回有效访问令牌')
- }
- if (result.token_type?.toLowerCase() !== 'bearer') {
- throw new Error('认证服务返回了不支持的令牌类型')
- }
- if (cancelled) return
- // 解析 JWT 提取用户信息
- let decodedDisplayName = ''
- let decodedRole: string[] = []
- try {
- const decoded = jwtDecode<DecodedAccessToken>(result.access_token)
- decodedDisplayName = decoded?.displayName || ''
- if (Array.isArray(decoded?.role)) {
- decodedRole = decoded.role
- } else if (typeof decoded?.role === 'string' && decoded.role) {
- decodedRole = [decoded.role]
- }
- } catch (decodeError) {
- console.warn('access_token 解析失败:', decodeError)
- }
- await setAuthInfo({
- accessToken: result.access_token,
- displayName: decodedDisplayName || result.displayName,
- role: decodedRole
- })
- clearOAuthTemp()
- await router.replace('/dashboard')
- } catch (error) {
- showError(error)
- }
- }
- onMounted(async () => {
- if (mockLogin) return
- const token = getToken()
- if (token) {
- await router.replace('/dashboard')
- return
- }
- const searchParams = new URLSearchParams(location.search)
- const code = searchParams.get('code')
- const state = searchParams.get('state')
- const authError = searchParams.get('error')
- const noAutoRedirect = sessionStorage.getItem('oauth_no_auto_redirect') === '1'
- if (code || authError) {
- await handleCallback(code || '', state, authError)
- return
- }
- if (noAutoRedirect || sessionStorage.getItem('warehouse-auth-paused')) {
- busy.value = false
- manualRetryVisible.value = true
- message.value = '登录已退出或会话已失效,请重新认证。'
- return
- }
- await authorize()
- })
- </script>
- <template>
- <MockLogin v-if="mockLogin" />
- <div v-else class="login-screen">
- <section class="login-panel" aria-labelledby="login-title">
- <div class="brand login-brand"><img :src="faviconUrl" class="brand-mark" alt="HS 仓储运营台" />
- <div><strong>HS 仓储运营台</strong><small>WAREHOUSE OPS · 上海闵行仓</small></div>
- </div>
- <h1 id="login-title">统一身份认证</h1>
- <div class="auth-status" role="status" aria-live="polite" :aria-busy="busy">
- <el-icon v-if="busy" class="is-loading" :size="28"><Loading /></el-icon>
- <p :class="{ 'auth-error': failed }">{{ message }}</p>
- </div>
- <el-button v-if="!busy && manualRetryVisible" type="primary" size="large" class="login-submit"
- :icon="failed ? RefreshRight : Right" @click="handleRetryAuth">{{ failed ? '重新发起认证' : '前往认证中心' }}</el-button>
- <footer>仅限内部授权账号访问</footer>
- </section>
- </div>
- </template>
- <style scoped>
- .auth-status { display: flex; flex-direction: column; align-items: center; gap: 16px; padding: 24px 0; min-height: 128px; }
- .auth-status p { margin: 0; line-height: 1.7; overflow-wrap: anywhere; text-align: center; }
- .auth-error { color: var(--el-color-danger); }
- </style>
|