Merge pull request #4461 from yardbirds0/feat/account-homepage-link

feat: 支持从 API Key 账号名称跳转上游站点主页
This commit is contained in:
Wesley Liddick
2026-07-17 09:34:50 +08:00
committed by GitHub
2 changed files with 69 additions and 3 deletions
+25 -1
View File
@@ -252,7 +252,24 @@
</template>
<template #cell-name="{ row, value }">
<div class="flex flex-col">
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
<HelpTooltip
v-if="accountHomepageUrl(row)"
:content="accountHomepageUrl(row)"
width-class="w-max max-w-sm break-all"
class="-ml-1 self-start"
>
<template #trigger>
<a
:href="accountHomepageUrl(row)"
target="_blank"
rel="noopener noreferrer"
class="border-b border-dotted border-gray-300 font-medium text-gray-900 dark:border-gray-600 dark:text-white"
>
{{ value }}
</a>
</template>
</HelpTooltip>
<span v-else class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
<span
v-if="accountDisplayEmail(row)"
class="text-xs text-gray-500 dark:text-gray-400 truncate max-w-[200px]"
@@ -523,6 +540,7 @@ import { buildOpenAIUsageRefreshKey } from '@/utils/accountUsageRefresh'
import { formatDateTime, formatRelativeTime } from '@/utils/format'
import { proxyExpiryBadgeClass, proxyExpiryLabelKey } from '@/utils/proxyExpiry'
import { extractApiErrorMessage } from '@/utils/apiError'
import { sanitizeUrl } from '@/utils/url'
import type { Account, AccountPlatform, AccountSchedulerGroupScore, AccountType, Proxy as AccountProxy, AdminGroup, WindowStats, ClaudeModel, UpstreamBillingProbeSettings, UpstreamBillingProbeSnapshot } from '@/types'
const { t } = useI18n()
@@ -1295,6 +1313,12 @@ function accountDisplayEmail(row: any): string {
return row.extra?.email_address || row.extra?.email || row.credentials?.email || row.parent_email || ''
}
function accountHomepageUrl(row: Account): string {
if (row.type !== 'apikey' || typeof row.credentials?.base_url !== 'string') return ''
const baseUrl = sanitizeUrl(row.credentials.base_url)
return baseUrl ? new URL(baseUrl).origin : ''
}
type OpenAICompactBadgeState = 'active' | 'blocked' | 'auto'
function getOpenAICompactState(row: any): OpenAICompactBadgeState | null {
@@ -5,6 +5,7 @@ import AccountsView from '../AccountsView.vue'
import AccountActionMenu from '@/components/admin/account/AccountActionMenu.vue'
import PlatformTypeBadge from '@/components/common/PlatformTypeBadge.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import HelpTooltip from '@/components/common/HelpTooltip.vue'
// 外审 F2:AccountActionMenu emit 'create-spark-shadow',但 AccountsView 此前未监听,
// 导致按钮点击无效。本测试通过真实组件引用 emit 该事件,断言父页面接线调用 API。
@@ -207,7 +208,7 @@ describe('admin AccountsView — 外审 F2:spark 影子创建接线', () => {
})
})
// Task 6: 影子行 parent_* OR 兜底展示
// 账号行展示
const mountViewWithRow = () =>
mount(AccountsView, {
global: {
@@ -255,7 +256,7 @@ const mountViewWithRow = () =>
}
})
describe('admin AccountsView — 影子行 parent_* OR 兜底展示', () => {
describe('admin AccountsView — 账号行展示', () => {
beforeEach(() => {
localStorage.clear()
for (const fn of [listAccounts, listWithEtag, getBatchTodayStats, getAllProxies, getAllGroups, duplicateAccount, createSparkShadow, showSuccess, showError]) {
@@ -304,6 +305,47 @@ describe('admin AccountsView — 影子行 parent_* OR 兜底展示', () => {
wrapper.unmount()
})
it('仅将具有安全 base_url 的 API Key 账号名称链接到站点主页', async () => {
listAccounts.mockResolvedValue({
items: [
{ id: 101, name: 'relay-account', platform: 'openai', type: 'apikey', credentials: { base_url: 'https://relay.example.com/api/v1/' } },
{ id: 102, name: 'oauth-account', platform: 'openai', type: 'oauth', credentials: { base_url: 'https://oauth.example.com/v1' } },
{ id: 103, name: 'invalid-url', platform: 'openai', type: 'apikey', credentials: { base_url: 'javascript:alert(1)' } },
],
total: 3,
page: 1,
page_size: 20,
pages: 1,
})
const wrapper = mountViewWithRow()
await flushPromises()
const links = wrapper.findAll('a')
expect(links).toHaveLength(1)
const [link] = links
expect(link.text()).toBe('relay-account')
expect(link.attributes()).toMatchObject({
href: 'https://relay.example.com',
target: '_blank',
rel: 'noopener noreferrer',
})
expect(link.classes()).toEqual(expect.arrayContaining([
'border-dotted',
'text-gray-900',
'dark:text-white',
]))
expect(link.classes()).not.toContain('text-primary-600')
const tooltip = wrapper.findComponent(HelpTooltip)
expect(tooltip.props('content')).toBe('https://relay.example.com')
expect(tooltip.props('widthClass')).toBe('w-max max-w-sm break-all')
expect(tooltip.classes()).toEqual(expect.arrayContaining(['self-start']))
expect(wrapper.text()).toContain('oauth-account')
expect(wrapper.text()).toContain('invalid-url')
wrapper.unmount()
})
it('passes fresh Grok billing and quota snapshots before stale credential fallbacks', async () => {
const grokAccounts = [
{