From 0772d9250eddd8b1c2f74fa5d1faf90db9fb6129 Mon Sep 17 00:00:00 2001 From: Wang Lvyuan <74089601+LvyuanW@users.noreply.github.com> Date: Tue, 17 Mar 2026 18:44:10 +0800 Subject: [PATCH 01/12] fix(admin/accounts): reset edit modal state on reopen --- .../components/account/EditAccountModal.vue | 500 +++++++++--------- .../__tests__/EditAccountModal.spec.ts | 159 ++++++ 2 files changed, 414 insertions(+), 245 deletions(-) create mode 100644 frontend/src/components/account/__tests__/EditAccountModal.spec.ts diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index c2f2f7d2..5f3da1b7 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -1980,271 +1980,281 @@ const normalizePoolModeRetryCount = (value: number) => { return normalized } -watch( - () => props.account, - (newAccount) => { - if (newAccount) { - antigravityMixedChannelConfirmed.value = false - showMixedChannelWarning.value = false - mixedChannelWarningDetails.value = null - mixedChannelWarningRawMessage.value = '' - mixedChannelWarningAction.value = null - form.name = newAccount.name - form.notes = newAccount.notes || '' - form.proxy_id = newAccount.proxy_id - form.concurrency = newAccount.concurrency - form.load_factor = newAccount.load_factor ?? null - form.priority = newAccount.priority - form.rate_multiplier = newAccount.rate_multiplier ?? 1 - form.status = (newAccount.status === 'active' || newAccount.status === 'inactive' || newAccount.status === 'error') - ? newAccount.status - : 'active' - form.group_ids = newAccount.group_ids || [] - form.expires_at = newAccount.expires_at ?? null +const syncFormFromAccount = (newAccount: Account | null) => { + if (!newAccount) { + return + } + antigravityMixedChannelConfirmed.value = false + showMixedChannelWarning.value = false + mixedChannelWarningDetails.value = null + mixedChannelWarningRawMessage.value = '' + mixedChannelWarningAction.value = null + form.name = newAccount.name + form.notes = newAccount.notes || '' + form.proxy_id = newAccount.proxy_id + form.concurrency = newAccount.concurrency + form.load_factor = newAccount.load_factor ?? null + form.priority = newAccount.priority + form.rate_multiplier = newAccount.rate_multiplier ?? 1 + form.status = (newAccount.status === 'active' || newAccount.status === 'inactive' || newAccount.status === 'error') + ? newAccount.status + : 'active' + form.group_ids = newAccount.group_ids || [] + form.expires_at = newAccount.expires_at ?? null - // Load intercept warmup requests setting (applies to all account types) - const credentials = newAccount.credentials as Record | undefined - interceptWarmupRequests.value = credentials?.intercept_warmup_requests === true - autoPauseOnExpired.value = newAccount.auto_pause_on_expired === true + // Load intercept warmup requests setting (applies to all account types) + const credentials = newAccount.credentials as Record | undefined + interceptWarmupRequests.value = credentials?.intercept_warmup_requests === true + autoPauseOnExpired.value = newAccount.auto_pause_on_expired === true - // Load mixed scheduling setting (only for antigravity accounts) - mixedScheduling.value = false - allowOverages.value = false - const extra = newAccount.extra as Record | undefined - mixedScheduling.value = extra?.mixed_scheduling === true - allowOverages.value = extra?.allow_overages === true + // Load mixed scheduling setting (only for antigravity accounts) + mixedScheduling.value = false + allowOverages.value = false + const extra = newAccount.extra as Record | undefined + mixedScheduling.value = extra?.mixed_scheduling === true + allowOverages.value = extra?.allow_overages === true - // Load OpenAI passthrough toggle (OpenAI OAuth/API Key) - openaiPassthroughEnabled.value = false - openaiOAuthResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF - openaiAPIKeyResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF - codexCLIOnlyEnabled.value = false - anthropicPassthroughEnabled.value = false - if (newAccount.platform === 'openai' && (newAccount.type === 'oauth' || newAccount.type === 'apikey')) { - openaiPassthroughEnabled.value = extra?.openai_passthrough === true || extra?.openai_oauth_passthrough === true - openaiOAuthResponsesWebSocketV2Mode.value = resolveOpenAIWSModeFromExtra(extra, { - modeKey: 'openai_oauth_responses_websockets_v2_mode', - enabledKey: 'openai_oauth_responses_websockets_v2_enabled', - fallbackEnabledKeys: ['responses_websockets_v2_enabled', 'openai_ws_enabled'], - defaultMode: OPENAI_WS_MODE_OFF - }) - openaiAPIKeyResponsesWebSocketV2Mode.value = resolveOpenAIWSModeFromExtra(extra, { - modeKey: 'openai_apikey_responses_websockets_v2_mode', - enabledKey: 'openai_apikey_responses_websockets_v2_enabled', - fallbackEnabledKeys: ['responses_websockets_v2_enabled', 'openai_ws_enabled'], - defaultMode: OPENAI_WS_MODE_OFF - }) - if (newAccount.type === 'oauth') { - codexCLIOnlyEnabled.value = extra?.codex_cli_only === true - } - } - if (newAccount.platform === 'anthropic' && newAccount.type === 'apikey') { - anthropicPassthroughEnabled.value = extra?.anthropic_passthrough === true - } + // Load OpenAI passthrough toggle (OpenAI OAuth/API Key) + openaiPassthroughEnabled.value = false + openaiOAuthResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF + openaiAPIKeyResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF + codexCLIOnlyEnabled.value = false + anthropicPassthroughEnabled.value = false + if (newAccount.platform === 'openai' && (newAccount.type === 'oauth' || newAccount.type === 'apikey')) { + openaiPassthroughEnabled.value = extra?.openai_passthrough === true || extra?.openai_oauth_passthrough === true + openaiOAuthResponsesWebSocketV2Mode.value = resolveOpenAIWSModeFromExtra(extra, { + modeKey: 'openai_oauth_responses_websockets_v2_mode', + enabledKey: 'openai_oauth_responses_websockets_v2_enabled', + fallbackEnabledKeys: ['responses_websockets_v2_enabled', 'openai_ws_enabled'], + defaultMode: OPENAI_WS_MODE_OFF + }) + openaiAPIKeyResponsesWebSocketV2Mode.value = resolveOpenAIWSModeFromExtra(extra, { + modeKey: 'openai_apikey_responses_websockets_v2_mode', + enabledKey: 'openai_apikey_responses_websockets_v2_enabled', + fallbackEnabledKeys: ['responses_websockets_v2_enabled', 'openai_ws_enabled'], + defaultMode: OPENAI_WS_MODE_OFF + }) + if (newAccount.type === 'oauth') { + codexCLIOnlyEnabled.value = extra?.codex_cli_only === true + } + } + if (newAccount.platform === 'anthropic' && newAccount.type === 'apikey') { + anthropicPassthroughEnabled.value = extra?.anthropic_passthrough === true + } - // Load quota limit for apikey/bedrock accounts (bedrock quota is also loaded in its own branch above) - if (newAccount.type === 'apikey' || newAccount.type === 'bedrock') { - const quotaVal = extra?.quota_limit as number | undefined - editQuotaLimit.value = (quotaVal && quotaVal > 0) ? quotaVal : null - const dailyVal = extra?.quota_daily_limit as number | undefined - editQuotaDailyLimit.value = (dailyVal && dailyVal > 0) ? dailyVal : null - const weeklyVal = extra?.quota_weekly_limit as number | undefined - editQuotaWeeklyLimit.value = (weeklyVal && weeklyVal > 0) ? weeklyVal : null - // Load quota reset mode config - editDailyResetMode.value = (extra?.quota_daily_reset_mode as 'rolling' | 'fixed') || null - editDailyResetHour.value = (extra?.quota_daily_reset_hour as number) ?? null - editWeeklyResetMode.value = (extra?.quota_weekly_reset_mode as 'rolling' | 'fixed') || null - editWeeklyResetDay.value = (extra?.quota_weekly_reset_day as number) ?? null - editWeeklyResetHour.value = (extra?.quota_weekly_reset_hour as number) ?? null - editResetTimezone.value = (extra?.quota_reset_timezone as string) || null + // Load quota limit for apikey/bedrock accounts (bedrock quota is also loaded in its own branch above) + if (newAccount.type === 'apikey' || newAccount.type === 'bedrock') { + const quotaVal = extra?.quota_limit as number | undefined + editQuotaLimit.value = (quotaVal && quotaVal > 0) ? quotaVal : null + const dailyVal = extra?.quota_daily_limit as number | undefined + editQuotaDailyLimit.value = (dailyVal && dailyVal > 0) ? dailyVal : null + const weeklyVal = extra?.quota_weekly_limit as number | undefined + editQuotaWeeklyLimit.value = (weeklyVal && weeklyVal > 0) ? weeklyVal : null + // Load quota reset mode config + editDailyResetMode.value = (extra?.quota_daily_reset_mode as 'rolling' | 'fixed') || null + editDailyResetHour.value = (extra?.quota_daily_reset_hour as number) ?? null + editWeeklyResetMode.value = (extra?.quota_weekly_reset_mode as 'rolling' | 'fixed') || null + editWeeklyResetDay.value = (extra?.quota_weekly_reset_day as number) ?? null + editWeeklyResetHour.value = (extra?.quota_weekly_reset_hour as number) ?? null + editResetTimezone.value = (extra?.quota_reset_timezone as string) || null + } else { + editQuotaLimit.value = null + editQuotaDailyLimit.value = null + editQuotaWeeklyLimit.value = null + editDailyResetMode.value = null + editDailyResetHour.value = null + editWeeklyResetMode.value = null + editWeeklyResetDay.value = null + editWeeklyResetHour.value = null + editResetTimezone.value = null + } + + // Load antigravity model mapping (Antigravity 只支持映射模式) + if (newAccount.platform === 'antigravity') { + const credentials = newAccount.credentials as Record | undefined + + // Antigravity 始终使用映射模式 + antigravityModelRestrictionMode.value = 'mapping' + antigravityWhitelistModels.value = [] + + // 从 model_mapping 读取映射配置 + const rawAgMapping = credentials?.model_mapping as Record | undefined + if (rawAgMapping && typeof rawAgMapping === 'object') { + const entries = Object.entries(rawAgMapping) + // 无论是白名单样式(key===value)还是真正的映射,都统一转换为映射列表 + antigravityModelMappings.value = entries.map(([from, to]) => ({ from, to })) + } else { + // 兼容旧数据:从 model_whitelist 读取,转换为映射格式 + const rawWhitelist = credentials?.model_whitelist + if (Array.isArray(rawWhitelist) && rawWhitelist.length > 0) { + antigravityModelMappings.value = rawWhitelist + .map((v) => String(v).trim()) + .filter((v) => v.length > 0) + .map((m) => ({ from: m, to: m })) } else { - editQuotaLimit.value = null - editQuotaDailyLimit.value = null - editQuotaWeeklyLimit.value = null - editDailyResetMode.value = null - editDailyResetHour.value = null - editWeeklyResetMode.value = null - editWeeklyResetDay.value = null - editWeeklyResetHour.value = null - editResetTimezone.value = null - } - - // Load antigravity model mapping (Antigravity 只支持映射模式) - if (newAccount.platform === 'antigravity') { - const credentials = newAccount.credentials as Record | undefined - - // Antigravity 始终使用映射模式 - antigravityModelRestrictionMode.value = 'mapping' - antigravityWhitelistModels.value = [] - - // 从 model_mapping 读取映射配置 - const rawAgMapping = credentials?.model_mapping as Record | undefined - if (rawAgMapping && typeof rawAgMapping === 'object') { - const entries = Object.entries(rawAgMapping) - // 无论是白名单样式(key===value)还是真正的映射,都统一转换为映射列表 - antigravityModelMappings.value = entries.map(([from, to]) => ({ from, to })) - } else { - // 兼容旧数据:从 model_whitelist 读取,转换为映射格式 - const rawWhitelist = credentials?.model_whitelist - if (Array.isArray(rawWhitelist) && rawWhitelist.length > 0) { - antigravityModelMappings.value = rawWhitelist - .map((v) => String(v).trim()) - .filter((v) => v.length > 0) - .map((m) => ({ from: m, to: m })) - } else { - antigravityModelMappings.value = [] - } - } - } else { - antigravityModelRestrictionMode.value = 'mapping' - antigravityWhitelistModels.value = [] antigravityModelMappings.value = [] } + } + } else { + antigravityModelRestrictionMode.value = 'mapping' + antigravityWhitelistModels.value = [] + antigravityModelMappings.value = [] + } - // Load quota control settings (Anthropic OAuth/SetupToken only) - loadQuotaControlSettings(newAccount) + // Load quota control settings (Anthropic OAuth/SetupToken only) + loadQuotaControlSettings(newAccount) - loadTempUnschedRules(credentials) + loadTempUnschedRules(credentials) - // Initialize API Key fields for apikey type - if (newAccount.type === 'apikey' && newAccount.credentials) { - const credentials = newAccount.credentials as Record - const platformDefaultUrl = - newAccount.platform === 'openai' || newAccount.platform === 'sora' - ? 'https://api.openai.com' - : newAccount.platform === 'gemini' - ? 'https://generativelanguage.googleapis.com' - : 'https://api.anthropic.com' - editBaseUrl.value = (credentials.base_url as string) || platformDefaultUrl + // Initialize API Key fields for apikey type + if (newAccount.type === 'apikey' && newAccount.credentials) { + const credentials = newAccount.credentials as Record + const platformDefaultUrl = + newAccount.platform === 'openai' || newAccount.platform === 'sora' + ? 'https://api.openai.com' + : newAccount.platform === 'gemini' + ? 'https://generativelanguage.googleapis.com' + : 'https://api.anthropic.com' + editBaseUrl.value = (credentials.base_url as string) || platformDefaultUrl - // Load model mappings and detect mode - const existingMappings = credentials.model_mapping as Record | undefined - if (existingMappings && typeof existingMappings === 'object') { - const entries = Object.entries(existingMappings) + // Load model mappings and detect mode + const existingMappings = credentials.model_mapping as Record | undefined + if (existingMappings && typeof existingMappings === 'object') { + const entries = Object.entries(existingMappings) - // Detect if this is whitelist mode (all from === to) or mapping mode - const isWhitelistMode = entries.length > 0 && entries.every(([from, to]) => from === to) + // Detect if this is whitelist mode (all from === to) or mapping mode + const isWhitelistMode = entries.length > 0 && entries.every(([from, to]) => from === to) - if (isWhitelistMode) { - // Whitelist mode: populate allowedModels - modelRestrictionMode.value = 'whitelist' - allowedModels.value = entries.map(([from]) => from) - modelMappings.value = [] - } else { - // Mapping mode: populate modelMappings - modelRestrictionMode.value = 'mapping' - modelMappings.value = entries.map(([from, to]) => ({ from, to })) - allowedModels.value = [] - } - } else { - // No mappings: default to whitelist mode with empty selection (allow all) - modelRestrictionMode.value = 'whitelist' - modelMappings.value = [] - allowedModels.value = [] - } - - // Load pool mode - poolModeEnabled.value = credentials.pool_mode === true - poolModeRetryCount.value = normalizePoolModeRetryCount( - Number(credentials.pool_mode_retry_count ?? DEFAULT_POOL_MODE_RETRY_COUNT) - ) - - // Load custom error codes - customErrorCodesEnabled.value = credentials.custom_error_codes_enabled === true - const existingErrorCodes = credentials.custom_error_codes as number[] | undefined - if (existingErrorCodes && Array.isArray(existingErrorCodes)) { - selectedErrorCodes.value = [...existingErrorCodes] - } else { - selectedErrorCodes.value = [] - } - } else if (newAccount.type === 'bedrock' && newAccount.credentials) { - const bedrockCreds = newAccount.credentials as Record - const authMode = (bedrockCreds.auth_mode as string) || 'sigv4' - editBedrockRegion.value = (bedrockCreds.aws_region as string) || '' - editBedrockForceGlobal.value = (bedrockCreds.aws_force_global as string) === 'true' - - if (authMode === 'apikey') { - editBedrockApiKeyValue.value = '' - } else { - editBedrockAccessKeyId.value = (bedrockCreds.aws_access_key_id as string) || '' - editBedrockSecretAccessKey.value = '' - editBedrockSessionToken.value = '' - } - - // Load pool mode for bedrock - poolModeEnabled.value = bedrockCreds.pool_mode === true - const retryCount = bedrockCreds.pool_mode_retry_count - poolModeRetryCount.value = (typeof retryCount === 'number' && retryCount >= 0) ? retryCount : DEFAULT_POOL_MODE_RETRY_COUNT - - // Load quota limits for bedrock - const bedrockExtra = (newAccount.extra as Record) || {} - editQuotaLimit.value = typeof bedrockExtra.quota_limit === 'number' ? bedrockExtra.quota_limit : null - editQuotaDailyLimit.value = typeof bedrockExtra.quota_daily_limit === 'number' ? bedrockExtra.quota_daily_limit : null - editQuotaWeeklyLimit.value = typeof bedrockExtra.quota_weekly_limit === 'number' ? bedrockExtra.quota_weekly_limit : null - - // Load model mappings for bedrock - const existingMappings = bedrockCreds.model_mapping as Record | undefined - if (existingMappings && typeof existingMappings === 'object') { - const entries = Object.entries(existingMappings) - const isWhitelistMode = entries.length > 0 && entries.every(([from, to]) => from === to) - if (isWhitelistMode) { - modelRestrictionMode.value = 'whitelist' - allowedModels.value = entries.map(([from]) => from) - modelMappings.value = [] - } else { - modelRestrictionMode.value = 'mapping' - modelMappings.value = entries.map(([from, to]) => ({ from, to })) - allowedModels.value = [] - } - } else { - modelRestrictionMode.value = 'whitelist' - modelMappings.value = [] - allowedModels.value = [] - } - } else if (newAccount.type === 'upstream' && newAccount.credentials) { - const credentials = newAccount.credentials as Record - editBaseUrl.value = (credentials.base_url as string) || '' + if (isWhitelistMode) { + // Whitelist mode: populate allowedModels + modelRestrictionMode.value = 'whitelist' + allowedModels.value = entries.map(([from]) => from) + modelMappings.value = [] } else { - const platformDefaultUrl = - newAccount.platform === 'openai' || newAccount.platform === 'sora' - ? 'https://api.openai.com' - : newAccount.platform === 'gemini' - ? 'https://generativelanguage.googleapis.com' - : 'https://api.anthropic.com' - editBaseUrl.value = platformDefaultUrl + // Mapping mode: populate modelMappings + modelRestrictionMode.value = 'mapping' + modelMappings.value = entries.map(([from, to]) => ({ from, to })) + allowedModels.value = [] + } + } else { + // No mappings: default to whitelist mode with empty selection (allow all) + modelRestrictionMode.value = 'whitelist' + modelMappings.value = [] + allowedModels.value = [] + } - // Load model mappings for OpenAI OAuth accounts - if (newAccount.platform === 'openai' && newAccount.credentials) { - const oauthCredentials = newAccount.credentials as Record - const existingMappings = oauthCredentials.model_mapping as Record | undefined - if (existingMappings && typeof existingMappings === 'object') { - const entries = Object.entries(existingMappings) - const isWhitelistMode = entries.length > 0 && entries.every(([from, to]) => from === to) - if (isWhitelistMode) { - modelRestrictionMode.value = 'whitelist' - allowedModels.value = entries.map(([from]) => from) - modelMappings.value = [] - } else { - modelRestrictionMode.value = 'mapping' - modelMappings.value = entries.map(([from, to]) => ({ from, to })) - allowedModels.value = [] - } - } else { - modelRestrictionMode.value = 'whitelist' - modelMappings.value = [] - allowedModels.value = [] - } - } else { + // Load pool mode + poolModeEnabled.value = credentials.pool_mode === true + poolModeRetryCount.value = normalizePoolModeRetryCount( + Number(credentials.pool_mode_retry_count ?? DEFAULT_POOL_MODE_RETRY_COUNT) + ) + + // Load custom error codes + customErrorCodesEnabled.value = credentials.custom_error_codes_enabled === true + const existingErrorCodes = credentials.custom_error_codes as number[] | undefined + if (existingErrorCodes && Array.isArray(existingErrorCodes)) { + selectedErrorCodes.value = [...existingErrorCodes] + } else { + selectedErrorCodes.value = [] + } + } else if (newAccount.type === 'bedrock' && newAccount.credentials) { + const bedrockCreds = newAccount.credentials as Record + const authMode = (bedrockCreds.auth_mode as string) || 'sigv4' + editBedrockRegion.value = (bedrockCreds.aws_region as string) || '' + editBedrockForceGlobal.value = (bedrockCreds.aws_force_global as string) === 'true' + + if (authMode === 'apikey') { + editBedrockApiKeyValue.value = '' + } else { + editBedrockAccessKeyId.value = (bedrockCreds.aws_access_key_id as string) || '' + editBedrockSecretAccessKey.value = '' + editBedrockSessionToken.value = '' + } + + // Load pool mode for bedrock + poolModeEnabled.value = bedrockCreds.pool_mode === true + const retryCount = bedrockCreds.pool_mode_retry_count + poolModeRetryCount.value = (typeof retryCount === 'number' && retryCount >= 0) ? retryCount : DEFAULT_POOL_MODE_RETRY_COUNT + + // Load quota limits for bedrock + const bedrockExtra = (newAccount.extra as Record) || {} + editQuotaLimit.value = typeof bedrockExtra.quota_limit === 'number' ? bedrockExtra.quota_limit : null + editQuotaDailyLimit.value = typeof bedrockExtra.quota_daily_limit === 'number' ? bedrockExtra.quota_daily_limit : null + editQuotaWeeklyLimit.value = typeof bedrockExtra.quota_weekly_limit === 'number' ? bedrockExtra.quota_weekly_limit : null + + // Load model mappings for bedrock + const existingMappings = bedrockCreds.model_mapping as Record | undefined + if (existingMappings && typeof existingMappings === 'object') { + const entries = Object.entries(existingMappings) + const isWhitelistMode = entries.length > 0 && entries.every(([from, to]) => from === to) + if (isWhitelistMode) { + modelRestrictionMode.value = 'whitelist' + allowedModels.value = entries.map(([from]) => from) + modelMappings.value = [] + } else { + modelRestrictionMode.value = 'mapping' + modelMappings.value = entries.map(([from, to]) => ({ from, to })) + allowedModels.value = [] + } + } else { + modelRestrictionMode.value = 'whitelist' + modelMappings.value = [] + allowedModels.value = [] + } + } else if (newAccount.type === 'upstream' && newAccount.credentials) { + const credentials = newAccount.credentials as Record + editBaseUrl.value = (credentials.base_url as string) || '' + } else { + const platformDefaultUrl = + newAccount.platform === 'openai' || newAccount.platform === 'sora' + ? 'https://api.openai.com' + : newAccount.platform === 'gemini' + ? 'https://generativelanguage.googleapis.com' + : 'https://api.anthropic.com' + editBaseUrl.value = platformDefaultUrl + + // Load model mappings for OpenAI OAuth accounts + if (newAccount.platform === 'openai' && newAccount.credentials) { + const oauthCredentials = newAccount.credentials as Record + const existingMappings = oauthCredentials.model_mapping as Record | undefined + if (existingMappings && typeof existingMappings === 'object') { + const entries = Object.entries(existingMappings) + const isWhitelistMode = entries.length > 0 && entries.every(([from, to]) => from === to) + if (isWhitelistMode) { modelRestrictionMode.value = 'whitelist' + allowedModels.value = entries.map(([from]) => from) modelMappings.value = [] + } else { + modelRestrictionMode.value = 'mapping' + modelMappings.value = entries.map(([from, to]) => ({ from, to })) allowedModels.value = [] } - poolModeEnabled.value = false - poolModeRetryCount.value = DEFAULT_POOL_MODE_RETRY_COUNT - customErrorCodesEnabled.value = false - selectedErrorCodes.value = [] + } else { + modelRestrictionMode.value = 'whitelist' + modelMappings.value = [] + allowedModels.value = [] } - editApiKey.value = '' + } else { + modelRestrictionMode.value = 'whitelist' + modelMappings.value = [] + allowedModels.value = [] + } + poolModeEnabled.value = false + poolModeRetryCount.value = DEFAULT_POOL_MODE_RETRY_COUNT + customErrorCodesEnabled.value = false + selectedErrorCodes.value = [] + } + editApiKey.value = '' +} + +watch( + [() => props.show, () => props.account], + ([show, newAccount], [wasShow, previousAccount]) => { + if (!show || !newAccount) { + return + } + if (!wasShow || newAccount !== previousAccount) { + syncFormFromAccount(newAccount) } }, { immediate: true } diff --git a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts new file mode 100644 index 00000000..e3260168 --- /dev/null +++ b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts @@ -0,0 +1,159 @@ +import { describe, expect, it, vi } from 'vitest' +import { defineComponent } from 'vue' +import { mount } from '@vue/test-utils' + +const { updateAccountMock, checkMixedChannelRiskMock } = vi.hoisted(() => ({ + updateAccountMock: vi.fn(), + checkMixedChannelRiskMock: vi.fn() +})) + +vi.mock('@/stores/app', () => ({ + useAppStore: () => ({ + showError: vi.fn(), + showSuccess: vi.fn(), + showInfo: vi.fn() + }) +})) + +vi.mock('@/stores/auth', () => ({ + useAuthStore: () => ({ + isSimpleMode: true + }) +})) + +vi.mock('@/api/admin', () => ({ + adminAPI: { + accounts: { + update: updateAccountMock, + checkMixedChannelRisk: checkMixedChannelRiskMock + } + } +})) + +vi.mock('@/api/admin/accounts', () => ({ + getAntigravityDefaultModelMapping: vi.fn() +})) + +vi.mock('vue-i18n', async () => { + const actual = await vi.importActual('vue-i18n') + return { + ...actual, + useI18n: () => ({ + t: (key: string) => key + }) + } +}) + +import EditAccountModal from '../EditAccountModal.vue' + +const BaseDialogStub = defineComponent({ + name: 'BaseDialog', + props: { + show: { + type: Boolean, + default: false + } + }, + template: '
' +}) + +const ModelWhitelistSelectorStub = defineComponent({ + name: 'ModelWhitelistSelector', + props: { + modelValue: { + type: Array, + default: () => [] + } + }, + emits: ['update:modelValue'], + template: ` +
+ + + {{ Array.isArray(modelValue) ? modelValue.join(',') : '' }} + +
+ ` +}) + +function buildAccount() { + return { + id: 1, + name: 'OpenAI Key', + notes: '', + platform: 'openai', + type: 'apikey', + credentials: { + api_key: 'sk-test', + base_url: 'https://api.openai.com', + model_mapping: { + 'gpt-5.2': 'gpt-5.2' + } + }, + extra: {}, + proxy_id: null, + concurrency: 1, + priority: 1, + rate_multiplier: 1, + status: 'active', + group_ids: [], + expires_at: null, + auto_pause_on_expired: false + } as any +} + +function mountModal(account = buildAccount()) { + return mount(EditAccountModal, { + props: { + show: true, + account, + proxies: [], + groups: [] + }, + global: { + stubs: { + BaseDialog: BaseDialogStub, + Select: true, + Icon: true, + ProxySelector: true, + GroupSelector: true, + ModelWhitelistSelector: ModelWhitelistSelectorStub + } + } + }) +} + +describe('EditAccountModal', () => { + it('reopening the same account rehydrates the OpenAI whitelist from props', async () => { + const account = buildAccount() + updateAccountMock.mockReset() + checkMixedChannelRiskMock.mockReset() + checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false }) + updateAccountMock.mockResolvedValue(account) + + const wrapper = mountModal(account) + + expect(wrapper.get('[data-testid="model-whitelist-value"]').text()).toBe('gpt-5.2') + + await wrapper.get('[data-testid="rewrite-to-snapshot"]').trigger('click') + expect(wrapper.get('[data-testid="model-whitelist-value"]').text()).toBe('gpt-5.2-2025-12-11') + + await wrapper.setProps({ show: false }) + await wrapper.setProps({ show: true }) + + expect(wrapper.get('[data-testid="model-whitelist-value"]').text()).toBe('gpt-5.2') + + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + + expect(updateAccountMock).toHaveBeenCalledTimes(1) + expect(updateAccountMock.mock.calls[0]?.[1]?.credentials?.model_mapping).toEqual({ + 'gpt-5.2': 'gpt-5.2' + }) + }) +}) From 2005fc97a8a7fe18ab3016b53525309d401185e3 Mon Sep 17 00:00:00 2001 From: Ethan0x0000 <3352979663@qq.com> Date: Tue, 17 Mar 2026 13:19:20 +0800 Subject: [PATCH 02/12] fix(ui): show 'now' for idle OpenAI usage windows Use utilization-based idle detection instead of local request counts so newly imported OAuth accounts keep countdowns when usage is non-zero. --- .../components/account/AccountUsageCell.vue | 2 + .../components/account/UsageProgressBar.vue | 14 +++- .../__tests__/UsageProgressBar.spec.ts | 69 +++++++++++++++++++ 3 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/account/__tests__/UsageProgressBar.spec.ts diff --git a/frontend/src/components/account/AccountUsageCell.vue b/frontend/src/components/account/AccountUsageCell.vue index e548be8c..131d82b2 100644 --- a/frontend/src/components/account/AccountUsageCell.vue +++ b/frontend/src/components/account/AccountUsageCell.vue @@ -82,6 +82,7 @@ :utilization="usageInfo.five_hour.utilization" :resets-at="usageInfo.five_hour.resets_at" :window-stats="usageInfo.five_hour.window_stats" + :show-now-when-idle="true" color="indigo" /> diff --git a/frontend/src/components/account/UsageProgressBar.vue b/frontend/src/components/account/UsageProgressBar.vue index 506071fa..52f0ecbb 100644 --- a/frontend/src/components/account/UsageProgressBar.vue +++ b/frontend/src/components/account/UsageProgressBar.vue @@ -48,7 +48,7 @@ - + {{ formatResetTime }} @@ -68,6 +68,7 @@ const props = defineProps<{ resetsAt?: string | null color: 'indigo' | 'emerald' | 'purple' | 'amber' windowStats?: WindowStats | null + showNowWhenIdle?: boolean }>() const { t } = useI18n() @@ -139,9 +140,20 @@ const displayPercent = computed(() => { return percent > 999 ? '>999%' : `${percent}%` }) +const shouldShowResetTime = computed(() => { + if (props.resetsAt) return true + return Boolean(props.showNowWhenIdle && props.utilization <= 0) +}) + // Format reset time const formatResetTime = computed(() => { + // For rolling windows, when utilization is 0%, treat as immediately available. + if (props.showNowWhenIdle && props.utilization <= 0) { + return '现在' + } + if (!props.resetsAt) return '-' + const date = new Date(props.resetsAt) const diffMs = date.getTime() - now.value.getTime() diff --git a/frontend/src/components/account/__tests__/UsageProgressBar.spec.ts b/frontend/src/components/account/__tests__/UsageProgressBar.spec.ts new file mode 100644 index 00000000..9def052c --- /dev/null +++ b/frontend/src/components/account/__tests__/UsageProgressBar.spec.ts @@ -0,0 +1,69 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import UsageProgressBar from '../UsageProgressBar.vue' + +vi.mock('vue-i18n', async () => { + const actual = await vi.importActual('vue-i18n') + return { + ...actual, + useI18n: () => ({ + t: (key: string) => key + }) + } +}) + +describe('UsageProgressBar', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-03-17T00:00:00Z')) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('showNowWhenIdle=true 且利用率为 0 时显示“现在”', () => { + const wrapper = mount(UsageProgressBar, { + props: { + label: '5h', + utilization: 0, + resetsAt: '2026-03-17T02:30:00Z', + showNowWhenIdle: true, + color: 'indigo' + } + }) + + expect(wrapper.text()).toContain('现在') + expect(wrapper.text()).not.toContain('2h 30m') + }) + + it('showNowWhenIdle=true 但利用率大于 0 时显示倒计时', () => { + const wrapper = mount(UsageProgressBar, { + props: { + label: '7d', + utilization: 12, + resetsAt: '2026-03-17T02:30:00Z', + showNowWhenIdle: true, + color: 'emerald' + } + }) + + expect(wrapper.text()).toContain('2h 30m') + expect(wrapper.text()).not.toContain('现在') + }) + + it('showNowWhenIdle=false 时保持原有倒计时行为', () => { + const wrapper = mount(UsageProgressBar, { + props: { + label: '1d', + utilization: 0, + resetsAt: '2026-03-17T02:30:00Z', + showNowWhenIdle: false, + color: 'indigo' + } + }) + + expect(wrapper.text()).toContain('2h 30m') + expect(wrapper.text()).not.toContain('现在') + }) +}) From 50a3c7fa0b1d536239ffedee6be97cb859b41afa Mon Sep 17 00:00:00 2001 From: Gemini Wen Date: Wed, 18 Mar 2026 09:23:19 +0800 Subject: [PATCH 03/12] feat: add platform type filter to subscription management page Add a platform filter dropdown to the admin subscriptions view, allowing filtering subscriptions by platform (Anthropic, OpenAI, Gemini, etc.) through the group association. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../handler/admin/subscription_handler.go | 3 ++- .../repository/user_subscription_repo.go | 6 +++++- .../subscription_assign_idempotency_test.go | 2 +- .../internal/service/subscription_service.go | 4 ++-- .../service/user_subscription_port.go | 2 +- frontend/src/api/admin/subscriptions.ts | 1 + frontend/src/i18n/locales/en.ts | 1 + frontend/src/i18n/locales/zh.ts | 1 + .../src/views/admin/SubscriptionsView.vue | 19 +++++++++++++++++++ 9 files changed, 33 insertions(+), 6 deletions(-) diff --git a/backend/internal/handler/admin/subscription_handler.go b/backend/internal/handler/admin/subscription_handler.go index 342964b6..611666de 100644 --- a/backend/internal/handler/admin/subscription_handler.go +++ b/backend/internal/handler/admin/subscription_handler.go @@ -77,12 +77,13 @@ func (h *SubscriptionHandler) List(c *gin.Context) { } } status := c.Query("status") + platform := c.Query("platform") // Parse sorting parameters sortBy := c.DefaultQuery("sort_by", "created_at") sortOrder := c.DefaultQuery("sort_order", "desc") - subscriptions, pagination, err := h.subscriptionService.List(c.Request.Context(), page, pageSize, userID, groupID, status, sortBy, sortOrder) + subscriptions, pagination, err := h.subscriptionService.List(c.Request.Context(), page, pageSize, userID, groupID, status, platform, sortBy, sortOrder) if err != nil { response.ErrorFrom(c, err) return diff --git a/backend/internal/repository/user_subscription_repo.go b/backend/internal/repository/user_subscription_repo.go index 5a649846..e3f64a5f 100644 --- a/backend/internal/repository/user_subscription_repo.go +++ b/backend/internal/repository/user_subscription_repo.go @@ -5,6 +5,7 @@ import ( "time" dbent "github.com/Wei-Shaw/sub2api/ent" + "github.com/Wei-Shaw/sub2api/ent/group" "github.com/Wei-Shaw/sub2api/ent/usersubscription" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" "github.com/Wei-Shaw/sub2api/internal/service" @@ -190,7 +191,7 @@ func (r *userSubscriptionRepository) ListByGroupID(ctx context.Context, groupID return userSubscriptionEntitiesToService(subs), paginationResultFromTotal(int64(total), params), nil } -func (r *userSubscriptionRepository) List(ctx context.Context, params pagination.PaginationParams, userID, groupID *int64, status, sortBy, sortOrder string) ([]service.UserSubscription, *pagination.PaginationResult, error) { +func (r *userSubscriptionRepository) List(ctx context.Context, params pagination.PaginationParams, userID, groupID *int64, status, platform, sortBy, sortOrder string) ([]service.UserSubscription, *pagination.PaginationResult, error) { client := clientFromContext(ctx, r.client) q := client.UserSubscription.Query() if userID != nil { @@ -199,6 +200,9 @@ func (r *userSubscriptionRepository) List(ctx context.Context, params pagination if groupID != nil { q = q.Where(usersubscription.GroupIDEQ(*groupID)) } + if platform != "" { + q = q.Where(usersubscription.HasGroupWith(group.PlatformEQ(platform))) + } // Status filtering with real-time expiration check now := time.Now() diff --git a/backend/internal/service/subscription_assign_idempotency_test.go b/backend/internal/service/subscription_assign_idempotency_test.go index 0defafba..034d4fa1 100644 --- a/backend/internal/service/subscription_assign_idempotency_test.go +++ b/backend/internal/service/subscription_assign_idempotency_test.go @@ -92,7 +92,7 @@ func (userSubRepoNoop) ListActiveByUserID(context.Context, int64) ([]UserSubscri func (userSubRepoNoop) ListByGroupID(context.Context, int64, pagination.PaginationParams) ([]UserSubscription, *pagination.PaginationResult, error) { panic("unexpected ListByGroupID call") } -func (userSubRepoNoop) List(context.Context, pagination.PaginationParams, *int64, *int64, string, string, string) ([]UserSubscription, *pagination.PaginationResult, error) { +func (userSubRepoNoop) List(context.Context, pagination.PaginationParams, *int64, *int64, string, string, string, string) ([]UserSubscription, *pagination.PaginationResult, error) { panic("unexpected List call") } func (userSubRepoNoop) ExistsByUserIDAndGroupID(context.Context, int64, int64) (bool, error) { diff --git a/backend/internal/service/subscription_service.go b/backend/internal/service/subscription_service.go index af548509..f0a5540e 100644 --- a/backend/internal/service/subscription_service.go +++ b/backend/internal/service/subscription_service.go @@ -634,9 +634,9 @@ func (s *SubscriptionService) ListGroupSubscriptions(ctx context.Context, groupI } // List 获取所有订阅(分页,支持筛选和排序) -func (s *SubscriptionService) List(ctx context.Context, page, pageSize int, userID, groupID *int64, status, sortBy, sortOrder string) ([]UserSubscription, *pagination.PaginationResult, error) { +func (s *SubscriptionService) List(ctx context.Context, page, pageSize int, userID, groupID *int64, status, platform, sortBy, sortOrder string) ([]UserSubscription, *pagination.PaginationResult, error) { params := pagination.PaginationParams{Page: page, PageSize: pageSize} - subs, pag, err := s.userSubRepo.List(ctx, params, userID, groupID, status, sortBy, sortOrder) + subs, pag, err := s.userSubRepo.List(ctx, params, userID, groupID, status, platform, sortBy, sortOrder) if err != nil { return nil, nil, err } diff --git a/backend/internal/service/user_subscription_port.go b/backend/internal/service/user_subscription_port.go index 2dfc8d02..4484fae8 100644 --- a/backend/internal/service/user_subscription_port.go +++ b/backend/internal/service/user_subscription_port.go @@ -18,7 +18,7 @@ type UserSubscriptionRepository interface { ListByUserID(ctx context.Context, userID int64) ([]UserSubscription, error) ListActiveByUserID(ctx context.Context, userID int64) ([]UserSubscription, error) ListByGroupID(ctx context.Context, groupID int64, params pagination.PaginationParams) ([]UserSubscription, *pagination.PaginationResult, error) - List(ctx context.Context, params pagination.PaginationParams, userID, groupID *int64, status, sortBy, sortOrder string) ([]UserSubscription, *pagination.PaginationResult, error) + List(ctx context.Context, params pagination.PaginationParams, userID, groupID *int64, status, platform, sortBy, sortOrder string) ([]UserSubscription, *pagination.PaginationResult, error) ExistsByUserIDAndGroupID(ctx context.Context, userID, groupID int64) (bool, error) ExtendExpiry(ctx context.Context, subscriptionID int64, newExpiresAt time.Time) error diff --git a/frontend/src/api/admin/subscriptions.ts b/frontend/src/api/admin/subscriptions.ts index 7557e3ad..611f67c2 100644 --- a/frontend/src/api/admin/subscriptions.ts +++ b/frontend/src/api/admin/subscriptions.ts @@ -27,6 +27,7 @@ export async function list( status?: 'active' | 'expired' | 'revoked' user_id?: number group_id?: number + platform?: string sort_by?: string sort_order?: 'asc' | 'desc' }, diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 26edcfe9..42139534 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -1694,6 +1694,7 @@ export default { revokeSubscription: 'Revoke Subscription', allStatus: 'All Status', allGroups: 'All Groups', + allPlatforms: 'All Platforms', daily: 'Daily', weekly: 'Weekly', monthly: 'Monthly', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 39c900ca..a69827b6 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -1774,6 +1774,7 @@ export default { revokeSubscription: '撤销订阅', allStatus: '全部状态', allGroups: '全部分组', + allPlatforms: '全部平台', daily: '每日', weekly: '每周', monthly: '每月', diff --git a/frontend/src/views/admin/SubscriptionsView.vue b/frontend/src/views/admin/SubscriptionsView.vue index 97282594..0136c680 100644 --- a/frontend/src/views/admin/SubscriptionsView.vue +++ b/frontend/src/views/admin/SubscriptionsView.vue @@ -81,6 +81,14 @@ @change="applyFilters" /> +
+