This commit is contained in:
2026-06-30 14:24:48 +08:00
parent 2061b3e361
commit d461476889
7 changed files with 118 additions and 22 deletions

View File

@@ -366,7 +366,6 @@
"useThrottleFn": true,
"useThrottledRefHistory": true,
"useTimeAgo": true,
"useTimeAgoIntl": true,
"useTimeout": true,
"useTimeoutFn": true,
"useTimeoutPoll": true,

1
auto-imports.d.ts vendored
View File

@@ -817,7 +817,6 @@ declare module 'vue' {
readonly useThrottleFn: UnwrapRef<typeof import('@vueuse/core')['useThrottleFn']>
readonly useThrottledRefHistory: UnwrapRef<typeof import('@vueuse/core')['useThrottledRefHistory']>
readonly useTimeAgo: UnwrapRef<typeof import('@vueuse/core')['useTimeAgo']>
readonly useTimeAgoIntl: UnwrapRef<typeof import('@vueuse/core')['useTimeAgoIntl']>
readonly useTimeout: UnwrapRef<typeof import('@vueuse/core')['useTimeout']>
readonly useTimeoutFn: UnwrapRef<typeof import('@vueuse/core')['useTimeoutFn']>
readonly useTimeoutPoll: UnwrapRef<typeof import('@vueuse/core')['useTimeoutPoll']>

View File

@@ -25,3 +25,66 @@ export const isCurrentOrigin = (origin) => {
if (!origin || typeof window === 'undefined') return false
return window.location.origin === origin
}
const PORTAL_HANDOFF_HASH = '_ph'
const encodeHandoffPayload = (payload) => {
return btoa(JSON.stringify(payload))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '')
}
const decodeHandoffPayload = (encoded) => {
const normalized = encoded.replace(/-/g, '+').replace(/_/g, '/')
const padding = normalized.length % 4 === 0 ? '' : '='.repeat(4 - (normalized.length % 4))
return JSON.parse(atob(normalized + padding))
}
/** 构建跨域跳转 URLtoken 经 hash 一次性传递,不发给服务端) */
export const buildPortalRedirectURL = (targetOrigin, path, credentials) => {
const url = new URL(path || '/', targetOrigin)
if (credentials?.access_token) {
url.hash = `${PORTAL_HANDOFF_HASH}=${encodeHandoffPayload({
access_token: credentials.access_token,
token_type: credentials.token_type || 'Bearer'
})}`
}
return url.toString()
}
/** 读取并清除 URL 中的跨域 handoff token */
export const consumePortalHandoff = () => {
if (typeof window === 'undefined') return null
const rawHash = window.location.hash.replace(/^#/, '')
if (!rawHash.startsWith(`${PORTAL_HANDOFF_HASH}=`)) return null
try {
const payload = decodeHandoffPayload(rawHash.slice(PORTAL_HANDOFF_HASH.length + 1))
if (!payload?.access_token) return null
const cleanUrl = window.location.pathname + window.location.search
window.history.replaceState(null, '', cleanUrl)
return {
access_token: payload.access_token,
token_type: payload.token_type || 'Bearer'
}
} catch {
return null
}
}
/** 清除当前域登录态后跳转到目标域(避免子/主域 localStorage 互相污染) */
export const redirectCrossPortal = (targetOrigin, path, userStore) => {
const credentials = userStore.isLoggedIn
? { access_token: userStore.accessToken, token_type: userStore.tokenType }
: null
userStore.logout()
window.location.replace(
buildPortalRedirectURL(targetOrigin, path, credentials)
)
}

View File

@@ -317,13 +317,11 @@
</div>
</div>
<!-- 请求参数 -->
<!-- <div class="info-section">
<!-- 请求参数仅开发环境 -->
<div v-if="isDevelopment && selectedApiCall?.request_params" class="info-section">
<h4 class="text-lg font-semibold text-gray-900 mb-4">请求参数</h4>
<div class="info-item">
<span class="info-value">{{ selectedApiCall?.request_params || '-' }}</span>
</div>
</div> -->
<pre class="request-params-json">{{ formatRequestParams(selectedApiCall.request_params) }}</pre>
</div>
</div>
<div v-else class="flex justify-center items-center py-8">
<el-loading size="large" />
@@ -365,6 +363,9 @@ const route = useRoute()
// 移动端检测
const { isMobile, isTablet } = useMobileTable()
// 开发环境标识
const isDevelopment = import.meta.env.DEV
// 响应式数据
const loading = ref(false)
const apiCalls = ref([])
@@ -472,6 +473,16 @@ const formatDateTime = (date) => {
return new Date(date).toLocaleString('zh-CN')
}
// 格式化请求参数 JSON
const formatRequestParams = (params) => {
if (!params) return '-'
try {
return JSON.stringify(JSON.parse(params), null, 2)
} catch {
return params
}
}
// 获取状态类型
const getStatusType = (status) => {
@@ -683,6 +694,20 @@ const handleExport = async (options) => {
flex: 1;
}
.request-params-json {
margin: 0;
padding: 0.75rem;
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 0.375rem;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.8125rem;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-all;
color: #111827;
}
/* 错误信息样式 */
.error-info {
background: rgba(254, 242, 242, 0.8);

View File

@@ -1,4 +1,4 @@
import { isCurrentOrigin, isPortalDomainConfigReady, isSubPortal, mainPortalOrigin, subPortalOrigin } from '@/constants/portal'
import { isCurrentOrigin, isPortalDomainConfigReady, isSubPortal, mainPortalOrigin, redirectCrossPortal, subPortalOrigin } from '@/constants/portal'
import { useUserStore } from '@/stores/user'
import { createRouter, createWebHistory } from 'vue-router'
import { statisticsRoutes } from './modules/statistics'
@@ -413,8 +413,9 @@ router.beforeEach(async (to, from, next) => {
const isAuthRoute = to.path.startsWith('/auth') || to.path.startsWith('/sub/auth')
const requiresAuth = to.meta.requiresAuth
// 只有在需要认证的路由上才等待初始化
if (requiresAuth && !userStore.initialized) {
// 只有在需要认证的路由,或本地已有 token 时,才等待 init避免登录页无法触发跨域隔离
const hasStoredToken = typeof localStorage !== 'undefined' && !!localStorage.getItem('access_token')
if ((requiresAuth || hasStoredToken) && !userStore.initialized) {
await userStore.init()
} else if (!userStore.initialized) {
// 对于不需要认证的路由,异步初始化但不阻塞
@@ -446,16 +447,16 @@ router.beforeEach(async (to, from, next) => {
console.warn('[router] 未配置主/子域名,当前以单域模式运行,跳过域名隔离跳转')
}
// 域名隔离:子账号登录态必须在子账号专属域名
// 域名隔离:子账号登录态必须在子账号专属域名(跳转前清当前域 token避免主站无法再次登录
if (userStore.isLoggedIn && userStore.accountKind === 'subordinate' && subPortalOrigin && !isCurrentOrigin(subPortalOrigin)) {
window.location.replace(`${subPortalOrigin}${to.fullPath}`)
redirectCrossPortal(subPortalOrigin, to.fullPath, userStore)
return
}
// 域名隔离:普通/管理员账号不应停留在子账号专属域名
if (userStore.isLoggedIn && userStore.accountKind !== 'subordinate' && subPortalOrigin && isCurrentOrigin(subPortalOrigin)) {
if (mainPortalOrigin) {
window.location.replace(`${mainPortalOrigin}${to.fullPath}`)
redirectCrossPortal(mainPortalOrigin, to.fullPath, userStore)
return
}
next('/products')

View File

@@ -92,7 +92,7 @@
*/
import { userApi } from '@/api'
import { isSubPortal } from '@/constants/portal'
import { consumePortalHandoff, isSubPortal } from '@/constants/portal'
import router from '@/router'
import { authEventBus } from '@/utils/request'
import { generateSMSRequest } from '@/utils/smsSignature'
@@ -453,6 +453,15 @@ export const useUserStore = defineStore('user', () => {
isInitializing = true
try {
// 跨域跳转后接收 hash 中的一次性 token
const handoff = consumePortalHandoff()
if (handoff?.access_token) {
accessToken.value = handoff.access_token
tokenType.value = handoff.token_type || 'Bearer'
localStorage.setItem('access_token', handoff.access_token)
localStorage.setItem('token_type', handoff.token_type || 'Bearer')
}
// 监听认证错误事件(只注册一次)
if (!authEventBus.listeners.includes(handleAuthError)) {
authEventBus.onAuthError(handleAuthError)

View File

@@ -124,18 +124,18 @@ export default defineConfig({
server: {
proxy: {
// // 本地开发时将 /api/v1 的请求代理到 8080 端口
'/api/v1': {
target: 'http://localhost:8080',
changeOrigin: true,
// 保持路径不变
rewrite: path => path,
},
// '/api/v1': {
// target: 'https://console.tianyuanapi.com',
// target: 'http://localhost:8080',
// changeOrigin: true,
// // 保持路径不变
// rewrite: path => path,
// },
'/api/v1': {
target: 'https://console.tianyuanapi.com',
changeOrigin: true,
// 保持路径不变
rewrite: path => path,
},
},
},
})