Merge branch 'Wei-Shaw:main' into main

This commit is contained in:
xueshiji
2026-07-01 14:01:03 +08:00
committed by GitHub
95 changed files with 6388 additions and 179 deletions
+14 -1
View File
@@ -781,6 +781,18 @@ export async function resetOpenAIQuota(id: number): Promise<OpenAIQuotaResetResu
return data
}
export interface SparkShadowCreatePayload {
name?: string
priority?: number
concurrency?: number
group_ids?: number[]
}
export async function createSparkShadow(parentId: number, payload: SparkShadowCreatePayload): Promise<Account> {
const { data } = await apiClient.post<Account>(`/admin/accounts/${parentId}/shadow`, payload)
return data
}
export const accountsAPI = {
list,
listWithEtag,
@@ -825,7 +837,8 @@ export const accountsAPI = {
setPrivacy,
revertProxyFallback,
queryOpenAIQuota,
resetOpenAIQuota
resetOpenAIQuota,
createSparkShadow
}
export default accountsAPI
@@ -1296,7 +1296,7 @@
</div>
</div>
<div>
<div v-if="!isSparkShadow">
<div class="mb-1 flex items-center gap-2">
<label class="input-label mb-0">{{ t('admin.accounts.proxy') }}</label>
<ProxyAdBanner />
@@ -2454,6 +2454,10 @@ const { t } = useI18n()
const appStore = useAppStore()
const authStore = useAuthStore()
// Spark 影子账号(parent_account_id 非空):代理恒继承母账号,不可独立编辑(外审 B/P1),
// 故隐藏代理选择器。
const isSparkShadow = computed(() => props.account?.parent_account_id != null)
// Platform-specific hint for Base URL
const baseUrlHint = computed(() => {
if (!props.account) return t('admin.accounts.baseUrlHint')
@@ -2930,6 +2934,28 @@ const loadModelRestrictionFromMapping = (rawMapping?: Record<string, unknown>) =
const buildModelRestrictionMapping = () =>
buildModelMappingObject('combined', allowedModels.value, modelMappings.value)
const applyOpenAIModelMappingCredentials = (credentials: Record<string, unknown>) => {
const shouldApplyModelMapping = !openaiPassthroughEnabled.value
if (shouldApplyModelMapping) {
const modelMapping = buildModelRestrictionMapping()
if (modelMapping) {
credentials.model_mapping = modelMapping
} else {
delete credentials.model_mapping
}
} else if (!credentials.model_mapping) {
delete credentials.model_mapping
}
const compactModelMapping = buildModelMappingObject('mapping', [], openAICompactModelMappings.value)
if (compactModelMapping) {
credentials.compact_model_mapping = compactModelMapping
} else {
delete credentials.compact_model_mapping
}
}
const syncFormFromAccount = (newAccount: Account | null) => {
if (!newAccount) {
return
@@ -3922,28 +3948,12 @@ const handleSubmit = async () => {
// OpenAI OAuth: persist model mapping to credentials
if (props.account.platform === 'openai' && props.account.type === 'oauth') {
const currentCredentials = (updatePayload.credentials as Record<string, unknown>) ||
((props.account.credentials as Record<string, unknown>) || {})
const currentCredentials = isSparkShadow.value
? {}
: (updatePayload.credentials as Record<string, unknown>) ||
((props.account.credentials as Record<string, unknown>) || {})
const newCredentials: Record<string, unknown> = { ...currentCredentials }
const shouldApplyModelMapping = !openaiPassthroughEnabled.value
if (shouldApplyModelMapping) {
const modelMapping = buildModelRestrictionMapping()
if (modelMapping) {
newCredentials.model_mapping = modelMapping
} else {
delete newCredentials.model_mapping
}
} else if (currentCredentials.model_mapping) {
// 透传模式保留现有映射
newCredentials.model_mapping = currentCredentials.model_mapping
}
const compactModelMapping = buildModelMappingObject('mapping', [], openAICompactModelMappings.value)
if (compactModelMapping) {
newCredentials.compact_model_mapping = compactModelMapping
} else {
delete newCredentials.compact_model_mapping
}
applyOpenAIModelMappingCredentials(newCredentials)
updatePayload.credentials = newCredentials
}
@@ -119,10 +119,15 @@ const data = ref<OpenAIQuotaUsage | null>(null)
const resetMessage = ref<string | null>(null)
const showResetConfirm = ref(false)
// 影子账号的额度查询会 resolve 到母账号,但影子本身不支持重置(后端返回 409);
// 重置必须在母账号上进行。前端据此禁用影子的重置入口(外审 F6)。
const isShadow = computed(() => props.account.parent_account_id != null)
const availableResetCount = computed(() => data.value?.rate_limit_reset_credits?.available_count ?? 0)
const canReset = computed(() => availableResetCount.value > 0)
const canReset = computed(() => availableResetCount.value > 0 && !isShadow.value)
const resetButtonTitle = computed(() => {
if (isShadow.value) return t('admin.accounts.openaiQuotaReset.resetTooltipShadow')
if (!data.value) return t('admin.accounts.openaiQuotaReset.resetTooltipNeedQuery')
if (!canReset.value) return t('admin.accounts.openaiQuotaReset.resetTooltipNoCredits')
return t('admin.accounts.openaiQuotaReset.resetTooltipReady')
@@ -1,10 +1,11 @@
import { describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent } from 'vue'
import { mount } from '@vue/test-utils'
const { updateAccountMock, checkMixedChannelRiskMock } = vi.hoisted(() => ({
const { updateAccountMock, checkMixedChannelRiskMock, authIsSimpleMode } = vi.hoisted(() => ({
updateAccountMock: vi.fn(),
checkMixedChannelRiskMock: vi.fn()
checkMixedChannelRiskMock: vi.fn(),
authIsSimpleMode: { value: true }
}))
vi.mock('@/stores/app', () => ({
@@ -17,7 +18,9 @@ vi.mock('@/stores/app', () => ({
vi.mock('@/stores/auth', () => ({
useAuthStore: () => ({
isSimpleMode: true
get isSimpleMode() {
return authIsSimpleMode.value
}
})
}))
@@ -115,6 +118,28 @@ const SelectStub = defineComponent({
`
})
const GroupSelectorStub = defineComponent({
name: 'GroupSelector',
props: {
modelValue: {
type: Array,
default: () => []
}
},
emits: ['update:modelValue'],
template: `
<div data-testid="group-selector">
<button
type="button"
data-testid="set-shadow-group"
@click="$emit('update:modelValue', [7])"
>
group
</button>
</div>
`
})
function buildAccount() {
return {
id: 1,
@@ -141,6 +166,30 @@ function buildAccount() {
} as any
}
function buildOpenAISparkShadowAccount() {
const account = buildAccount()
return {
...account,
id: 4,
name: 'OpenAI Spark Shadow',
type: 'oauth',
parent_account_id: 1,
credentials: {
access_token: 'parent-access-token',
refresh_token: 'parent-refresh-token',
api_key: 'sk-parent',
base_url: 'https://api.openai.com',
model_mapping: {
'gpt-5.3-codex-spark': 'gpt-5.3-codex-spark'
},
compact_model_mapping: {
'gpt-5.3-codex-spark': 'gpt-5.3-codex-spark-compact'
}
},
group_ids: []
} as any
}
function buildVertexAccount() {
return {
id: 2,
@@ -206,7 +255,7 @@ function mountModal(account = buildAccount()) {
Select: SelectStub,
Icon: true,
ProxySelector: true,
GroupSelector: true,
GroupSelector: GroupSelectorStub,
ModelWhitelistSelector: ModelWhitelistSelectorStub
}
}
@@ -214,6 +263,10 @@ function mountModal(account = buildAccount()) {
}
describe('EditAccountModal', () => {
beforeEach(() => {
authIsSimpleMode.value = true
})
it('reopening the same account rehydrates the OpenAI whitelist from props', async () => {
const account = buildAccount()
updateAccountMock.mockReset()
@@ -293,6 +346,32 @@ describe('EditAccountModal', () => {
})
})
it('only submits model mapping credentials when saving an OpenAI spark shadow account', async () => {
authIsSimpleMode.value = false
const account = buildOpenAISparkShadowAccount()
updateAccountMock.mockReset()
checkMixedChannelRiskMock.mockReset()
checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false })
updateAccountMock.mockResolvedValue(account)
const wrapper = mountModal(account)
await wrapper.get('[data-testid="set-shadow-group"]').trigger('click')
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
expect(updateAccountMock).toHaveBeenCalledTimes(1)
const payload = updateAccountMock.mock.calls[0]?.[1]
expect(payload?.group_ids).toEqual([7])
expect(payload?.credentials).toEqual({
model_mapping: {
'gpt-5.3-codex-spark': 'gpt-5.3-codex-spark'
},
compact_model_mapping: {
'gpt-5.3-codex-spark': 'gpt-5.3-codex-spark-compact'
}
})
})
it('submits OpenAI APIKey Responses support override mode', async () => {
const account = buildAccount()
account.extra = {
@@ -0,0 +1,67 @@
import { describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import OpenAIQuotaResetCell from '../OpenAIQuotaResetCell.vue'
import type { Account } from '@/types'
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({ t: (key: string) => key }),
}
})
function makeAccount(overrides: Partial<Account>): Account {
return {
id: 1,
name: 'acc',
platform: 'openai',
type: 'oauth',
proxy_id: null,
concurrency: 3,
priority: 50,
status: 'active',
error_message: null,
last_used_at: null,
expires_at: null,
auto_pause_on_expired: false,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
schedulable: true,
rate_limited_at: null,
rate_limit_reset_at: null,
overload_until: null,
temp_unschedulable_until: null,
temp_unschedulable_reason: null,
session_window_start: null,
session_window_end: null,
session_window_status: null,
...overrides,
}
}
// 第二个按钮(橙色)是 reset 按钮::disabled="resetting||loading||!canReset" :title="resetButtonTitle"
const resetButton = (wrapper: ReturnType<typeof mount>) =>
wrapper.findAll('button')[1]
describe('OpenAIQuotaResetCell — 外审 F6:影子禁用重置', () => {
it('影子账号(parent_account_id 非空)的 reset 按钮被禁用且提示在母账号重置', () => {
const account = makeAccount({ parent_account_id: 100 })
const wrapper = mount(OpenAIQuotaResetCell, { props: { account } })
const btn = resetButton(wrapper)
expect(btn.attributes('disabled')).toBeDefined()
expect(btn.attributes('title')).toBe('admin.accounts.openaiQuotaReset.resetTooltipShadow')
wrapper.unmount()
})
it('普通账号(无 parent_account_id)未查询时禁用原因是「需先查询」而非影子提示', () => {
const account = makeAccount({ parent_account_id: null })
const wrapper = mount(OpenAIQuotaResetCell, { props: { account } })
const btn = resetButton(wrapper)
// 未加载数据时本就 disabled(无次数),但提示语必须是 needQuery,不得是 shadow 提示。
expect(btn.attributes('title')).toBe('admin.accounts.openaiQuotaReset.resetTooltipNeedQuery')
wrapper.unmount()
})
})
@@ -22,7 +22,8 @@
<Icon name="clock" size="sm" class="text-orange-500" />
{{ t('admin.scheduledTests.schedule') }}
</button>
<template v-if="account.type === 'oauth' || account.type === 'setup-token'">
<!-- 影子账号不持凭据:重授权/刷新 token 对其无效(后端拒绝),故隐藏(外审 G4)。 -->
<template v-if="(account.type === 'oauth' || account.type === 'setup-token') && !isShadow">
<button @click="$emit('reauth', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-blue-600 hover:bg-gray-100 dark:hover:bg-dark-700">
<Icon name="link" size="sm" />
{{ t('admin.accounts.reAuthorize') }}
@@ -32,6 +33,10 @@
{{ t('admin.accounts.refreshToken') }}
</button>
</template>
<button v-if="isOpenAIOAuthParent" @click="$emit('create-spark-shadow', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-amber-600 hover:bg-gray-100 dark:hover:bg-dark-700">
<Icon name="sparkles" size="sm" />
{{ t('admin.accounts.createSparkShadow') }}
</button>
<button v-if="supportsPrivacy" @click="$emit('set-privacy', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-emerald-600 hover:bg-gray-100 dark:hover:bg-dark-700">
<Icon name="shield" size="sm" />
{{ t('admin.accounts.setPrivacy') }}
@@ -59,7 +64,7 @@ import { Icon } from '@/components/icons'
import type { Account } from '@/types'
const props = defineProps<{ show: boolean; account: Account | null; position: { top: number; left: number } | null }>()
const emit = defineEmits(['close', 'test', 'stats', 'schedule', 'reauth', 'refresh-token', 'recover-state', 'reset-quota', 'set-privacy'])
const emit = defineEmits(['close', 'test', 'stats', 'schedule', 'reauth', 'refresh-token', 'recover-state', 'reset-quota', 'set-privacy', 'create-spark-shadow'])
const { t } = useI18n()
const isRateLimited = computed(() => {
if (props.account?.rate_limit_reset_at && new Date(props.account.rate_limit_reset_at) > new Date()) {
@@ -81,7 +86,11 @@ const hasRecoverableState = computed(() => {
})
const isAntigravityOAuth = computed(() => props.account?.platform === 'antigravity' && props.account?.type === 'oauth')
const isOpenAIOAuth = computed(() => props.account?.platform === 'openai' && props.account?.type === 'oauth')
const supportsPrivacy = computed(() => isAntigravityOAuth.value || isOpenAIOAuth.value)
// 影子账号(链接型,持 parent_account_id)不持凭据、type 不可变,凭据/隐私类操作对其无效。
const isShadow = computed(() => props.account?.parent_account_id != null)
// A "parent" OpenAI OAuth account is one that is NOT itself a shadow (parent_account_id == null)
const isOpenAIOAuthParent = computed(() => isOpenAIOAuth.value && !isShadow.value)
const supportsPrivacy = computed(() => (isAntigravityOAuth.value || isOpenAIOAuth.value) && !isShadow.value)
const hasQuotaLimit = computed(() => {
return (props.account?.type === 'apikey' || props.account?.type === 'bedrock') && (
(props.account?.quota_limit ?? 0) > 0 ||
@@ -0,0 +1,127 @@
import { describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import AccountActionMenu from '../AccountActionMenu.vue'
import type { Account } from '@/types'
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({
t: (key: string) => key,
}),
}
})
function makeAccount(overrides: Partial<Account>): Account {
return {
id: 1,
name: 'test-account',
platform: 'openai',
type: 'oauth',
proxy_id: null,
concurrency: 3,
priority: 50,
status: 'active',
error_message: null,
last_used_at: null,
expires_at: null,
auto_pause_on_expired: false,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
schedulable: true,
rate_limited_at: null,
rate_limit_reset_at: null,
overload_until: null,
temp_unschedulable_until: null,
temp_unschedulable_reason: null,
session_window_start: null,
session_window_end: null,
session_window_status: null,
...overrides,
}
}
const position = { top: 100, left: 100 }
// AccountActionMenu uses <Teleport to="body">; content is rendered in document.body, not in wrapper.
const getBodyText = () => document.body.textContent ?? ''
const getBodyButtons = () => Array.from(document.body.querySelectorAll('button'))
describe('AccountActionMenu — spark shadow 按钮可见性', () => {
it('OpenAI OAuth 母账号(无 parent_account_id)显示「创建 spark 影子」按钮', () => {
const account = makeAccount({ platform: 'openai', type: 'oauth', parent_account_id: null })
const wrapper = mount(AccountActionMenu, {
props: { show: true, account, position },
attachTo: document.body,
})
expect(getBodyText()).toContain('admin.accounts.createSparkShadow')
wrapper.unmount()
})
it('影子账号(parent_account_id 非 null)隐藏「创建 spark 影子」按钮', () => {
const account = makeAccount({ platform: 'openai', type: 'oauth', parent_account_id: 42 })
const wrapper = mount(AccountActionMenu, {
props: { show: true, account, position },
attachTo: document.body,
})
expect(getBodyText()).not.toContain('admin.accounts.createSparkShadow')
wrapper.unmount()
})
it('非 OpenAI 账号隐藏「创建 spark 影子」按钮', () => {
const account = makeAccount({ platform: 'antigravity', type: 'oauth', parent_account_id: null })
const wrapper = mount(AccountActionMenu, {
props: { show: true, account, position },
attachTo: document.body,
})
expect(getBodyText()).not.toContain('admin.accounts.createSparkShadow')
wrapper.unmount()
})
it('影子账号隐藏凭据/隐私类操作(重授权/刷新token/隐私)— 外审 G4', () => {
const account = makeAccount({ platform: 'openai', type: 'oauth', parent_account_id: 42 })
const wrapper = mount(AccountActionMenu, {
props: { show: true, account, position },
attachTo: document.body,
})
const body = getBodyText()
expect(body).not.toContain('admin.accounts.reAuthorize')
expect(body).not.toContain('admin.accounts.refreshToken')
expect(body).not.toContain('admin.accounts.setPrivacy')
wrapper.unmount()
})
it('普通 OpenAI OAuth 母账号仍显示凭据/隐私类操作', () => {
const account = makeAccount({ platform: 'openai', type: 'oauth', parent_account_id: null })
const wrapper = mount(AccountActionMenu, {
props: { show: true, account, position },
attachTo: document.body,
})
const body = getBodyText()
expect(body).toContain('admin.accounts.reAuthorize')
expect(body).toContain('admin.accounts.setPrivacy')
wrapper.unmount()
})
it('点击按钮触发 create-spark-shadow 事件并携带 account', async () => {
const account = makeAccount({ platform: 'openai', type: 'oauth', parent_account_id: null })
const wrapper = mount(AccountActionMenu, {
props: { show: true, account, position },
attachTo: document.body,
})
// Content is teleported to body — find button by text there
const sparkBtn = getBodyButtons().find(b => b.textContent?.includes('admin.accounts.createSparkShadow'))
expect(sparkBtn).toBeDefined()
sparkBtn!.click()
await wrapper.vm.$nextTick()
const emitted = wrapper.emitted('create-spark-shadow')
expect(emitted).toBeTruthy()
expect(emitted![0][0]).toMatchObject({ id: account.id, platform: 'openai' })
wrapper.unmount()
})
})
+6
View File
@@ -3108,6 +3108,7 @@ export default {
dataExportConfirmMessage: 'The exported data contains sensitive account and proxy information. Store it securely.',
dataExportConfirm: 'Confirm Export',
dataExported: 'Data exported successfully',
dataExportedSkippedShadows: 'Data exported. Skipped {count} spark shadow account(s): their scheduling config is not included in the backup; recreate and re-tune them after restore.',
dataExportFailed: 'Failed to export data',
dataImportTitle: 'Import Data',
dataImportHint: 'Upload the exported JSON file to import accounts and proxies.',
@@ -3417,6 +3418,10 @@ export default {
revertProxy: 'Revert proxy',
revertProxySuccess: 'Successfully reverted to original proxy',
revertProxyFailed: 'Failed to revert proxy',
createSparkShadow: 'Create Spark Shadow',
createSparkShadowConfirm: 'Create a spark shadow account linked to "{name}"? It shares the parent\'s credentials and serves only spark models.',
createSparkShadowSuccess: 'Spark shadow account created',
createSparkShadowFailed: 'Failed to create spark shadow account',
resetStatus: 'Reset Status',
statusReset: 'Account status reset successfully',
failedToResetStatus: 'Failed to reset account status',
@@ -4244,6 +4249,7 @@ export default {
resetTooltipReady: 'Consume 1 reset credit to immediately restore the window',
resetTooltipNeedQuery: 'Click Credits first to load the available count',
resetTooltipNoCredits: 'No reset credits available',
resetTooltipShadow: 'Spark shadow accounts cannot reset credits; reset on the parent account',
noCreditsAvailable: 'No reset credits available',
resetSuccess: 'Reset {windows} window(s)',
confirmTitle: 'Confirm Weekly Limit Reset',
+6
View File
@@ -3183,6 +3183,7 @@ export default {
dataExportConfirmMessage: '导出的数据包含账号与代理的敏感信息,请妥善保存。',
dataExportConfirm: '确认导出',
dataExported: '数据导出成功',
dataExportedSkippedShadows: '数据已导出。已跳过 {count} 个 spark 影子账号:其调度配置不在备份内,还原后需在重建的影子上重新调优。',
dataExportFailed: '数据导出失败',
dataImportTitle: '导入数据',
dataImportHint: '上传导出的 JSON 文件以批量导入账号与代理。',
@@ -3485,6 +3486,7 @@ export default {
resetTooltipReady: '消耗 1 次重置次数以立即恢复当前窗口',
resetTooltipNeedQuery: '先点击「次数」加载剩余重置次数',
resetTooltipNoCredits: '没有可用的重置次数',
resetTooltipShadow: 'Spark 影子账号不能重置次数;请在母账号上重置',
noCreditsAvailable: '没有可用的重置次数',
resetSuccess: '已重置 {windows} 个窗口',
confirmTitle: '确认重置周限',
@@ -3588,6 +3590,10 @@ export default {
revertProxy: '切回原代理',
revertProxySuccess: '已成功切回原代理',
revertProxyFailed: '切回原代理失败',
createSparkShadow: '创建 Spark 影子账号',
createSparkShadowConfirm: '为「{name}」创建链接型 Spark 影子账号?影子共享母账号凭据、仅服务 spark 模型。',
createSparkShadowSuccess: 'Spark 影子账号已创建',
createSparkShadowFailed: '创建 Spark 影子账号失败',
resetStatus: '重置状态',
statusReset: '账号状态已重置',
failedToResetStatus: '重置账号状态失败',
+12
View File
@@ -931,6 +931,16 @@ export interface Account {
current_window_cost?: number | null // 当前窗口费用
active_sessions?: number | null // 当前活跃会话数
current_rpm?: number | null // 当前分钟 RPM 计数
// 影子账号关系(spark 维度影子)
parent_account_id?: number | null
quota_dimension?: string
// 影子账号回填的母账号信息(仅影子非空)
parent_email?: string
parent_plan_type?: string
parent_privacy_mode?: string
parent_subscription_expires_at?: string
parent_chatgpt_account_id?: string
}
// Account Usage types
@@ -1140,6 +1150,8 @@ export interface AdminDataPayload {
exported_at: string
proxies: AdminDataProxy[]
accounts: AdminDataAccount[]
// 导出时被排除的 spark 影子账号数量(影子不持凭据、其调度配置不在备份范围)。
skipped_shadows?: number
}
export interface AdminDataProxy {
+42 -6
View File
@@ -217,11 +217,11 @@
<div class="flex flex-col">
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
<span
v-if="row.extra?.email_address || row.extra?.email || row.credentials?.email"
v-if="accountDisplayEmail(row)"
class="text-xs text-gray-500 dark:text-gray-400 truncate max-w-[200px]"
:title="String(row.extra?.email_address || row.extra?.email || row.credentials?.email)"
:title="accountDisplayEmail(row) + (row.parent_chatgpt_account_id ? ' · ' + row.parent_chatgpt_account_id : '')"
>
{{ row.extra?.email_address || row.extra?.email || row.credentials?.email }}
{{ accountDisplayEmail(row) }}
</span>
</div>
</template>
@@ -232,7 +232,10 @@
<template #cell-platform_type="{ row }">
<div class="flex min-w-0 flex-col gap-1">
<div class="flex flex-wrap items-center gap-1">
<PlatformTypeBadge :platform="row.platform" :type="row.type" :plan-type="row.credentials?.plan_type" :privacy-mode="row.extra?.privacy_mode" :subscription-expires-at="row.credentials?.subscription_expires_at" />
<PlatformTypeBadge :platform="row.platform" :type="row.type"
:plan-type="row.credentials?.plan_type || row.parent_plan_type"
:privacy-mode="row.extra?.privacy_mode || row.parent_privacy_mode"
:subscription-expires-at="row.credentials?.subscription_expires_at || row.parent_subscription_expires_at" />
<span
v-if="getAntigravityTierLabel(row)"
:class="['inline-block rounded px-1.5 py-0.5 text-[10px] font-medium', getAntigravityTierClass(row)]"
@@ -371,7 +374,7 @@
<AccountTestModal :show="showTest" :account="testingAcc" @close="closeTestModal" />
<AccountStatsModal :show="showStats" :account="statsAcc" @close="closeStatsModal" />
<ScheduledTestsPanel :show="showSchedulePanel" :account-id="scheduleAcc?.id ?? null" :model-options="scheduleModelOptions" @close="closeSchedulePanel" />
<AccountActionMenu :show="menu.show" :account="menu.acc" :position="menu.pos" @close="menu.show = false" @test="handleTest" @stats="handleViewStats" @schedule="handleSchedule" @reauth="handleReAuth" @refresh-token="handleRefresh" @recover-state="handleRecoverState" @reset-quota="handleResetQuota" @set-privacy="handleSetPrivacy" />
<AccountActionMenu :show="menu.show" :account="menu.acc" :position="menu.pos" @close="menu.show = false" @test="handleTest" @stats="handleViewStats" @schedule="handleSchedule" @reauth="handleReAuth" @refresh-token="handleRefresh" @recover-state="handleRecoverState" @reset-quota="handleResetQuota" @set-privacy="handleSetPrivacy" @create-spark-shadow="handleCreateSparkShadow" />
<SyncFromCrsModal :show="showSync" @close="showSync = false" @synced="reload" />
<ImportDataModal :show="showImportData" @close="showImportData = false" @imported="handleDataImported" />
<BulkEditAccountModal
@@ -387,6 +390,7 @@
/>
<TempUnschedStatusModal :show="showTempUnsched" :account="tempUnschedAcc" @close="showTempUnsched = false" @reset="handleTempUnschedReset" />
<ConfirmDialog :show="showDeleteDialog" :title="t('admin.accounts.deleteAccount')" :message="t('admin.accounts.deleteConfirm', { name: deletingAcc?.name })" :confirm-text="t('common.delete')" :cancel-text="t('common.cancel')" :danger="true" @confirm="confirmDelete" @cancel="showDeleteDialog = false" />
<ConfirmDialog :show="showCreateShadowDialog" :title="t('admin.accounts.createSparkShadow')" :message="t('admin.accounts.createSparkShadowConfirm', { name: creatingShadowAcc?.name })" @confirm="confirmCreateSparkShadow" @cancel="showCreateShadowDialog = false" />
<ConfirmDialog :show="showExportDataDialog" :title="t('admin.accounts.dataExport')" :message="t('admin.accounts.dataExportConfirmMessage')" :confirm-text="t('admin.accounts.dataExportConfirm')" :cancel-text="t('common.cancel')" @confirm="handleExportData" @cancel="showExportDataDialog = false">
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input type="checkbox" class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500" v-model="includeProxyOnExport" />
@@ -496,6 +500,7 @@ const showBulkEdit = ref(false)
const bulkEditTarget = ref<AccountBulkEditTarget | null>(null)
const showTempUnsched = ref(false)
const showDeleteDialog = ref(false)
const showCreateShadowDialog = ref(false)
const showReAuth = ref(false)
const showTest = ref(false)
const showStats = ref(false)
@@ -504,6 +509,7 @@ const showTLSFingerprintProfiles = ref(false)
const edAcc = ref<Account | null>(null)
const tempUnschedAcc = ref<Account | null>(null)
const deletingAcc = ref<Account | null>(null)
const creatingShadowAcc = ref<Account | null>(null)
const reAuthAcc = ref<Account | null>(null)
const testingAcc = ref<Account | null>(null)
const statsAcc = ref<Account | null>(null)
@@ -1078,6 +1084,12 @@ function getAntigravityTierLabel(row: any): string | null {
}
}
// 账号显示邮箱:优先账号自身(extra/credentials),影子账号回退母账号 parent_email。
// 供名称单元格 v-if/标题/文本三处共用,避免同一回退链在模板里重复三次。
function accountDisplayEmail(row: any): string {
return row.extra?.email_address || row.extra?.email || row.credentials?.email || row.parent_email || ''
}
type OpenAICompactBadgeState = 'active' | 'blocked' | 'auto'
function getOpenAICompactState(row: any): OpenAICompactBadgeState | null {
@@ -1548,7 +1560,13 @@ const handleExportData = async () => {
link.download = filename
link.click()
URL.revokeObjectURL(url)
appStore.showSuccess(t('admin.accounts.dataExported'))
// spark 影子账号被后端排除出备份(其凭据透传母账号、调度配置不可经凭据型导入重建);
// 跳过非零时明确提示用户,避免「下载成功但少了账号」的静默丢失。
if (dataPayload.skipped_shadows && dataPayload.skipped_shadows > 0) {
appStore.showWarning(t('admin.accounts.dataExportedSkippedShadows', { count: dataPayload.skipped_shadows }))
} else {
appStore.showSuccess(t('admin.accounts.dataExported'))
}
} catch (error: any) {
appStore.showError(error?.message || t('admin.accounts.dataExportFailed'))
} finally {
@@ -1652,6 +1670,24 @@ const onRevertFallback = async (a: Account) => {
appStore.showError(error?.response?.data?.message || t('admin.accounts.revertProxyFailed'))
}
}
const handleCreateSparkShadow = (a: Account) => {
creatingShadowAcc.value = a
showCreateShadowDialog.value = true
}
const confirmCreateSparkShadow = async () => {
const a = creatingShadowAcc.value
if (!a) return
try {
await adminAPI.accounts.createSparkShadow(a.id, { name: `${a.name} (Spark)` })
showCreateShadowDialog.value = false
creatingShadowAcc.value = null
appStore.showSuccess(t('admin.accounts.createSparkShadowSuccess'))
reload()
} catch (error: any) {
console.error('Failed to create spark shadow:', error)
appStore.showError(error?.response?.data?.message || t('admin.accounts.createSparkShadowFailed'))
}
}
const handleDelete = (a: Account) => { deletingAcc.value = a; showDeleteDialog.value = true }
const confirmDelete = async () => { if(!deletingAcc.value) return; try { await adminAPI.accounts.delete(deletingAcc.value.id); showDeleteDialog.value = false; deletingAcc.value = null; reload() } catch (error) { console.error('Failed to delete account:', error) } }
const handleToggleSchedulable = async (a: Account) => {
@@ -0,0 +1,256 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
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'
// 外审 F2:AccountActionMenu emit 'create-spark-shadow',但 AccountsView 此前未监听,
// 导致按钮点击无效。本测试通过真实组件引用 emit 该事件,断言父页面接线调用 API。
const {
listAccounts,
listWithEtag,
getBatchTodayStats,
getAllProxies,
getAllGroups,
createSparkShadow,
showSuccess,
showError
} = vi.hoisted(() => ({
listAccounts: vi.fn(),
listWithEtag: vi.fn(),
getBatchTodayStats: vi.fn(),
getAllProxies: vi.fn(),
getAllGroups: vi.fn(),
createSparkShadow: vi.fn(),
showSuccess: vi.fn(),
showError: vi.fn()
}))
vi.mock('@/api/admin', () => ({
adminAPI: {
accounts: {
list: listAccounts,
listWithEtag,
getBatchTodayStats,
createSparkShadow,
delete: vi.fn(),
batchClearError: vi.fn(),
batchRefresh: vi.fn(),
toggleSchedulable: vi.fn()
},
proxies: { getAll: getAllProxies },
groups: { getAll: getAllGroups }
}
}))
vi.mock('@/stores/app', () => ({
useAppStore: () => ({ showError, showSuccess, showInfo: vi.fn() })
}))
vi.mock('@/stores/auth', () => ({
useAuthStore: () => ({ token: 'test-token' })
}))
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({ t: (key: string) => key })
}
})
const mountView = () =>
mount(AccountsView, {
global: {
stubs: {
AppLayout: { template: '<div><slot /></div>' },
TablePageLayout: {
template: '<div><slot name="filters" /><slot name="table" /><slot name="pagination" /></div>'
},
DataTable: true,
Pagination: true,
ConfirmDialog: true,
AccountTableActions: { template: '<div><slot name="beforeCreate" /><slot name="after" /></div>' },
AccountTableFilters: { template: '<div></div>' },
AccountBulkActionsBar: true,
AccountActionMenu: true,
ImportDataModal: true,
ReAuthAccountModal: true,
AccountTestModal: true,
AccountStatsModal: true,
ScheduledTestsPanel: true,
SyncFromCrsModal: true,
TempUnschedStatusModal: true,
ErrorPassthroughRulesModal: true,
TLSFingerprintProfilesModal: true,
CreateAccountModal: true,
EditAccountModal: true,
BulkEditAccountModal: true,
PlatformTypeBadge: true,
AccountCapacityCell: true,
AccountStatusIndicator: true,
AccountTodayStatsCell: true,
AccountGroupsCell: true,
AccountUsageCell: true,
Icon: true
}
}
})
describe('admin AccountsView — 外审 F2:spark 影子创建接线', () => {
beforeEach(() => {
localStorage.clear()
for (const fn of [listAccounts, listWithEtag, getBatchTodayStats, getAllProxies, getAllGroups, createSparkShadow, showSuccess, showError]) {
fn.mockReset()
}
listAccounts.mockResolvedValue({ items: [], total: 0, page: 1, page_size: 20, pages: 0 })
listWithEtag.mockResolvedValue({ notModified: true, etag: null, data: null })
getBatchTodayStats.mockResolvedValue({ stats: {} })
getAllProxies.mockResolvedValue([])
getAllGroups.mockResolvedValue([])
createSparkShadow.mockResolvedValue({ id: 999, name: 'parent-acc (Spark)' })
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('AccountActionMenu 的 create-spark-shadow 事件触发 createSparkShadow API + 成功提示', async () => {
const wrapper = mountView()
await flushPromises()
const menu = wrapper.findComponent(AccountActionMenu)
expect(menu.exists()).toBe(true)
menu.vm.$emit('create-spark-shadow', { id: 42, name: 'parent-acc' })
await flushPromises()
// 不再用原生 confirm,改用应用内 ConfirmDialog:先弹出,点确认才调 API
const dialog = wrapper.findAllComponents(ConfirmDialog).find(d => d.props('show'))
expect(dialog).toBeTruthy()
dialog?.vm.$emit('confirm')
await flushPromises()
expect(createSparkShadow).toHaveBeenCalledTimes(1)
expect(createSparkShadow).toHaveBeenCalledWith(42, { name: 'parent-acc (Spark)' })
expect(showSuccess).toHaveBeenCalledWith('admin.accounts.createSparkShadowSuccess')
wrapper.unmount()
})
it('用户取消确认时不调用 API', async () => {
const wrapper = mountView()
await flushPromises()
wrapper.findComponent(AccountActionMenu).vm.$emit('create-spark-shadow', { id: 42, name: 'parent-acc' })
await flushPromises()
// 弹出 ConfirmDialog 后点取消,不应调用 API
const dialog = wrapper.findAllComponents(ConfirmDialog).find(d => d.props('show'))
expect(dialog).toBeTruthy()
dialog?.vm.$emit('cancel')
await flushPromises()
expect(createSparkShadow).not.toHaveBeenCalled()
wrapper.unmount()
})
})
// Task 6: 影子行 parent_* OR 兜底展示
const mountViewWithRow = () =>
mount(AccountsView, {
global: {
stubs: {
AppLayout: { template: '<div><slot /></div>' },
TablePageLayout: {
template: '<div><slot name="filters" /><slot name="table" /><slot name="pagination" /></div>'
},
// 使用能透传 row 数据的自定义 DataTable stub,以便渲染 cell 插槽
DataTable: {
props: ['data', 'columns', 'loading'],
template: `<div>
<div v-for="(row, idx) in (data || [])" :key="idx">
<slot name="cell-name" :row="row" :value="row.name" />
<slot name="cell-platform_type" :row="row" />
</div>
</div>`
},
Pagination: true,
ConfirmDialog: true,
AccountTableActions: { template: '<div><slot name="beforeCreate" /><slot name="after" /></div>' },
AccountTableFilters: { template: '<div></div>' },
AccountBulkActionsBar: true,
AccountActionMenu: true,
ImportDataModal: true,
ReAuthAccountModal: true,
AccountTestModal: true,
AccountStatsModal: true,
ScheduledTestsPanel: true,
SyncFromCrsModal: true,
TempUnschedStatusModal: true,
ErrorPassthroughRulesModal: true,
TLSFingerprintProfilesModal: true,
CreateAccountModal: true,
EditAccountModal: true,
BulkEditAccountModal: true,
PlatformTypeBadge: true,
AccountCapacityCell: true,
AccountStatusIndicator: true,
AccountTodayStatsCell: true,
AccountGroupsCell: true,
AccountUsageCell: true,
Icon: true
}
}
})
describe('admin AccountsView — 影子行 parent_* OR 兜底展示', () => {
beforeEach(() => {
localStorage.clear()
for (const fn of [listAccounts, listWithEtag, getBatchTodayStats, getAllProxies, getAllGroups, createSparkShadow, showSuccess, showError]) {
fn.mockReset()
}
listWithEtag.mockResolvedValue({ notModified: true, etag: null, data: null })
getBatchTodayStats.mockResolvedValue({ stats: {} })
getAllProxies.mockResolvedValue([])
getAllGroups.mockResolvedValue([])
vi.stubGlobal('confirm', vi.fn(() => true))
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('影子行 email 单元格显示 parent_email,PlatformTypeBadge 接收 parent_plan_type/parent_privacy_mode', async () => {
const shadowAccount = {
id: 100,
name: '影子账号',
platform: 'openai',
type: 'oauth',
parent_account_id: 1,
parent_email: 'parent@example.com',
parent_plan_type: 'plus',
parent_privacy_mode: 'false',
parent_subscription_expires_at: '2027-01-01T00:00:00Z',
parent_chatgpt_account_id: 'chatgpt-abc123',
}
listAccounts.mockResolvedValue({ items: [shadowAccount], total: 1, page: 1, page_size: 20, pages: 1 })
const wrapper = mountViewWithRow()
await flushPromises()
// 1. email 单元格通过 OR 兜底渲染 parent_email
expect(wrapper.text()).toContain('parent@example.com')
// 2. PlatformTypeBadge 收到 parent_plan_type 和 parent_privacy_mode
const badge = wrapper.findComponent(PlatformTypeBadge)
expect(badge.exists()).toBe(true)
expect(badge.props('planType')).toBe('plus')
expect(badge.props('privacyMode')).toBe('false')
expect(badge.props('subscriptionExpiresAt')).toBe('2027-01-01T00:00:00Z')
wrapper.unmount()
})
})