index.vue 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199
  1. <script setup lang="ts">
  2. import { onMounted, onBeforeUnmount, ref } from 'vue'
  3. import { useRouter } from 'vue-router'
  4. import { ElMessage } from 'element-plus'
  5. import { Loading, Right, RefreshRight } from '@element-plus/icons-vue'
  6. import { jwtDecode } from 'jwt-decode'
  7. import MockLogin from '../Login.vue'
  8. import { api } from '../../api'
  9. import {
  10. clearAuthInfo, clearOAuthTemp, consumeOAuthTransaction,
  11. getCachedOAuthState, getCachedVerifier, getOAuthLoginUrl,
  12. getToken, mockLogin, setAuthInfo
  13. } from '../../utils/auth'
  14. const faviconUrl = '/favicon.ico'
  15. const router = useRouter()
  16. const busy = ref(true)
  17. const message = ref('正在检查登录状态...')
  18. const failed = ref(false)
  19. const manualRetryVisible = ref(false)
  20. let cancelled = false
  21. onBeforeUnmount(() => { cancelled = true })
  22. interface DecodedAccessToken {
  23. displayName?: string
  24. role?: string | string[]
  25. }
  26. let exchangingCode: string | null = null
  27. let exchangingPromise: Promise<any> | null = null
  28. /** 同一 code 并发换取令牌时复用请求,避免重复调用。 */
  29. function exchangeTokenOnce(code: string, verifier: string, redirectURI: string) {
  30. if (exchangingPromise && exchangingCode === code) return exchangingPromise
  31. exchangingCode = code
  32. exchangingPromise = api.exchangeToken(code, verifier, redirectURI).finally(() => {
  33. exchangingCode = null
  34. exchangingPromise = null
  35. })
  36. return exchangingPromise
  37. }
  38. async function authorize() {
  39. busy.value = true
  40. failed.value = false
  41. manualRetryVisible.value = false
  42. message.value = '正在跳转认证中心...'
  43. try {
  44. const url = getOAuthLoginUrl()
  45. if (cancelled) return
  46. sessionStorage.removeItem('warehouse-auth-paused')
  47. sessionStorage.removeItem('oauth_no_auto_redirect')
  48. location.assign(url)
  49. } catch (error) {
  50. showError(error)
  51. }
  52. }
  53. function handleRetryAuth() {
  54. sessionStorage.removeItem('oauth_no_auto_redirect')
  55. authorize()
  56. }
  57. function showError(error: unknown) {
  58. clearOAuthTemp()
  59. if (cancelled) return
  60. busy.value = false
  61. failed.value = true
  62. manualRetryVisible.value = true
  63. message.value = error instanceof Error ? error.message : '登录失败,请重新认证'
  64. }
  65. async function handleCallback(code: string, state: string | null, authError: string | null) {
  66. // 清理 URL 中的 OAuth 临时参数,避免刷新页面重复使用
  67. // const query = new URLSearchParams(location.search)
  68. // for (const key of ['code', 'state', 'error', 'error_description', 'session_state', 'iss']) {
  69. // query.delete(key)
  70. // }
  71. // const remaining = query.toString()
  72. // history.replaceState(history.state, '', `${location.pathname}${remaining ? `?${remaining}` : ''}`)
  73. // sessionStorage.removeItem('oauth_no_auto_redirect')
  74. // clearAuthInfo()
  75. // const cachedState = getCachedOAuthState()
  76. // if (!state || !cachedState || state !== cachedState) {
  77. // ElMessage.warning('登录状态校验失败,准备重新发起认证')
  78. // clearOAuthTemp()
  79. // authorize()
  80. // return
  81. // }
  82. const codeVerifier = getCachedVerifier()
  83. if (!codeVerifier) {
  84. ElMessage.warning('登录会话已失效,准备重新发起认证')
  85. clearOAuthTemp()
  86. authorize()
  87. return
  88. }
  89. try {
  90. message.value = '正在换取访问令牌...'
  91. busy.value = true
  92. manualRetryVisible.value = false
  93. const transaction = consumeOAuthTransaction(state)
  94. if (authError) throw new Error('认证被取消或拒绝,请重新认证')
  95. const result = await exchangeTokenOnce(code, transaction.verifier, transaction.redirectURI)
  96. if (typeof result.access_token !== 'string' || !result.access_token.trim()) {
  97. throw new Error('认证服务未返回有效访问令牌')
  98. }
  99. if (result.token_type?.toLowerCase() !== 'bearer') {
  100. throw new Error('认证服务返回了不支持的令牌类型')
  101. }
  102. if (cancelled) return
  103. // 解析 JWT 提取用户信息
  104. let decodedDisplayName = ''
  105. let decodedRole: string[] = []
  106. try {
  107. const decoded = jwtDecode<DecodedAccessToken>(result.access_token)
  108. decodedDisplayName = decoded?.displayName || ''
  109. if (Array.isArray(decoded?.role)) {
  110. decodedRole = decoded.role
  111. } else if (typeof decoded?.role === 'string' && decoded.role) {
  112. decodedRole = [decoded.role]
  113. }
  114. } catch (decodeError) {
  115. console.warn('access_token 解析失败:', decodeError)
  116. }
  117. await setAuthInfo({
  118. accessToken: result.access_token,
  119. displayName: decodedDisplayName || result.displayName,
  120. role: decodedRole
  121. })
  122. clearOAuthTemp()
  123. await router.replace('/dashboard')
  124. } catch (error) {
  125. showError(error)
  126. }
  127. }
  128. onMounted(async () => {
  129. if (mockLogin) return
  130. const token = getToken()
  131. if (token) {
  132. await router.replace('/dashboard')
  133. return
  134. }
  135. const searchParams = new URLSearchParams(location.search)
  136. const code = searchParams.get('code')
  137. const state = searchParams.get('state')
  138. const authError = searchParams.get('error')
  139. const noAutoRedirect = sessionStorage.getItem('oauth_no_auto_redirect') === '1'
  140. if (code || authError) {
  141. await handleCallback(code || '', state, authError)
  142. return
  143. }
  144. if (noAutoRedirect || sessionStorage.getItem('warehouse-auth-paused')) {
  145. busy.value = false
  146. manualRetryVisible.value = true
  147. message.value = '登录已退出或会话已失效,请重新认证。'
  148. return
  149. }
  150. await authorize()
  151. })
  152. </script>
  153. <template>
  154. <MockLogin v-if="mockLogin" />
  155. <div v-else class="login-screen">
  156. <section class="login-panel" aria-labelledby="login-title">
  157. <div class="brand login-brand"><img :src="faviconUrl" class="brand-mark" alt="HS 仓储运营台" />
  158. <div><strong>HS 仓储运营台</strong><small>WAREHOUSE OPS · 上海闵行仓</small></div>
  159. </div>
  160. <h1 id="login-title">统一身份认证</h1>
  161. <div class="auth-status" role="status" aria-live="polite" :aria-busy="busy">
  162. <el-icon v-if="busy" class="is-loading" :size="28"><Loading /></el-icon>
  163. <p :class="{ 'auth-error': failed }">{{ message }}</p>
  164. </div>
  165. <el-button v-if="!busy && manualRetryVisible" type="primary" size="large" class="login-submit"
  166. :icon="failed ? RefreshRight : Right" @click="handleRetryAuth">{{ failed ? '重新发起认证' : '前往认证中心' }}</el-button>
  167. <footer>仅限内部授权账号访问</footer>
  168. </section>
  169. </div>
  170. </template>
  171. <style scoped>
  172. .auth-status { display: flex; flex-direction: column; align-items: center; gap: 16px; padding: 24px 0; min-height: 128px; }
  173. .auth-status p { margin: 0; line-height: 1.7; overflow-wrap: anywhere; text-align: center; }
  174. .auth-error { color: var(--el-color-danger); }
  175. </style>