Merge upstream/main into pr/upstream-model-tracking

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Ethan0x0000
2026-03-18 14:16:50 +08:00
54 changed files with 1594 additions and 381 deletions

View File

@@ -218,6 +218,34 @@ export async function batchSetGroupRateMultipliers(
return data
}
/**
* Get usage summary (today + cumulative cost) for all groups
* @param timezone - IANA timezone string (e.g. "Asia/Shanghai")
* @returns Array of group usage summaries
*/
export async function getUsageSummary(
timezone?: string
): Promise<{ group_id: number; today_cost: number; total_cost: number }[]> {
const { data } = await apiClient.get<
{ group_id: number; today_cost: number; total_cost: number }[]
>('/admin/groups/usage-summary', {
params: timezone ? { timezone } : undefined
})
return data
}
/**
* Get capacity summary (concurrency/sessions/RPM) for all active groups
*/
export async function getCapacitySummary(): Promise<
{ group_id: number; concurrency_used: number; concurrency_max: number; sessions_used: number; sessions_max: number; rpm_used: number; rpm_max: number }[]
> {
const { data } = await apiClient.get<
{ group_id: number; concurrency_used: number; concurrency_max: number; sessions_used: number; sessions_max: number; rpm_used: number; rpm_max: number }[]
>('/admin/groups/capacity-summary')
return data
}
export const groupsAPI = {
list,
getAll,
@@ -232,7 +260,9 @@ export const groupsAPI = {
getGroupRateMultipliers,
clearGroupRateMultipliers,
batchSetGroupRateMultipliers,
updateSortOrder
updateSortOrder,
getUsageSummary,
getCapacitySummary
}
export default groupsAPI

View File

@@ -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'
},

View File

@@ -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"
/>
<UsageProgressBar
@@ -90,6 +91,7 @@
:utilization="usageInfo.seven_day.utilization"
:resets-at="usageInfo.seven_day.resets_at"
:window-stats="usageInfo.seven_day.window_stats"
:show-now-when-idle="true"
color="emerald"
/>
</div>

View File

@@ -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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string, unknown> | undefined
// Antigravity 始终使用映射模式
antigravityModelRestrictionMode.value = 'mapping'
antigravityWhitelistModels.value = []
// 从 model_mapping 读取映射配置
const rawAgMapping = credentials?.model_mapping as Record<string, string> | 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<string, unknown> | undefined
// Antigravity 始终使用映射模式
antigravityModelRestrictionMode.value = 'mapping'
antigravityWhitelistModels.value = []
// 从 model_mapping 读取映射配置
const rawAgMapping = credentials?.model_mapping as Record<string, string> | 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<string, unknown>
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<string, unknown>
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<string, string> | undefined
if (existingMappings && typeof existingMappings === 'object') {
const entries = Object.entries(existingMappings)
// Load model mappings and detect mode
const existingMappings = credentials.model_mapping as Record<string, string> | 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<string, unknown>
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<string, unknown>) || {}
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<string, string> | 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<string, unknown>
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<string, unknown>
const existingMappings = oauthCredentials.model_mapping as Record<string, string> | 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<string, unknown>
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<string, unknown>) || {}
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<string, string> | 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<string, unknown>
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<string, unknown>
const existingMappings = oauthCredentials.model_mapping as Record<string, string> | 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 }

View File

@@ -48,7 +48,7 @@
</span>
<!-- Reset time -->
<span v-if="resetsAt" class="shrink-0 text-[10px] text-gray-400">
<span v-if="shouldShowResetTime" class="shrink-0 text-[10px] text-gray-400">
{{ formatResetTime }}
</span>
</div>
@@ -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()

View File

@@ -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<typeof import('vue-i18n')>('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: '<div v-if="show"><slot /><slot name="footer" /></div>'
})
const ModelWhitelistSelectorStub = defineComponent({
name: 'ModelWhitelistSelector',
props: {
modelValue: {
type: Array,
default: () => []
}
},
emits: ['update:modelValue'],
template: `
<div>
<button
type="button"
data-testid="rewrite-to-snapshot"
@click="$emit('update:modelValue', ['gpt-5.2-2025-12-11'])"
>
rewrite
</button>
<span data-testid="model-whitelist-value">
{{ Array.isArray(modelValue) ? modelValue.join(',') : '' }}
</span>
</div>
`
})
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'
})
})
})

View File

@@ -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<typeof import('vue-i18n')>('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('现在')
})
})

View File

@@ -0,0 +1,84 @@
<template>
<div class="flex flex-col gap-1">
<!-- 并发槽位 -->
<div class="flex items-center gap-1">
<span
:class="[
'inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-medium',
capacityClass(concurrencyUsed, concurrencyMax)
]"
>
<svg class="h-2.5 w-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z" />
</svg>
<span class="font-mono">{{ concurrencyUsed }}</span>
<span class="text-gray-400 dark:text-gray-500">/</span>
<span class="font-mono">{{ concurrencyMax }}</span>
</span>
</div>
<!-- 会话数 -->
<div v-if="sessionsMax > 0" class="flex items-center gap-1">
<span
:class="[
'inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-medium',
capacityClass(sessionsUsed, sessionsMax)
]"
>
<svg class="h-2.5 w-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
</svg>
<span class="font-mono">{{ sessionsUsed }}</span>
<span class="text-gray-400 dark:text-gray-500">/</span>
<span class="font-mono">{{ sessionsMax }}</span>
</span>
</div>
<!-- RPM -->
<div v-if="rpmMax > 0" class="flex items-center gap-1">
<span
:class="[
'inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-medium',
capacityClass(rpmUsed, rpmMax)
]"
>
<svg class="h-2.5 w-2.5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
<span class="font-mono">{{ rpmUsed }}</span>
<span class="text-gray-400 dark:text-gray-500">/</span>
<span class="font-mono">{{ rpmMax }}</span>
</span>
</div>
</div>
</template>
<script setup lang="ts">
interface Props {
concurrencyUsed: number
concurrencyMax: number
sessionsUsed: number
sessionsMax: number
rpmUsed: number
rpmMax: number
}
withDefaults(defineProps<Props>(), {
concurrencyUsed: 0,
concurrencyMax: 0,
sessionsUsed: 0,
sessionsMax: 0,
rpmUsed: 0,
rpmMax: 0
})
function capacityClass(used: number, max: number): string {
if (max > 0 && used >= max) {
return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
}
if (used > 0) {
return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
}
return 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400'
}
</script>

View File

@@ -1508,6 +1508,8 @@ export default {
rateMultiplier: 'Rate Multiplier',
type: 'Type',
accounts: 'Accounts',
capacity: 'Capacity',
usage: 'Usage',
status: 'Status',
actions: 'Actions',
billingType: 'Billing Type',
@@ -1516,6 +1518,12 @@ export default {
userNotes: 'Notes',
userStatus: 'Status'
},
usageToday: 'Today',
usageTotal: 'Total',
accountsAvailable: 'Avail:',
accountsRateLimited: 'Limited:',
accountsTotal: 'Total:',
accountsUnit: '',
rateAndAccounts: '{rate}x rate · {count} accounts',
accountsCount: '{count} accounts',
form: {
@@ -1697,6 +1705,7 @@ export default {
revokeSubscription: 'Revoke Subscription',
allStatus: 'All Status',
allGroups: 'All Groups',
allPlatforms: 'All Platforms',
daily: 'Daily',
weekly: 'Weekly',
monthly: 'Monthly',
@@ -1762,7 +1771,37 @@ export default {
pleaseSelectGroup: 'Please select a group',
validityDaysRequired: 'Please enter a valid number of days (at least 1)',
revokeConfirm:
"Are you sure you want to revoke the subscription for '{user}'? This action cannot be undone."
"Are you sure you want to revoke the subscription for '{user}'? This action cannot be undone.",
guide: {
title: 'Subscription Management Guide',
subtitle: 'Subscription mode lets you assign time-based usage quotas to users, with daily/weekly/monthly limits. Follow these steps to get started.',
showGuide: 'Usage Guide',
step1: {
title: 'Create a Subscription Group',
line1: 'Go to "Group Management" page, click "Create Group"',
line2: 'Set billing type to "Subscription", configure daily/weekly/monthly quota limits',
line3: 'Save the group and ensure its status is "Active"',
link: 'Go to Group Management'
},
step2: {
title: 'Assign Subscription to User',
line1: 'Click the "Assign Subscription" button in the top right',
line2: 'Search for a user by email and select them',
line3: 'Choose a subscription group, set validity days, then click "Assign"'
},
step3: {
title: 'Manage Existing Subscriptions'
},
actions: {
adjust: 'Adjust',
adjustDesc: 'Extend or shorten the subscription validity period',
resetQuota: 'Reset Quota',
resetQuotaDesc: 'Reset daily/weekly/monthly usage to zero',
revoke: 'Revoke',
revokeDesc: 'Immediately terminate the subscription (irreversible)'
},
tip: 'Tip: Only groups with billing type "Subscription" and status "Active" appear in the group dropdown. If no options are available, create one in Group Management first.'
}
},
// Accounts

View File

@@ -1564,6 +1564,8 @@ export default {
priority: '优先级',
apiKeys: 'API 密钥数',
accounts: '账号数',
capacity: '容量',
usage: '用量',
status: '状态',
actions: '操作',
billingType: '计费类型',
@@ -1572,6 +1574,12 @@ export default {
userNotes: '备注',
userStatus: '状态'
},
usageToday: '今日',
usageTotal: '累计',
accountsAvailable: '可用:',
accountsRateLimited: '限流:',
accountsTotal: '总量:',
accountsUnit: '个账号',
form: {
name: '名称',
description: '描述',
@@ -1777,6 +1785,7 @@ export default {
revokeSubscription: '撤销订阅',
allStatus: '全部状态',
allGroups: '全部分组',
allPlatforms: '全部平台',
daily: '每日',
weekly: '每周',
monthly: '每月',
@@ -1841,7 +1850,37 @@ export default {
pleaseSelectUser: '请选择用户',
pleaseSelectGroup: '请选择分组',
validityDaysRequired: '请输入有效的天数至少1天',
revokeConfirm: "确定要撤销 '{user}' 的订阅吗?此操作无法撤销。"
revokeConfirm: "确定要撤销 '{user}' 的订阅吗?此操作无法撤销。",
guide: {
title: '订阅管理教程',
subtitle: '订阅模式允许你按时间周期为用户分配使用额度,支持日/周/月配额限制。按照以下步骤即可完成配置。',
showGuide: '使用指南',
step1: {
title: '创建订阅分组',
line1: '前往「分组管理」页面,点击「创建分组」',
line2: '将计费类型设为「订阅」,配置日/周/月额度限制',
line3: '保存分组,确保状态为「正常」',
link: '前往分组管理'
},
step2: {
title: '分配订阅给用户',
line1: '点击本页右上角「分配订阅」按钮',
line2: '在弹窗中搜索用户邮箱并选择目标用户',
line3: '选择订阅分组、设置有效期天数,点击「分配」'
},
step3: {
title: '管理已有订阅'
},
actions: {
adjust: '调整',
adjustDesc: '延长或缩短订阅有效期',
resetQuota: '重置配额',
resetQuotaDesc: '将日/周/月用量归零,重新开始计算',
revoke: '撤销',
revokeDesc: '立即终止该用户的订阅,不可恢复'
},
tip: '提示:订阅分组下拉列表中只会显示计费类型为「订阅」且状态为「正常」的分组。如果没有可选项,请先到分组管理中创建。'
}
},
// Accounts Management

View File

@@ -411,6 +411,8 @@ export interface AdminGroup extends Group {
// 分组下账号数量(仅管理员可见)
account_count?: number
active_account_count?: number
rate_limited_account_count?: number
// OpenAI Messages 调度配置(仅 openai 平台使用)
default_mapped_model?: string

View File

@@ -158,12 +158,51 @@
</span>
</template>
<template #cell-account_count="{ value }">
<span
class="inline-flex items-center rounded bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-800 dark:bg-dark-600 dark:text-gray-300"
>
{{ t('admin.groups.accountsCount', { count: value || 0 }) }}
</span>
<template #cell-account_count="{ row }">
<div class="space-y-0.5 text-xs">
<div>
<span class="text-gray-500 dark:text-gray-400">{{ t('admin.groups.accountsAvailable') }}</span>
<span class="ml-1 font-medium text-emerald-600 dark:text-emerald-400">{{ (row.active_account_count || 0) - (row.rate_limited_account_count || 0) }}</span>
<span class="ml-1 inline-flex items-center rounded bg-gray-100 px-1.5 py-0.5 font-medium text-gray-800 dark:bg-dark-600 dark:text-gray-300">{{ t('admin.groups.accountsUnit') }}</span>
</div>
<div v-if="row.rate_limited_account_count">
<span class="text-gray-500 dark:text-gray-400">{{ t('admin.groups.accountsRateLimited') }}</span>
<span class="ml-1 font-medium text-amber-600 dark:text-amber-400">{{ row.rate_limited_account_count }}</span>
<span class="ml-1 inline-flex items-center rounded bg-gray-100 px-1.5 py-0.5 font-medium text-gray-800 dark:bg-dark-600 dark:text-gray-300">{{ t('admin.groups.accountsUnit') }}</span>
</div>
<div>
<span class="text-gray-500 dark:text-gray-400">{{ t('admin.groups.accountsTotal') }}</span>
<span class="ml-1 font-medium text-gray-700 dark:text-gray-300">{{ row.account_count || 0 }}</span>
<span class="ml-1 inline-flex items-center rounded bg-gray-100 px-1.5 py-0.5 font-medium text-gray-800 dark:bg-dark-600 dark:text-gray-300">{{ t('admin.groups.accountsUnit') }}</span>
</div>
</div>
</template>
<template #cell-capacity="{ row }">
<GroupCapacityBadge
v-if="capacityMap.get(row.id)"
:concurrency-used="capacityMap.get(row.id)!.concurrencyUsed"
:concurrency-max="capacityMap.get(row.id)!.concurrencyMax"
:sessions-used="capacityMap.get(row.id)!.sessionsUsed"
:sessions-max="capacityMap.get(row.id)!.sessionsMax"
:rpm-used="capacityMap.get(row.id)!.rpmUsed"
:rpm-max="capacityMap.get(row.id)!.rpmMax"
/>
<span v-else class="text-xs text-gray-400"></span>
</template>
<template #cell-usage="{ row }">
<div v-if="usageLoading" class="text-xs text-gray-400"></div>
<div v-else class="space-y-0.5 text-xs">
<div class="text-gray-500 dark:text-gray-400">
<span class="text-gray-400 dark:text-gray-500">{{ t('admin.groups.usageToday') }}</span>
<span class="ml-1 font-medium text-gray-700 dark:text-gray-300">${{ formatCost(usageMap.get(row.id)?.today_cost ?? 0) }}</span>
</div>
<div class="text-gray-500 dark:text-gray-400">
<span class="text-gray-400 dark:text-gray-500">{{ t('admin.groups.usageTotal') }}</span>
<span class="ml-1 font-medium text-gray-700 dark:text-gray-300">${{ formatCost(usageMap.get(row.id)?.total_cost ?? 0) }}</span>
</div>
</div>
</template>
<template #cell-status="{ value }">
@@ -1812,6 +1851,7 @@ import Select from '@/components/common/Select.vue'
import PlatformIcon from '@/components/common/PlatformIcon.vue'
import Icon from '@/components/icons/Icon.vue'
import GroupRateMultipliersModal from '@/components/admin/group/GroupRateMultipliersModal.vue'
import GroupCapacityBadge from '@/components/common/GroupCapacityBadge.vue'
import { VueDraggable } from 'vue-draggable-plus'
import { createStableObjectKeyResolver } from '@/utils/stableObjectKey'
import { useKeyedDebouncedSearch } from '@/composables/useKeyedDebouncedSearch'
@@ -1827,6 +1867,8 @@ const columns = computed<Column[]>(() => [
{ key: 'rate_multiplier', label: t('admin.groups.columns.rateMultiplier'), sortable: true },
{ key: 'is_exclusive', label: t('admin.groups.columns.type'), sortable: true },
{ key: 'account_count', label: t('admin.groups.columns.accounts'), sortable: true },
{ key: 'capacity', label: t('admin.groups.columns.capacity'), sortable: false },
{ key: 'usage', label: t('admin.groups.columns.usage'), sortable: false },
{ key: 'status', label: t('admin.groups.columns.status'), sortable: true },
{ key: 'actions', label: t('admin.groups.columns.actions'), sortable: false }
])
@@ -1963,6 +2005,9 @@ const copyAccountsGroupOptionsForEdit = computed(() => {
const groups = ref<AdminGroup[]>([])
const loading = ref(false)
const usageMap = ref<Map<number, { today_cost: number; total_cost: number }>>(new Map())
const usageLoading = ref(false)
const capacityMap = ref<Map<number, { concurrencyUsed: number; concurrencyMax: number; sessionsUsed: number; sessionsMax: number; rpmUsed: number; rpmMax: number }>>(new Map())
const searchQuery = ref('')
const filters = reactive({
platform: '',
@@ -2301,6 +2346,8 @@ const loadGroups = async () => {
groups.value = response.items
pagination.total = response.total
pagination.pages = response.pages
loadUsageSummary()
loadCapacitySummary()
} catch (error: any) {
if (signal.aborted || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') {
return
@@ -2314,6 +2361,49 @@ const loadGroups = async () => {
}
}
const formatCost = (cost: number): string => {
if (cost >= 1000) return cost.toFixed(0)
if (cost >= 100) return cost.toFixed(1)
return cost.toFixed(2)
}
const loadUsageSummary = async () => {
usageLoading.value = true
try {
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone
const data = await adminAPI.groups.getUsageSummary(tz)
const map = new Map<number, { today_cost: number; total_cost: number }>()
for (const item of data) {
map.set(item.group_id, { today_cost: item.today_cost, total_cost: item.total_cost })
}
usageMap.value = map
} catch (error) {
console.error('Error loading group usage summary:', error)
} finally {
usageLoading.value = false
}
}
const loadCapacitySummary = async () => {
try {
const data = await adminAPI.groups.getCapacitySummary()
const map = new Map<number, { concurrencyUsed: number; concurrencyMax: number; sessionsUsed: number; sessionsMax: number; rpmUsed: number; rpmMax: number }>()
for (const item of data) {
map.set(item.group_id, {
concurrencyUsed: item.concurrency_used,
concurrencyMax: item.concurrency_max,
sessionsUsed: item.sessions_used,
sessionsMax: item.sessions_max,
rpmUsed: item.rpm_used,
rpmMax: item.rpm_max
})
}
capacityMap.value = map
} catch (error) {
console.error('Error loading group capacity summary:', error)
}
}
let searchTimeout: ReturnType<typeof setTimeout>
const handleSearch = () => {
clearTimeout(searchTimeout)

View File

@@ -81,6 +81,14 @@
@change="applyFilters"
/>
</div>
<div class="w-full sm:w-40">
<Select
v-model="filters.platform"
:options="platformFilterOptions"
:placeholder="t('admin.subscriptions.allPlatforms')"
@change="applyFilters"
/>
</div>
</div>
<!-- Right: Actions -->
@@ -144,6 +152,13 @@
</div>
</div>
</div>
<button
@click="showGuideModal = true"
class="btn btn-secondary"
:title="t('admin.subscriptions.guide.showGuide')"
>
<Icon name="questionCircle" size="md" />
</button>
<button @click="showAssignModal = true" class="btn btn-primary">
<Icon name="plus" size="md" class="mr-2" />
{{ t('admin.subscriptions.assignSubscription') }}
@@ -638,6 +653,85 @@
@confirm="confirmResetQuota"
@cancel="showResetQuotaConfirm = false"
/>
<!-- Subscription Guide Modal -->
<teleport to="body">
<transition name="modal">
<div v-if="showGuideModal" class="fixed inset-0 z-50 flex items-center justify-center p-4" @mousedown.self="showGuideModal = false">
<div class="fixed inset-0 bg-black/50" @click="showGuideModal = false"></div>
<div class="relative max-h-[85vh] w-full max-w-2xl overflow-y-auto rounded-xl bg-white p-6 shadow-2xl dark:bg-dark-800">
<button type="button" class="absolute right-4 top-4 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200" @click="showGuideModal = false">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
<h2 class="mb-4 text-lg font-bold text-gray-900 dark:text-white">{{ t('admin.subscriptions.guide.title') }}</h2>
<p class="mb-5 text-sm text-gray-500 dark:text-gray-400">{{ t('admin.subscriptions.guide.subtitle') }}</p>
<!-- Step 1 -->
<div class="mb-5">
<h3 class="mb-2 flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-white">
<span class="flex h-6 w-6 items-center justify-center rounded-full bg-primary-100 text-xs font-bold text-primary-700 dark:bg-primary-900/40 dark:text-primary-300">1</span>
{{ t('admin.subscriptions.guide.step1.title') }}
</h3>
<ol class="ml-8 list-decimal space-y-1 text-sm text-gray-600 dark:text-gray-300">
<li>{{ t('admin.subscriptions.guide.step1.line1') }}</li>
<li>{{ t('admin.subscriptions.guide.step1.line2') }}</li>
<li>{{ t('admin.subscriptions.guide.step1.line3') }}</li>
</ol>
<div class="ml-8 mt-2">
<router-link
to="/admin/groups"
@click="showGuideModal = false"
class="inline-flex items-center gap-1 text-sm font-medium text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
>
{{ t('admin.subscriptions.guide.step1.link') }}
<Icon name="arrowRight" size="xs" />
</router-link>
</div>
</div>
<!-- Step 2 -->
<div class="mb-5">
<h3 class="mb-2 flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-white">
<span class="flex h-6 w-6 items-center justify-center rounded-full bg-primary-100 text-xs font-bold text-primary-700 dark:bg-primary-900/40 dark:text-primary-300">2</span>
{{ t('admin.subscriptions.guide.step2.title') }}
</h3>
<ol class="ml-8 list-decimal space-y-1 text-sm text-gray-600 dark:text-gray-300">
<li>{{ t('admin.subscriptions.guide.step2.line1') }}</li>
<li>{{ t('admin.subscriptions.guide.step2.line2') }}</li>
<li>{{ t('admin.subscriptions.guide.step2.line3') }}</li>
</ol>
</div>
<!-- Step 3 -->
<div class="mb-5">
<h3 class="mb-2 flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-white">
<span class="flex h-6 w-6 items-center justify-center rounded-full bg-primary-100 text-xs font-bold text-primary-700 dark:bg-primary-900/40 dark:text-primary-300">3</span>
{{ t('admin.subscriptions.guide.step3.title') }}
</h3>
<div class="ml-8 overflow-hidden rounded-lg border border-gray-200 dark:border-dark-600">
<table class="w-full text-sm">
<tbody>
<tr v-for="(row, i) in guideActionRows" :key="i" class="border-b border-gray-100 dark:border-dark-700 last:border-0">
<td class="whitespace-nowrap bg-gray-50 px-3 py-2 font-medium text-gray-700 dark:bg-dark-700 dark:text-gray-300">{{ row.action }}</td>
<td class="px-3 py-2 text-gray-600 dark:text-gray-400">{{ row.desc }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Tip -->
<div class="rounded-lg bg-blue-50 p-3 text-xs text-blue-700 dark:bg-blue-900/20 dark:text-blue-300">
{{ t('admin.subscriptions.guide.tip') }}
</div>
<div class="mt-4 text-right">
<button type="button" class="btn btn-primary btn-sm" @click="showGuideModal = false">{{ t('common.close') }}</button>
</div>
</div>
</div>
</transition>
</teleport>
</AppLayout>
</template>
@@ -674,6 +768,15 @@ interface GroupOption {
rate: number
}
// Guide modal state
const showGuideModal = ref(false)
const guideActionRows = computed(() => [
{ action: t('admin.subscriptions.guide.actions.adjust'), desc: t('admin.subscriptions.guide.actions.adjustDesc') },
{ action: t('admin.subscriptions.guide.actions.resetQuota'), desc: t('admin.subscriptions.guide.actions.resetQuotaDesc') },
{ action: t('admin.subscriptions.guide.actions.revoke'), desc: t('admin.subscriptions.guide.actions.revokeDesc') }
])
// User column display mode: 'email' or 'username'
const userColumnMode = ref<'email' | 'username'>('email')
const USER_COLUMN_MODE_KEY = 'subscription-user-column-mode'
@@ -813,6 +916,7 @@ let userSearchTimeout: ReturnType<typeof setTimeout> | null = null
const filters = reactive({
status: 'active',
group_id: '',
platform: '',
user_id: null as number | null
})
@@ -855,6 +959,15 @@ const groupOptions = computed(() => [
...groups.value.map((g) => ({ value: g.id.toString(), label: g.name }))
])
const platformFilterOptions = computed(() => [
{ value: '', label: t('admin.subscriptions.allPlatforms') },
{ value: 'anthropic', label: 'Anthropic' },
{ value: 'openai', label: 'OpenAI' },
{ value: 'gemini', label: 'Gemini' },
{ value: 'antigravity', label: 'Antigravity' },
{ value: 'sora', label: 'Sora' }
])
// Group options for assign (only subscription type groups)
const subscriptionGroupOptions = computed(() =>
groups.value
@@ -890,6 +1003,7 @@ const loadSubscriptions = async () => {
{
status: (filters.status as any) || undefined,
group_id: filters.group_id ? parseInt(filters.group_id) : undefined,
platform: filters.platform || undefined,
user_id: filters.user_id || undefined,
sort_by: sortState.sort_by,
sort_order: sortState.sort_order