Merge upstream main into feat/500-api-key-ip-whitelist

This commit is contained in:
RWDai
2026-05-20 10:26:56 +08:00
501 changed files with 47013 additions and 3667 deletions

View File

@@ -1,4 +1,5 @@
import apiClient from './client'
import type { ModelTestCapabilities } from './endpoints/types'
import axios from 'axios'
import { cachedRequest, buildCacheKey } from '@/utils/cache'
import type { BillingSummary } from './auth'
@@ -420,6 +421,8 @@ export interface ProviderModelsQueryResponse {
owned_by?: string
display_name?: string
api_format?: string
api_formats?: string[]
model_test_capabilities?: ModelTestCapabilities | null
}>
error?: string
from_cache?: boolean

View File

@@ -8,6 +8,7 @@ export interface Announcement {
priority: number
is_pinned: boolean
is_active: boolean
requires_ack: boolean
author: {
id: string // UUID
username: string
@@ -31,6 +32,7 @@ export interface CreateAnnouncementRequest {
type?: 'info' | 'warning' | 'maintenance' | 'important'
priority?: number
is_pinned?: boolean
requires_ack?: boolean
start_time?: string
end_time?: string
}
@@ -42,6 +44,7 @@ export interface UpdateAnnouncementRequest {
priority?: number
is_active?: boolean
is_pinned?: boolean
requires_ack?: boolean
start_time?: string
end_time?: string
}
@@ -88,6 +91,11 @@ export const announcementApi = {
return response.data
},
async getRequiredUnreadAnnouncements(): Promise<AnnouncementListResponse> {
const response = await apiClient.get('/api/announcements/users/me/required-unread')
return response.data
},
// 管理员方法
// 创建公告
async createAnnouncement(data: CreateAnnouncementRequest): Promise<{ id: string; title: string; message: string }> {
@@ -106,4 +114,4 @@ export const announcementApi = {
const response = await apiClient.delete(`/api/announcements/${id}`)
return response.data
}
}
}

View File

@@ -69,6 +69,9 @@ export interface RegisterRequest {
username: string
password: string
turnstile_token?: string
invite_code?: string
privacy_policy_accepted?: boolean
privacy_policy_version?: string
}
export interface RegisterResponse {
@@ -86,6 +89,14 @@ export interface RegistrationSettingsResponse {
turnstile_enabled?: boolean
turnstile_site_key?: string | null
turnstile_required_actions?: string[]
privacy_policy?: RegistrationPrivacyPolicySettings
}
export interface RegistrationPrivacyPolicySettings {
enabled: boolean
format: 'markdown' | 'html'
content: string
version: string
}
export interface AuthSettingsResponse {

View File

@@ -196,6 +196,7 @@ export interface RequestDetail {
total_cost?: number
cache_creation_cost?: number
cache_read_cost?: number
image_output_cost?: number
request_cost?: number // 按次计费费用
// Historical pricing fields (per 1M tokens)
input_price_per_1m?: number

View File

@@ -112,6 +112,7 @@ export interface PoolPresetMeta {
export interface PoolKeyDetail {
key_id: string
key_name: string
provider_type?: string | null
is_active: boolean
auth_type: string
auth_type_by_format?: Record<string, 'api_key' | 'bearer'> | null

View File

@@ -111,6 +111,12 @@ export async function importProviderRefreshToken(
account_id?: string
account_user_id?: string
plan_type?: string
pool_tier?: string
sso_rw_token?: string
cf_cookies?: string
cf_clearance?: string
user_agent?: string
browser_profile?: string
user_id?: string
account_name?: string
}

View File

@@ -48,6 +48,7 @@ function normalizeProviderSummary(
...provider,
chat_pii_redaction: normalizeChatPiiRedactionProvider(provider.chat_pii_redaction),
pool_advanced: normalizePoolAdvanced(provider.pool_advanced),
kiro_simulated_cache_enabled: provider.kiro_simulated_cache_enabled ?? false,
}
}
@@ -91,7 +92,7 @@ export async function updateProvider(
providerId: string,
data: Partial<{
name: string
provider_type: 'custom' | 'vertex_ai' | 'claude_code' | 'codex' | 'chatgpt_web' | 'gemini_cli' | 'antigravity' | 'kiro'
provider_type: 'custom' | 'vertex_ai' | 'claude_code' | 'codex' | 'chatgpt_web' | 'gemini_cli' | 'antigravity' | 'kiro' | 'grok'
description: string | null
website: string
provider_priority: number
@@ -126,7 +127,7 @@ export async function updateProvider(
export async function createProvider(
data: {
name: string
provider_type?: 'custom' | 'vertex_ai' | 'claude_code' | 'codex' | 'chatgpt_web' | 'gemini_cli' | 'antigravity' | 'kiro'
provider_type?: 'custom' | 'vertex_ai' | 'claude_code' | 'codex' | 'chatgpt_web' | 'gemini_cli' | 'antigravity' | 'kiro' | 'grok'
description?: string
website?: string
billing_type?: 'monthly_quota' | 'pay_as_you_go' | 'free_tier'

View File

@@ -18,9 +18,20 @@ export interface PricingTier {
cache_ttl_pricing?: CacheTTLPricing[]
}
export type ImageOutputQuality = 'low' | 'medium' | 'high'
export interface ImageOutputPriceRange {
up_to_pixels: number | null
prices: Partial<Record<ImageOutputQuality, number>>
label?: string | null
}
/** 阶梯计费配置 */
export interface TieredPricingConfig {
tiers: PricingTier[]
image_output_prices?: Record<string, Record<string, number>> | null
image_output_price_default?: number | null
image_output_price_ranges?: ImageOutputPriceRange[] | null
}
export interface Model {

View File

@@ -10,7 +10,7 @@ export interface ProxyConfig {
url?: string
username?: string
password?: string
node_id?: string // 代理节点 IDaether-proxy 注册的节点,与 url 互斥)
node_id?: string // 代理节点 IDaether-tunnel 注册的节点,与 url 互斥)
enabled?: boolean // 是否启用代理false 时保留配置但不使用)
}
@@ -389,11 +389,25 @@ export interface ChatGPTWebUpstreamMetadata {
user_id?: string | null
}
export interface GrokUpstreamMetadata {
updated_at?: number // Unix 时间戳(秒)
plan_type?: string | null
pool_tier?: string | null
is_banned?: boolean | null
ban_reason?: string | null
last_rate_limit_probe_at?: number | null
clearance_state?: string | null
email?: string | null
account_id?: string | null
account_user_id?: string | null
}
export interface UpstreamMetadata {
codex?: CodexUpstreamMetadata
antigravity?: AntigravityUpstreamMetadata
kiro?: KiroUpstreamMetadata
chatgpt_web?: ChatGPTWebUpstreamMetadata
grok?: GrokUpstreamMetadata
}
// 按格式的健康度数据
@@ -409,11 +423,20 @@ export interface FormatHealthData {
// 按格式的熔断器数据
export interface FormatCircuitBreakerData {
open: boolean
reason?: string | null
open_at?: string | null
next_probe_at?: string | null
next_probe_at_unix_secs?: number | null
probe_interval_minutes?: number | null
max_probe_interval_minutes?: number | null
failure_count?: number | null
consecutive_failures?: number | null
last_failure_at?: string | null
last_probe_failure_at?: string | null
half_open_until?: string | null
half_open_successes: number
half_open_failures: number
request_results_window?: Array<{ ts: number; ok: boolean }>
}
export interface EndpointAPIKeyUpdate {
@@ -512,7 +535,7 @@ export interface PublicEndpointStatusMonitorResponse {
formats: PublicEndpointStatusMonitor[]
}
export type ProviderType = 'custom' | 'claude_code' | 'codex' | 'chatgpt_web' | 'gemini_cli' | 'antigravity' | 'kiro' | 'vertex_ai'
export type ProviderType = 'custom' | 'claude_code' | 'codex' | 'chatgpt_web' | 'gemini_cli' | 'antigravity' | 'kiro' | 'grok' | 'vertex_ai'
export interface ClaudeCodeAdvancedConfig {
// 会话数量控制null/undefined 表示不限制
@@ -661,6 +684,7 @@ export interface ProviderWithEndpointsSummary {
failover_rules?: FailoverRulesConfig | null
ops_configured: boolean // 是否配置了扩展操作(余额监控等)
ops_architecture_id?: string // 扩展操作使用的架构 ID如 cubence, anyrouter
kiro_simulated_cache_enabled?: boolean
created_at: string
updated_at: string
}

View File

@@ -64,6 +64,7 @@ export interface QuotaStatusSnapshot {
reset_at?: number | null
reset_seconds?: number | null
plan_type?: string | null
pool_tier?: string | null
credits?: QuotaCreditsSnapshot | null
windows?: QuotaWindowSnapshot[] | null
}

View File

@@ -61,11 +61,17 @@ export interface UsageRecordDetail {
cost: number // 官方费率
actual_cost?: number // 倍率消耗(仅管理员可见)
rate_multiplier?: number // 成本倍率(仅管理员可见)
response_time_ms?: number
response_time_ms?: number | null
first_byte_time_ms?: number | null
is_stream: boolean
upstream_is_stream?: boolean
client_requested_stream?: boolean
client_is_stream?: boolean
client_family?: string | null
client_ip?: string | null
user_agent?: string | null
request_path?: string | null
request_path_and_query?: string | null
created_at: string
cache_creation_input_tokens?: number
cache_creation_ephemeral_5m_input_tokens?: number

View File

@@ -3,6 +3,7 @@ import apiClient from './client'
export interface OAuthProviderInfo {
provider_type: string
display_name: string
icon_url?: string | null
}
export interface OAuthProvidersResponse {
@@ -46,6 +47,7 @@ export interface OAuthProviderAdminConfig {
frontend_callback_url: string
attribute_mapping?: Record<string, unknown> | null
extra_config?: Record<string, unknown> | null
icon_url?: string | null
is_enabled: boolean
}
@@ -61,6 +63,7 @@ export interface OAuthProviderUpsertRequest {
frontend_callback_url: string
attribute_mapping?: Record<string, unknown> | null
extra_config?: Record<string, unknown> | null
icon_url?: string | null
is_enabled: boolean
force?: boolean
}

View File

@@ -26,10 +26,10 @@ export interface ProxyNode {
proxy_url?: string
proxy_username?: string
proxy_password?: string
// 硬件信息aether-proxy 节点)
// 硬件信息aether-tunnel 节点)
hardware_info: Record<string, unknown> | null
estimated_max_concurrency: number | null
// 远程配置aether-proxy 节点)
// 远程配置aether-tunnel 节点)
remote_config: ProxyNodeRemoteConfig | null
config_version: number
registered_by: string | null

View File

@@ -0,0 +1,114 @@
import apiClient from './client'
export interface ReferralSummary {
total_invites: number
effective_invites: number
paid_reward_usd: number
pending_reward_usd: number
reversed_reward_usd: number
}
export interface ReferralDashboardResponse {
invite_code: string
invitation_link: string
summary: ReferralSummary
}
export interface ReferralRelationshipRecord {
id: string
inviter_user_id: string
inviter_username?: string | null
invitee_user_id: string
invitee_username?: string | null
invite_code_snapshot: string
first_paid_order_id?: string | null
first_paid_at_unix_secs?: number | null
source?: Record<string, unknown> | null
created_at_unix_secs: number
}
export interface ReferralRewardRecord {
id: string
referral_id: string
inviter_user_id: string
invitee_user_id: string
reward_type: string
source_order_id?: string | null
trigger_point: string
amount_usd: number
status: string
wallet_transaction_id?: string | null
idempotency_key: string
reversed_amount_usd: number
pending_reversal_amount_usd: number
admin_operator_id?: string | null
admin_note?: string | null
created_at_unix_secs: number
updated_at_unix_secs: number
}
export interface ReferralListResponse<T> {
items: T[]
total: number
limit: number
offset: number
stats: ReferralSummary
}
export interface ReferralRelationshipQuery {
inviter?: string
invitee?: string
invite_code?: string
first_paid?: boolean | null
limit?: number
offset?: number
}
export interface ReferralRewardQuery {
order_id?: string
reward_type?: string
status?: string
limit?: number
offset?: number
}
function cleanParams<T extends Record<string, unknown>>(params: T): Partial<T> {
return Object.fromEntries(
Object.entries(params).filter(([, value]) => value !== undefined && value !== null && value !== '')
) as Partial<T>
}
export const referralApi = {
async getMyReferral(): Promise<ReferralDashboardResponse> {
const response = await apiClient.get<ReferralDashboardResponse>('/api/users/me/referral')
return response.data
},
async getAdminReferrals(
params: ReferralRelationshipQuery = {}
): Promise<ReferralListResponse<ReferralRelationshipRecord>> {
const response = await apiClient.get('/api/admin/referrals', {
params: cleanParams(params as Record<string, unknown>)
})
return response.data
},
async getAdminReferralRewards(
params: ReferralRewardQuery = {}
): Promise<ReferralListResponse<ReferralRewardRecord>> {
const response = await apiClient.get('/api/admin/referral-rewards', {
params: cleanParams(params as Record<string, unknown>)
})
return response.data
},
async retryReferralReward(id: string, note?: string): Promise<{ reward: ReferralRewardRecord }> {
const response = await apiClient.post(`/api/admin/referral-rewards/${id}/retry`, { note })
return response.data
},
async voidReferralReward(id: string, note?: string): Promise<{ reward: ReferralRewardRecord }> {
const response = await apiClient.post(`/api/admin/referral-rewards/${id}/void`, { note })
return response.data
}
}

View File

@@ -9,6 +9,17 @@ export interface IPBlacklistEntry {
ttl?: number
}
export interface BlacklistListEntry {
ip_address: string
reason: string
ttl_seconds?: number | null
}
export interface BlacklistResponse {
items: BlacklistListEntry[]
total: number
}
export interface IPWhitelistEntry {
ip_address: string
}
@@ -50,6 +61,14 @@ export const blacklistApi = {
async getStats(): Promise<BlacklistStats> {
const response = await apiClient.get('/api/admin/security/ip/blacklist/stats')
return response.data
},
/**
* 获取黑名单列表
*/
async getList(): Promise<BlacklistResponse> {
const response = await apiClient.get('/api/admin/security/ip/blacklist')
return response.data
}
}

View File

@@ -24,6 +24,11 @@ export interface UsageRecord {
response_time?: number
created_at: string
has_fallback?: boolean // 🆕 是否发生了 fallback
client_family?: string | null
client_ip?: string | null
user_agent?: string | null
request_path?: string | null
request_path_and_query?: string | null
}
export interface UsageStats {
@@ -107,6 +112,7 @@ export interface UsageFilters {
granularity?: 'hour' | 'day' | 'week' | 'month'
timezone?: string
tz_offset_minutes?: number
client_family?: string
page?: number
page_size?: number
}

View File

@@ -7,23 +7,12 @@
<SelectValue placeholder="选择时间段" />
</SelectTrigger>
<SelectContent :searchable="false">
<SelectItem value="today">
今天
</SelectItem>
<SelectItem value="yesterday">
昨天
</SelectItem>
<SelectItem value="last7days">
最近7天
</SelectItem>
<SelectItem value="last30days">
最近30天
</SelectItem>
<SelectItem value="last90days">
最近90天
</SelectItem>
<SelectItem value="custom">
自定义
<SelectItem
v-for="preset in activePresetOptions"
:key="preset"
:value="preset"
>
{{ presetLabels[preset] }}
</SelectItem>
</SelectContent>
</Select>
@@ -85,27 +74,51 @@ import {
} from '@/components/ui'
import type { DateRangeParams } from '@/features/usage/types'
const props = defineProps<{
const selectablePresets = ['today', 'yesterday', 'last7days', 'last30days', 'last90days', 'custom'] as const
type SelectablePreset = typeof selectablePresets[number]
const presetLabels: Record<SelectablePreset, string> = {
today: '今天',
yesterday: '昨天',
last7days: '最近7天',
last30days: '最近30天',
last90days: '最近90天',
custom: '自定义'
}
const props = withDefaults(defineProps<{
modelValue: DateRangeParams
showGranularity?: boolean
allowHourly?: boolean
}>()
presetOptions?: SelectablePreset[]
}>(), {
presetOptions: () => ['today', 'yesterday', 'last7days', 'last30days', 'last90days', 'custom']
})
const emit = defineEmits<{
'update:modelValue': [value: DateRangeParams]
}>()
const selectablePresets = ['today', 'yesterday', 'last7days', 'last30days', 'last90days', 'custom'] as const
type SelectablePreset = typeof selectablePresets[number]
const activePresetOptions = computed<SelectablePreset[]>(() => {
const unique = new Set(props.presetOptions)
const filtered = selectablePresets.filter((preset) => unique.has(preset))
return filtered.length > 0 ? filtered : [...selectablePresets]
})
function defaultPreset(): SelectablePreset {
const options = activePresetOptions.value
if (options.includes('last7days')) return 'last7days'
return options[0] ?? 'last7days'
}
function normalizePreset(value: DateRangeParams): SelectablePreset {
if (value.preset && selectablePresets.includes(value.preset as SelectablePreset)) {
if (value.preset && activePresetOptions.value.includes(value.preset as SelectablePreset)) {
return value.preset as SelectablePreset
}
if (!value.preset && (value.start_date || value.end_date)) {
if (!value.preset && (value.start_date || value.end_date) && activePresetOptions.value.includes('custom')) {
return 'custom'
}
return 'last7days'
return defaultPreset()
}
const selectedPreset = ref<SelectablePreset>(normalizePreset(props.modelValue))
@@ -168,6 +181,12 @@ watch(() => props.modelValue, (value) => {
lastEmittedValue = getValueKey(value)
}, { deep: true })
watch(activePresetOptions, () => {
if (!activePresetOptions.value.includes(selectedPreset.value)) {
selectedPreset.value = normalizePreset(props.modelValue)
}
})
watch([selectedPreset, startDate, endDate, selectedGranularity], () => {
if (!allowHourly.value || !canUseHourly.value) {
if (selectedGranularity.value === 'hour') {

View File

@@ -0,0 +1,70 @@
<template>
<div class="flex flex-wrap items-center gap-2">
<TimeRangePicker
:model-value="timeRange"
:show-granularity="false"
:preset-options="timeRangePresetOptions"
@update:model-value="$emit('update:timeRange', $event)"
/>
<Select
:model-value="metric"
@update:model-value="emitMetric"
>
<SelectTrigger class="h-8 text-xs w-28">
<SelectValue placeholder="指标" />
</SelectTrigger>
<SelectContent>
<SelectItem value="requests">
请求数
</SelectItem>
<SelectItem value="tokens">
Tokens
</SelectItem>
<SelectItem value="cost">
成本
</SelectItem>
</SelectContent>
</Select>
</div>
</template>
<script setup lang="ts">
import { TimeRangePicker } from '@/components/common'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui'
import type { DateRangeParams } from '@/features/usage/types'
type LeaderboardMetric = 'requests' | 'tokens' | 'cost'
type LeaderboardTimeRangePreset = 'today' | 'yesterday' | 'last7days' | 'last30days' | 'last90days' | 'custom'
defineProps<{
metric: LeaderboardMetric
timeRange: DateRangeParams
}>()
const emit = defineEmits<{
(e: 'update:metric', value: LeaderboardMetric): void
(e: 'update:timeRange', value: DateRangeParams): void
}>()
const timeRangePresetOptions: LeaderboardTimeRangePreset[] = [
'today',
'yesterday',
'last7days',
'last30days',
'last90days',
'custom'
]
function emitMetric(value: string) {
if (value === 'requests' || value === 'tokens' || value === 'cost') {
emit('update:metric', value)
}
}
</script>

View File

@@ -1,26 +1,28 @@
<template>
<TableCard :title="title">
<template #actions>
<Select
v-if="showMetricSelect"
:model-value="metric"
@update:model-value="emitMetric"
>
<SelectTrigger class="h-8 text-xs w-28">
<SelectValue placeholder="指标" />
</SelectTrigger>
<SelectContent>
<SelectItem value="requests">
请求数
</SelectItem>
<SelectItem value="tokens">
Tokens
</SelectItem>
<SelectItem value="cost">
成本
</SelectItem>
</SelectContent>
</Select>
<slot name="actions">
<Select
v-if="showMetricSelect"
:model-value="metric"
@update:model-value="emitMetric"
>
<SelectTrigger class="h-8 text-xs w-28">
<SelectValue placeholder="指标" />
</SelectTrigger>
<SelectContent>
<SelectItem value="requests">
请求数
</SelectItem>
<SelectItem value="tokens">
Tokens
</SelectItem>
<SelectItem value="cost">
成本
</SelectItem>
</SelectContent>
</Select>
</slot>
</template>
<div
@@ -77,6 +79,8 @@
</TableRow>
</TableBody>
</Table>
<slot name="pagination" />
</TableCard>
</template>

View File

@@ -1,4 +1,5 @@
export { default as ActivityHeatmap } from './ActivityHeatmap.vue'
export { default as LeaderboardControls } from './LeaderboardControls.vue'
export { default as LeaderboardTable } from './LeaderboardTable.vue'
export { default as CostForecastChart } from './CostForecastChart.vue'
export { default as QuotaProgressCard } from './QuotaProgressCard.vue'

View File

@@ -2,8 +2,8 @@
<input
type="checkbox"
:class="checkboxClass"
:checked="isChecked"
v-bind="$attrs"
:checked="isChecked"
@change="handleChange"
>
</template>

View File

@@ -63,7 +63,7 @@
<!-- eslint-disable vue/no-v-html -->
<span
class="oauth-icon"
v-html="getOAuthIcon(oauthProviders[0].provider_type)"
v-html="getOAuthIcon(oauthProviders[0].provider_type, oauthProviders[0].icon_url)"
/>
<!-- eslint-enable vue/no-v-html -->
<span>使用 {{ oauthProviders[0].display_name }} 登录</span>
@@ -88,7 +88,7 @@
<!-- eslint-disable vue/no-v-html -->
<span
class="oauth-icon-lg"
v-html="getOAuthIcon(p.provider_type)"
v-html="getOAuthIcon(p.provider_type, p.icon_url)"
/>
<!-- eslint-enable vue/no-v-html -->
</button>
@@ -241,6 +241,7 @@
:password-policy-level="passwordPolicyLevel"
:turnstile-enabled="turnstileEnabled"
:turnstile-site-key="turnstileSiteKey"
:privacy-policy="privacyPolicy"
@success="handleRegisterSuccess"
@switch-to-login="handleSwitchToLogin"
/>
@@ -248,7 +249,7 @@
<script setup lang="ts">
import { ref, watch, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { Dialog } from '@/components/ui'
import Button from '@/components/ui/button.vue'
import Input from '@/components/ui/input.vue'
@@ -259,7 +260,7 @@ import { useSiteInfo } from '@/composables/useSiteInfo'
import { normalizePasswordPolicyLevel, type PasswordPolicyLevel } from '@/utils/passwordPolicy'
import { isDemoMode, DEMO_ACCOUNTS } from '@/config/demo'
import RegisterDialog from './RegisterDialog.vue'
import { authApi } from '@/api/auth'
import { authApi, type RegistrationPrivacyPolicySettings } from '@/api/auth'
import { oauthApi, type OAuthProviderInfo } from '@/api/oauth'
import { getClientDeviceId } from '@/utils/deviceId'
import { getApiUrl } from '@/utils/url'
@@ -274,6 +275,7 @@ const emit = defineEmits<{
}>()
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const { success: showSuccess, warning: showWarning, error: showError } = useToast()
const { siteName } = useSiteInfo()
@@ -287,6 +289,12 @@ const passwordPolicyLevel = ref<PasswordPolicyLevel>('weak')
const turnstileEnabled = ref(false)
const turnstileSiteKey = ref<string | null>(null)
const allowRegistration = ref(false) // 由系统配置控制,默认关闭
const privacyPolicy = ref<RegistrationPrivacyPolicySettings>({
enabled: false,
format: 'markdown',
content: '',
version: ''
})
// LDAP authentication settings
const PREFERRED_AUTH_TYPE_KEY = 'aether_preferred_auth_type'
@@ -446,6 +454,12 @@ onMounted(async () => {
passwordPolicyLevel.value = normalizePasswordPolicyLevel(regSettings.password_policy_level)
turnstileEnabled.value = !!regSettings.turnstile_enabled
turnstileSiteKey.value = regSettings.turnstile_site_key || null
privacyPolicy.value = regSettings.privacy_policy ?? {
enabled: false,
format: 'markdown',
content: '',
version: ''
}
localEnabled.value = authSettings.local_enabled
ldapEnabled.value = authSettings.ldap_enabled
@@ -465,6 +479,10 @@ onMounted(async () => {
}
oauthProviders.value = providers
if (allowRegistration.value && (route.path === '/register' || typeof route.query.invite === 'string')) {
isOpen.value = false
showRegisterDialog.value = true
}
} catch {
// If获取失败保持默认关闭注册 & 关闭邮箱验证 & 使用本地认证
allowRegistration.value = false
@@ -473,6 +491,12 @@ onMounted(async () => {
passwordPolicyLevel.value = 'weak'
turnstileEnabled.value = false
turnstileSiteKey.value = null
privacyPolicy.value = {
enabled: false,
format: 'markdown',
content: '',
version: ''
}
localEnabled.value = true
ldapEnabled.value = false
ldapExclusive.value = false

View File

@@ -211,6 +211,46 @@
两次输入的密码不一致
</p>
</div>
<div
v-if="inviteCode"
class="rounded-lg border border-primary/20 bg-primary/5 px-3 py-2 text-xs text-muted-foreground"
>
已识别邀请码 <span class="font-mono font-semibold text-foreground">{{ inviteCode }}</span>
</div>
<div
v-if="privacyPolicyEnabled"
class="rounded-lg border border-border bg-muted/30 p-3"
>
<label class="flex items-start gap-2 text-sm">
<Checkbox
:checked="privacyAccepted"
class="mt-0.5"
@update:checked="privacyAccepted = !!$event"
/>
<span class="leading-6">
我已阅读并同意
<button
type="button"
class="font-medium text-primary underline-offset-4 hover:underline"
@click="privacyDialogOpen = true"
>
隐私政策
</button>
<RouterLink
to="/privacy-policy"
target="_blank"
class="ml-1 text-xs text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
>
新窗口打开
</RouterLink>
</span>
</label>
<p class="mt-2 text-xs text-muted-foreground">
当前版本{{ privacyPolicyVersion }}
</p>
</div>
</form>
<!-- 登录链接 -->
@@ -245,13 +285,37 @@
</Button>
</template>
</Dialog>
<Dialog
v-model="privacyDialogOpen"
size="2xl"
title="隐私政策"
>
<!-- eslint-disable vue/no-v-html -->
<div
class="prose prose-sm dark:prose-invert max-h-[60vh] max-w-none overflow-y-auto"
v-html="renderedPrivacyPolicy"
/>
<!-- eslint-enable vue/no-v-html -->
<template #footer>
<Button
type="button"
@click="privacyDialogOpen = false"
>
我知道了
</Button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch, onUnmounted, nextTick } from 'vue'
import { authApi, type RegisterRequest } from '@/api/auth'
import { RouterLink } from 'vue-router'
import { marked } from 'marked'
import { authApi, type RegisterRequest, type RegistrationPrivacyPolicySettings } from '@/api/auth'
import { useToast } from '@/composables/useToast'
import { parseApiError } from '@/utils/errorParser'
import { sanitizeHtml, sanitizeMarkdown } from '@/utils/sanitize'
import {
getPasswordPolicyHint,
getPasswordPolicyPlaceholder,
@@ -260,10 +324,13 @@ import {
} from '@/utils/passwordPolicy'
import { Dialog } from '@/components/ui'
import Button from '@/components/ui/button.vue'
import Checkbox from '@/components/ui/checkbox.vue'
import Input from '@/components/ui/input.vue'
import Label from '@/components/ui/label.vue'
import TurnstileWidget from './TurnstileWidget.vue'
const INVITE_CODE_STORAGE_KEY = 'aether_invite_code'
interface Props {
open?: boolean
requireEmailVerification?: boolean
@@ -271,6 +338,7 @@ interface Props {
passwordPolicyLevel?: PasswordPolicyLevel
turnstileEnabled?: boolean
turnstileSiteKey?: string | null
privacyPolicy?: RegistrationPrivacyPolicySettings
}
interface Emits {
@@ -285,7 +353,13 @@ const props = withDefaults(defineProps<Props>(), {
emailConfigured: true,
passwordPolicyLevel: 'weak',
turnstileEnabled: false,
turnstileSiteKey: null
turnstileSiteKey: null,
privacyPolicy: () => ({
enabled: false,
format: 'markdown',
content: '',
version: ''
})
})
const emit = defineEmits<Emits>()
@@ -422,6 +496,32 @@ const handleTurnstileError = (message: string) => {
showError(message, '人机验证失败')
}
const inviteCode = ref<string | null>(null)
const privacyAccepted = ref(false)
const privacyDialogOpen = ref(false)
const privacyPolicyEnabled = computed(() => !!props.privacyPolicy?.enabled)
const privacyPolicyVersion = computed(() => props.privacyPolicy?.version || '1')
const renderedPrivacyPolicy = computed(() => {
const policy = props.privacyPolicy
if (!policy?.content) return '<p>暂无隐私政策内容</p>'
if (policy.format === 'html') {
return sanitizeHtml(policy.content)
}
const rawHtml = marked(policy.content) as string
return sanitizeMarkdown(rawHtml)
})
function loadInviteCode(): string | null {
if (typeof window === 'undefined') return null
const fromQuery = new URLSearchParams(window.location.search).get('invite')
const normalized = (fromQuery || localStorage.getItem(INVITE_CODE_STORAGE_KEY) || '')
.trim()
.toUpperCase()
if (!normalized) return null
localStorage.setItem(INVITE_CODE_STORAGE_KEY, normalized)
return normalized
}
// Send code cooldown timer
const canSendCode = computed(() => {
if (!formData.value.email) return false
@@ -501,6 +601,10 @@ const canSubmit = computed(() => {
return false
}
if (privacyPolicyEnabled.value && !privacyAccepted.value) {
return false
}
return true
})
@@ -618,7 +722,9 @@ const resetForm = () => {
isSendingCode.value = false
codeSentAt.value = null
cooldownSeconds.value = 0
resetTurnstile()
inviteCode.value = loadInviteCode()
privacyAccepted.value = false
privacyDialogOpen.value = false
// Reset password field nonce
formNonce.value = createFormNonce()
@@ -742,6 +848,11 @@ const handleSubmit = async () => {
return
}
if (privacyPolicyEnabled.value && !privacyAccepted.value) {
showError('请先阅读并同意隐私政策')
return
}
isLoading.value = true
loadingText.value = '注册中...'
@@ -758,6 +869,13 @@ const handleSubmit = async () => {
if (turnstileRequired.value && currentTurnstileAction.value === 'register') {
registerData.turnstile_token = turnstileToken.value
}
if (inviteCode.value) {
registerData.invite_code = inviteCode.value
}
if (privacyPolicyEnabled.value) {
registerData.privacy_policy_accepted = privacyAccepted.value
registerData.privacy_policy_version = privacyPolicyVersion.value
}
const response = await authApi.register(registerData)

View File

@@ -16,27 +16,6 @@
v-if="!isEditMode"
class="w-[260px] shrink-0 flex flex-col h-full"
>
<!-- 手动添加入口 -->
<button
type="button"
class="mb-3 w-full rounded-lg border px-3 py-2 text-left transition-colors"
:class="manualModelMode
? 'border-primary bg-primary/10 text-primary'
: 'border-border/60 bg-muted/20 hover:bg-muted/40'"
@click="enableManualModelMode"
>
<div class="flex items-center justify-between gap-2">
<span class="text-sm font-medium">手动添加模型</span>
<Plus class="h-4 w-4 shrink-0" />
</div>
<p
class="mt-1 text-xs"
:class="manualModelMode ? 'text-primary/80' : 'text-muted-foreground'"
>
无法联网获取目录时直接填写模型 ID 继续创建
</p>
</button>
<!-- 搜索框 -->
<div class="relative mb-3">
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
@@ -109,7 +88,7 @@
v-if="groupedModels.length === 0"
class="text-center py-8 text-sm text-muted-foreground"
>
{{ emptyModelListText }}
{{ searchQuery ? '未找到模型' : '加载中...' }}
</div>
</template>
</div>
@@ -129,12 +108,6 @@
<h4 class="font-medium text-sm">
基本信息
</h4>
<div
v-if="manualModelMode && !isEditMode"
class="rounded-lg border border-primary/30 bg-primary/5 px-3 py-2 text-xs text-muted-foreground"
>
当前为手动添加模式填写模型 ID名称和价格后即可离线创建统一模型稍后可在模型详情中关联 Provider
</div>
<div class="grid grid-cols-2 gap-3">
<div class="space-y-1.5">
<Label
@@ -230,6 +203,21 @@
</div>
</div>
</div>
<div class="flex items-start gap-2 border-t border-border/60 pt-3">
<Checkbox
:checked="isImageGenerationEnabled"
class="mt-0.5"
@update:checked="setImageGenerationEnabled"
/>
<div class="space-y-1">
<div class="text-sm font-medium">
图片模型
</div>
<p class="text-xs text-muted-foreground">
启用图片输出计费,并展开尺寸 × 质量矩阵价格。
</p>
</div>
</div>
</div>
</section>
@@ -242,6 +230,7 @@
ref="tieredPricingEditorRef"
v-model="tieredPricing"
:show-cache1h="true"
:show-image-pricing="isImageGenerationEnabled"
/>
<div class="flex items-center gap-3 pt-2 border-t">
<Label class="text-xs whitespace-nowrap">按次计费</Label>
@@ -370,7 +359,7 @@
{{ isEditMode ? '保存' : '添加' }}
</Button>
<Button
v-if="(selectedModel || manualModelMode) && !isEditMode"
v-if="selectedModel && !isEditMode"
type="button"
variant="ghost"
@click="clearSelection"
@@ -409,7 +398,6 @@ import {
EMBEDDING_API_FORMATS,
buildGlobalModelCreatePayload,
buildGlobalModelUpdatePayload,
getModelDirectoryEmptyText,
} from './global-model-form-helpers'
const props = defineProps<{
@@ -432,8 +420,6 @@ const searchQuery = ref('')
const allModelsCache = ref<ModelsDevModelItem[]>([]) // 全部模型(缓存)
const selectedModel = ref<ModelsDevModelItem | null>(null)
const expandedProvider = ref<string | null>(null)
const manualModelMode = ref(false)
const modelListLoadFailed = ref(false)
// 当前显示的模型列表:有搜索词时用全部,否则只用官方
const allModels = computed(() => {
@@ -496,14 +482,6 @@ const groupedModels = computed(() => {
return result
})
const emptyModelListText = computed(() => {
return getModelDirectoryEmptyText({
searchQuery: searchQuery.value,
manualModelMode: manualModelMode.value,
modelListLoadFailed: modelListLoadFailed.value,
})
})
// 搜索时如果只有一个提供商,自动展开
watch(groupedModels, (groups) => {
if (searchQuery.value && groups.length === 1) {
@@ -516,16 +494,6 @@ function toggleProvider(providerId: string) {
expandedProvider.value = expandedProvider.value === providerId ? null : providerId
}
function enableManualModelMode() {
manualModelMode.value = true
selectedModel.value = null
expandedProvider.value = null
searchQuery.value = ''
if (!form.value.name && !form.value.display_name) {
form.value = defaultForm()
}
}
// 阶梯计费配置
const tieredPricing = ref<TieredPricingConfig | null>(null)
@@ -575,6 +543,7 @@ const defaultForm = (): FormData => ({
})
const form = ref<FormData>(defaultForm())
const imageGenerationExplicitOverride = ref<boolean | null>(null)
const isEmbeddingEnabled = computed(() => {
return form.value.supported_capabilities?.includes('embedding') === true
@@ -582,6 +551,18 @@ const isEmbeddingEnabled = computed(() => {
|| form.value.config?.model_type === 'embedding'
})
const isImageGenerationEnabled = computed(() => {
if (imageGenerationExplicitOverride.value !== null) {
return imageGenerationExplicitOverride.value
}
return form.value.supported_capabilities?.includes('image_generation') === true
|| form.value.config?.image_generation === true
|| form.value.config?.model_type === 'image'
|| (Array.isArray(form.value.config?.api_formats)
&& form.value.config.api_formats.some((format) => String(format).endsWith(':image')))
|| tieredPricingHasImageOutputPricing(tieredPricing.value)
})
const KEEP_FALSE_CONFIG_KEYS = new Set(['streaming'])
// 设置 config 字段
@@ -624,6 +605,21 @@ function setEmbeddingEnabled(enabled: boolean) {
form.value.supported_capabilities = [...caps]
}
function setImageGenerationEnabled(value: boolean | 'indeterminate') {
const enabled = value === true
imageGenerationExplicitOverride.value = enabled
const caps = new Set(form.value.supported_capabilities || [])
if (enabled) {
caps.add('image_generation')
setConfigField('image_generation', true)
} else {
caps.delete('image_generation')
setConfigField('image_generation', undefined)
if (form.value.config?.model_type === 'image') setConfigField('model_type', undefined)
}
form.value.supported_capabilities = [...caps]
}
function getNested(obj: unknown, path: string): unknown {
if (!obj || typeof obj !== 'object') return undefined
const parts = path.split('.').filter(Boolean)
@@ -758,15 +754,11 @@ function fillVideoResolutionPricePreset(preset: 'common' | 'sora' | 'veo') {
async function loadModels() {
if (allModelsCache.value.length > 0) return
loading.value = true
modelListLoadFailed.value = false
try {
// 只加载一次全部模型,过滤在 computed 中完成
allModelsCache.value = await getModelsDevList(false)
} catch (err) {
log.error('Failed to load models:', err)
modelListLoadFailed.value = true
enableManualModelMode()
showError('模型目录加载失败,已切换到手动添加模式,可离线继续创建')
} finally {
loading.value = false
}
@@ -781,7 +773,7 @@ watch(() => props.open, (isOpen) => {
// 选择模型并填充表单
function selectModel(model: ModelsDevModelItem) {
manualModelMode.value = false
imageGenerationExplicitOverride.value = null
selectedModel.value = model
expandedProvider.value = model.providerId
form.value.name = model.modelId
@@ -806,7 +798,10 @@ function selectModel(model: ModelsDevModelItem) {
if (model.inputModalities?.length) config.input_modalities = model.inputModalities
if (model.outputModalities?.length) config.output_modalities = model.outputModalities
form.value.config = config
form.value.supported_capabilities = model.supportsEmbedding ? ['embedding'] : []
const supportedCapabilities = new Set<string>()
if (model.supportsEmbedding) supportedCapabilities.add('embedding')
if (model.outputModalities?.includes('image')) supportedCapabilities.add('image_generation')
form.value.supported_capabilities = [...supportedCapabilities]
if (model.supportsEmbedding) {
setEmbeddingEnabled(true)
}
@@ -827,7 +822,7 @@ function selectModel(model: ModelsDevModelItem) {
// 清除选择(手动填写)
function clearSelection() {
manualModelMode.value = false
imageGenerationExplicitOverride.value = null
selectedModel.value = null
form.value = defaultForm()
tieredPricing.value = null
@@ -841,36 +836,42 @@ function handleLogoError(event: Event) {
// 重置表单
function resetForm() {
imageGenerationExplicitOverride.value = null
form.value = defaultForm()
tieredPricing.value = null
videoResolutionPrices.value = []
searchQuery.value = ''
selectedModel.value = null
expandedProvider.value = null
manualModelMode.value = false
modelListLoadFailed.value = false
}
// 加载模型数据(编辑模式)
function loadModelData() {
if (!props.model) return
imageGenerationExplicitOverride.value = null
// 先重置创建模式的残留状态
selectedModel.value = null
searchQuery.value = ''
expandedProvider.value = null
const modelTieredPricing = props.model.default_tiered_pricing
? JSON.parse(JSON.stringify(props.model.default_tiered_pricing))
: null
const supportedCapabilities = new Set(props.model.supported_capabilities || [])
if (tieredPricingHasImageOutputPricing(modelTieredPricing)) {
supportedCapabilities.add('image_generation')
}
form.value = {
name: props.model.name,
display_name: props.model.display_name,
default_price_per_request: props.model.default_price_per_request,
supported_capabilities: [...(props.model.supported_capabilities || [])],
supported_capabilities: [...supportedCapabilities],
config: props.model.config ? { ...props.model.config } : { streaming: true },
is_active: props.model.is_active,
}
// 确保 tieredPricing 也被正确设置或重置
tieredPricing.value = props.model.default_tiered_pricing
? JSON.parse(JSON.stringify(props.model.default_tiered_pricing))
: null
tieredPricing.value = modelTieredPricing
loadVideoPricingFromConfig()
}
@@ -884,22 +885,13 @@ const { isEditMode, handleDialogUpdate, handleCancel } = useFormDialog({
resetForm,
})
watch(() => form.value.name, (name) => {
if (!manualModelMode.value || isEditMode.value) return
const modelName = name.trim()
if (modelName && !form.value.display_name.trim()) {
form.value.display_name = modelName
}
})
async function handleSubmit() {
if (!form.value.name || !form.value.display_name) {
showError('请填写模型ID和名称')
return
}
const finalTiers = tieredPricingEditorRef.value?.getFinalTiers()
const finalTieredPricing = finalTiers ? { tiers: finalTiers } : tieredPricing.value
const finalTieredPricing = tieredPricingEditorRef.value?.getFinalPricing() ?? tieredPricing.value
if (!finalTieredPricing?.tiers?.length) {
showError('请配置至少一个价格阶梯')
@@ -920,6 +912,9 @@ async function handleSubmit() {
} else {
caps.delete('cache_1h')
}
if (tieredPricingHasImageOutputPricing(finalTieredPricing)) {
caps.add('image_generation')
}
form.value.supported_capabilities = caps.size > 0 ? [...caps] : []
// 清理空的 config
@@ -949,4 +944,29 @@ async function handleSubmit() {
submitting.value = false
}
}
function tieredPricingHasImageOutputPricing(pricing: TieredPricingConfig | null | undefined): boolean {
if (!pricing) return false
if (toFinitePrice(pricing.image_output_price_default) !== null) return true
if (Object.values(pricing.image_output_prices || {}).some((prices) => {
if (!prices || typeof prices !== 'object') return false
return Object.values(prices).some((price) => toFinitePrice(price) !== null)
})) return true
return (pricing.image_output_price_ranges || []).some((range) => {
if (!range || typeof range !== 'object') return false
const prices = range.prices && typeof range.prices === 'object'
? range.prices
: range as Record<string, unknown>
return Object.values(prices).some((price) => toFinitePrice(price) !== null)
})
}
function toFinitePrice(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) return value
if (typeof value === 'string' && value.trim()) {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : null
}
return null
}
</script>

View File

@@ -137,6 +137,7 @@
</p>
</div>
</div>
</div>
<!-- 默认定价 -->
@@ -145,6 +146,114 @@
默认定价
</h4>
<!-- 图片输出计费 -->
<div
v-if="hasImagePricing"
class="space-y-2"
>
<div class="flex items-center justify-between gap-3 text-sm text-muted-foreground">
<div class="flex items-center gap-2">
<span>图片输出计费</span>
<Badge
v-if="imagePricingEntries.length > 0"
variant="outline"
class="text-[10px] h-5 px-1.5"
>
矩阵
</Badge>
<Badge
v-if="imagePriceRangeEntries.length > 0"
variant="outline"
class="text-[10px] h-5 px-1.5"
>
区间
</Badge>
</div>
<span
v-if="imageOutputDefaultPrice !== null"
class="text-xs font-mono"
>默认 ${{ imageOutputDefaultPrice.toFixed(6) }}/</span>
</div>
<div
v-if="imagePricingEntries.length > 0"
class="border rounded-lg overflow-hidden"
>
<Table>
<TableHeader>
<TableRow class="bg-muted/30">
<TableHead class="text-xs h-9">
分辨率
</TableHead>
<TableHead
v-for="quality in IMAGE_OUTPUT_QUALITIES"
:key="quality"
class="text-xs h-9 text-right"
>
{{ quality }}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="entry in imagePricingEntries"
:key="entry.size"
class="text-xs"
>
<TableCell class="py-2 font-mono">
{{ formatImageSize(entry.size) }}
</TableCell>
<TableCell
v-for="quality in IMAGE_OUTPUT_QUALITIES"
:key="`${entry.size}-${quality}`"
class="py-2 text-right font-mono"
>
{{ formatImagePrice(entry.prices[quality]) }}
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<div
v-if="imagePriceRangeEntries.length > 0"
class="border rounded-lg overflow-hidden"
>
<Table>
<TableHeader>
<TableRow class="bg-muted/30">
<TableHead class="text-xs h-9">
上限像素
</TableHead>
<TableHead
v-for="quality in IMAGE_OUTPUT_QUALITIES"
:key="quality"
class="text-xs h-9 text-right"
>
{{ quality }}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="entry in imagePriceRangeEntries"
:key="entry.key"
class="text-xs"
>
<TableCell class="py-2 font-mono">
{{ formatPixelLimit(entry.upToPixels) }}
</TableCell>
<TableCell
v-for="quality in IMAGE_OUTPUT_QUALITIES"
:key="`${entry.key}-${quality}`"
class="py-2 text-right font-mono"
>
{{ formatImagePrice(entry.prices[quality]) }}
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</div>
<!-- 单阶梯固定价格展示 -->
<div
v-if="getTierCount(model.default_tiered_pricing) <= 1"
@@ -561,6 +670,79 @@ const videoPricingEntries = computed(() => {
return sortResolutionEntries(Object.entries(priceByResolution))
})
const IMAGE_OUTPUT_QUALITIES = ['low', 'medium', 'high'] as const
const imageOutputDefaultPrice = computed(() => {
const value = props.model?.default_tiered_pricing?.image_output_price_default
return typeof value === 'number' && Number.isFinite(value) ? value : null
})
const imagePricingEntries = computed(() => {
const prices = props.model?.default_tiered_pricing?.image_output_prices
if (!prices || typeof prices !== 'object') return []
return sortResolutionEntries(Object.entries(prices)).map(([size, qualityPrices]) => ({
size,
prices: normalizeImageQualityPrices(qualityPrices),
})).filter(entry => Object.values(entry.prices).some(price => price !== null))
})
const imagePriceRangeEntries = computed(() => {
const ranges = props.model?.default_tiered_pricing?.image_output_price_ranges
if (!Array.isArray(ranges)) return []
return ranges.map((range, index) => {
const object = range && typeof range === 'object' ? range as Record<string, unknown> : {}
const rawPrices = object.prices && typeof object.prices === 'object'
? object.prices
: object
return {
key: `${object.up_to_pixels ?? 'unbounded'}-${index}`,
upToPixels: toFiniteNumber(object.up_to_pixels),
prices: normalizeImageQualityPrices(rawPrices),
}
}).filter(entry => Object.values(entry.prices).some(price => price !== null))
})
const hasImagePricing = computed(() =>
imageOutputDefaultPrice.value !== null
|| imagePricingEntries.value.length > 0
|| imagePriceRangeEntries.value.length > 0,
)
function normalizeImageQualityPrices(value: unknown): Record<typeof IMAGE_OUTPUT_QUALITIES[number], number | null> {
const object = value && typeof value === 'object' ? value as Record<string, unknown> : {}
return {
low: toFiniteNumber(object.low),
medium: toFiniteNumber(object.medium),
high: toFiniteNumber(object.high),
}
}
function toFiniteNumber(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null
}
function formatImagePrice(value: number | null): string {
return value === null ? '-' : `$${value.toFixed(6)}`
}
function formatImageSize(value: string): string {
return value.replace(/\s*[xX×]\s*/g, ' x ')
}
function formatPixelLimit(value: number | null): string {
return value === null ? '无上限' : `<= ${formatPixels(value)}`
}
function formatPixels(value: number): string {
if (value >= 1_000_000) {
return `${(value / 1_000_000).toFixed(value % 1_000_000 === 0 ? 0 : 2)}M px`
}
if (value >= 1_000) {
return `${(value / 1_000).toFixed(0)}K px`
}
return `${value} px`
}
const detailTab = ref('basic')
// 处理背景点击

View File

@@ -784,7 +784,9 @@ function targetFormatsForEndpoint(
provider: RoutingProviderInfo,
endpoint: RoutingEndpointInfo
): string[] {
return STANDARD_ROUTING_API_FORMATS.filter(format =>
const endpointFormat = normalizeLegacyOpenAIFormatAlias(endpoint.api_format)
const candidateFormats = Array.from(new Set([...STANDARD_ROUTING_API_FORMATS, endpointFormat]))
return candidateFormats.filter(format =>
endpointSupportsClientFormat(provider, endpoint, format, endpoint.api_format)
)
}

View File

@@ -137,6 +137,141 @@
添加价格阶梯
</Button>
<div
v-if="showImagePricing"
class="rounded-lg border bg-muted/10 p-3 space-y-3"
>
<div class="flex flex-wrap items-end justify-between gap-3">
<Label class="text-xs font-medium">图像输出计费 ($/张)</Label>
<div class="flex items-center gap-2">
<Label class="text-xs text-muted-foreground">默认价</Label>
<Input
:model-value="imageOutputPriceDefault"
type="number"
step="0.001"
min="0"
class="h-8 w-24"
placeholder="0"
@update:model-value="updateImageOutputPriceDefault"
/>
</div>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between gap-2">
<Label class="text-xs text-muted-foreground">精确分辨率覆盖</Label>
<span class="text-[11px] text-muted-foreground">优先匹配 size + quality</span>
</div>
<div class="grid grid-cols-[minmax(120px,1.1fr)_repeat(3,minmax(0,1fr))_32px] gap-2 text-xs text-muted-foreground">
<span>分辨率</span>
<span>low</span>
<span>medium</span>
<span>high</span>
<span />
</div>
<div
v-for="row in imageOutputPriceRows"
:key="row.id"
class="grid grid-cols-[minmax(120px,1.1fr)_repeat(3,minmax(0,1fr))_32px] gap-2 items-center"
>
<Input
:model-value="row.size"
class="h-8 font-mono text-xs"
placeholder="1024x1024"
@update:model-value="(v) => updateImageOutputSize(row.id, v)"
/>
<Input
v-for="quality in IMAGE_OUTPUT_QUALITIES"
:key="`${row.id}-${quality}`"
:model-value="getImageOutputPrice(row, quality)"
type="number"
step="0.001"
min="0"
class="h-8"
placeholder="0"
@update:model-value="(v) => updateImageOutputPrice(row.id, quality, v)"
/>
<Button
type="button"
variant="ghost"
size="sm"
class="h-8 w-8 p-0"
@click="removeImageOutputSizeRow(row.id)"
>
<X class="w-4 h-4 text-muted-foreground hover:text-destructive" />
</Button>
</div>
<Button
type="button"
variant="outline"
size="sm"
class="w-full"
@click="addImageOutputSizeRow"
>
<Plus class="w-4 h-4 mr-2" />
添加分辨率
</Button>
</div>
<div class="space-y-2 border-t pt-3">
<div class="flex items-center justify-between gap-2">
<Label class="text-xs text-muted-foreground">像素区间</Label>
<span class="text-[11px] text-muted-foreground">矩阵未命中时按宽×高落档</span>
</div>
<div class="grid grid-cols-[minmax(120px,1.1fr)_repeat(3,minmax(0,1fr))_32px] gap-2 text-xs text-muted-foreground">
<span>上限像素</span>
<span>low</span>
<span>medium</span>
<span>high</span>
<span />
</div>
<div
v-for="row in imageOutputPriceRangeRows"
:key="row.id"
class="grid grid-cols-[minmax(120px,1.1fr)_repeat(3,minmax(0,1fr))_32px] gap-2 items-center"
>
<Input
:model-value="row.upToPixels"
type="number"
min="1"
class="h-8 font-mono text-xs"
placeholder="=无上限"
@update:model-value="(v) => updateImageOutputRangeLimit(row.id, v)"
/>
<Input
v-for="quality in IMAGE_OUTPUT_QUALITIES"
:key="`${row.id}-${quality}`"
:model-value="getImageOutputRangePrice(row, quality)"
type="number"
step="0.001"
min="0"
class="h-8"
placeholder="0"
@update:model-value="(v) => updateImageOutputRangePrice(row.id, quality, v)"
/>
<Button
type="button"
variant="ghost"
size="sm"
class="h-8 w-8 p-0"
@click="removeImageOutputRangeRow(row.id)"
>
<X class="w-4 h-4 text-muted-foreground hover:text-destructive" />
</Button>
</div>
<Button
type="button"
variant="outline"
size="sm"
class="w-full"
@click="addImageOutputRangeRow"
>
<Plus class="w-4 h-4 mr-2" />
添加像素区间
</Button>
</div>
</div>
<!-- 验证提示 -->
<p
v-if="validationError"
@@ -151,11 +286,28 @@
import { ref, computed, watch, reactive } from 'vue'
import { Plus, X } from 'lucide-vue-next'
import { Button, Input, Label } from '@/components/ui'
import type { TieredPricingConfig, PricingTier } from '@/api/endpoints/types'
import type { TieredPricingConfig, PricingTier, ImageOutputPriceRange } from '@/api/endpoints/types'
type ImageOutputQuality = 'low' | 'medium' | 'high'
type ImageOutputPriceRow = {
id: string
size: string
prices: Partial<Record<ImageOutputQuality, number>>
}
type ImageOutputPriceRangeRow = {
id: string
upToPixels: string
prices: Partial<Record<ImageOutputQuality, number>>
}
const DEFAULT_IMAGE_OUTPUT_SIZES = ['1024x1024', '1536x1024', '1024x1536']
const DEFAULT_IMAGE_OUTPUT_PIXEL_LIMITS = [1_048_576, 1_572_864, 2_097_152]
const IMAGE_OUTPUT_QUALITIES: ImageOutputQuality[] = ['low', 'medium', 'high']
const props = defineProps<{
modelValue?: TieredPricingConfig | null
showCache1h?: boolean
showImagePricing?: boolean
}>()
const emit = defineEmits<{
@@ -164,6 +316,12 @@ const emit = defineEmits<{
// 本地状态
const localTiers = ref<PricingTier[]>([])
const imageOutputPriceRows = ref<ImageOutputPriceRow[]>([])
const imageOutputPriceRangeRows = ref<ImageOutputPriceRangeRow[]>([])
const imageOutputPriceDefault = ref<string>('')
const lastEmittedPricingJson = ref<string>('')
let imageOutputPriceRowId = 0
let imageOutputPriceRangeRowId = 0
// 跟踪每个阶梯的缓存价格是否被手动设置
const cacheManuallySet = reactive<Record<number, { creation: boolean; read: boolean; cache1h: boolean }>>({})
@@ -186,8 +344,16 @@ const customInputValue = reactive<Record<number, string>>({})
watch(
() => props.modelValue,
(newValue) => {
if (lastEmittedPricingJson.value && JSON.stringify(newValue ?? null) === lastEmittedPricingJson.value) {
return
}
if (newValue?.tiers) {
localTiers.value = newValue.tiers.map(t => ({ ...t }))
imageOutputPriceRows.value = createImageOutputPriceRows(newValue.image_output_prices)
imageOutputPriceRangeRows.value = createImageOutputPriceRangeRows(newValue.image_output_price_ranges)
imageOutputPriceDefault.value = newValue.image_output_price_default != null
? String(newValue.image_output_price_default)
: ''
// 如果已有缓存价格,标记为手动设置
newValue.tiers.forEach((t, i) => {
const has1hCache = t.cache_ttl_pricing?.some(c => c.ttl_minutes === 60) ?? false
@@ -203,6 +369,9 @@ watch(
input_price_per_1m: 0,
output_price_per_1m: 0,
}]
imageOutputPriceRows.value = createImageOutputPriceRows(null)
imageOutputPriceRangeRows.value = createImageOutputPriceRangeRows(null)
imageOutputPriceDefault.value = ''
cacheManuallySet[0] = { creation: false, read: false, cache1h: false }
}
},
@@ -367,7 +536,9 @@ function syncToParent() {
return tier
})
emit('update:modelValue', { tiers })
const value = buildPricingConfig(tiers)
lastEmittedPricingJson.value = JSON.stringify(value ?? null)
emit('update:modelValue', value)
}
// 获取最终提交的数据(包含自动计算的缓存价格)
@@ -406,11 +577,239 @@ function getFinalTiers(): PricingTier[] {
})
}
function getFinalPricing(): TieredPricingConfig {
return buildPricingConfig(getFinalTiers())
}
// 暴露给父组件调用
defineExpose({
getFinalTiers,
getFinalPricing,
})
function buildPricingConfig(tiers: PricingTier[]): TieredPricingConfig {
const config: TieredPricingConfig = { tiers }
if (!props.showImagePricing) {
return config
}
const matrix = normalizedImageOutputPrices()
if (Object.keys(matrix).length > 0) {
config.image_output_prices = matrix
}
const ranges = normalizedImageOutputPriceRanges()
if (ranges.length > 0) {
config.image_output_price_ranges = ranges
}
const defaultPrice = parseOptionalFloat(imageOutputPriceDefault.value)
if (defaultPrice != null) {
config.image_output_price_default = defaultPrice
}
return config
}
function createImageOutputPriceRows(value: TieredPricingConfig['image_output_prices']): ImageOutputPriceRow[] {
const rows: ImageOutputPriceRow[] = []
if (!value || typeof value !== 'object') {
return DEFAULT_IMAGE_OUTPUT_SIZES.map(size => createImageOutputPriceRow(size))
}
for (const [size, prices] of Object.entries(value)) {
if (!prices || typeof prices !== 'object') continue
const rowPrices: Partial<Record<ImageOutputQuality, number>> = {}
for (const quality of IMAGE_OUTPUT_QUALITIES) {
const price = (prices as Record<string, unknown>)[quality]
if (typeof price === 'number' && Number.isFinite(price)) {
rowPrices[quality] = price
}
}
rows.push(createImageOutputPriceRow(size, rowPrices))
}
if (rows.length > 0) return rows
return DEFAULT_IMAGE_OUTPUT_SIZES.map(size => createImageOutputPriceRow(size))
}
function createImageOutputPriceRangeRows(value: TieredPricingConfig['image_output_price_ranges']): ImageOutputPriceRangeRow[] {
const rows: ImageOutputPriceRangeRow[] = []
if (!Array.isArray(value)) {
return rows
}
for (const range of value) {
if (!range || typeof range !== 'object') continue
const rowPrices: Partial<Record<ImageOutputQuality, number>> = {}
const rawPrices = 'prices' in range && range.prices && typeof range.prices === 'object'
? range.prices as Record<string, unknown>
: range as Record<string, unknown>
for (const quality of IMAGE_OUTPUT_QUALITIES) {
const price = rawPrices[quality]
if (typeof price === 'number' && Number.isFinite(price)) {
rowPrices[quality] = price
}
}
const upToPixels = 'up_to_pixels' in range && range.up_to_pixels != null
? String(range.up_to_pixels)
: ''
rows.push(createImageOutputPriceRangeRow(upToPixels, rowPrices))
}
return rows
}
function createImageOutputPriceRow(
size = '',
prices: Partial<Record<ImageOutputQuality, number>> = {},
): ImageOutputPriceRow {
imageOutputPriceRowId += 1
return {
id: `image-output-size-${imageOutputPriceRowId}`,
size,
prices: { ...prices },
}
}
function createImageOutputPriceRangeRow(
upToPixels = '',
prices: Partial<Record<ImageOutputQuality, number>> = {},
): ImageOutputPriceRangeRow {
imageOutputPriceRangeRowId += 1
return {
id: `image-output-range-${imageOutputPriceRangeRowId}`,
upToPixels,
prices: { ...prices },
}
}
function normalizedImageOutputPrices(): Record<string, Record<string, number>> {
const out: Record<string, Record<string, number>> = {}
for (const row of imageOutputPriceRows.value) {
const size = normalizeImageOutputSize(row.size)
if (!size) continue
for (const quality of IMAGE_OUTPUT_QUALITIES) {
const price = row.prices[quality]
if (price != null && Number.isFinite(price)) {
out[size] = { ...(out[size] || {}), [quality]: price }
}
}
}
return out
}
function normalizedImageOutputPriceRanges(): ImageOutputPriceRange[] {
const ranges: ImageOutputPriceRange[] = []
for (const row of imageOutputPriceRangeRows.value) {
const prices: Partial<Record<ImageOutputQuality, number>> = {}
for (const quality of IMAGE_OUTPUT_QUALITIES) {
const price = row.prices[quality]
if (price != null && Number.isFinite(price)) {
prices[quality] = price
}
}
if (Object.keys(prices).length === 0) continue
ranges.push({
up_to_pixels: parseOptionalInteger(row.upToPixels),
prices,
})
}
return ranges.sort((a, b) => {
if (a.up_to_pixels == null && b.up_to_pixels == null) return 0
if (a.up_to_pixels == null) return 1
if (b.up_to_pixels == null) return -1
return a.up_to_pixels - b.up_to_pixels
})
}
function parseOptionalFloat(value: string | number): number | null {
if (value === '' || value === null || value === undefined) return null
const number = typeof value === 'string' ? parseFloat(value) : value
return Number.isFinite(number) ? number : null
}
function parseOptionalInteger(value: string | number): number | null {
if (value === '' || value === null || value === undefined) return null
const number = typeof value === 'string' ? parseInt(value, 10) : value
return Number.isFinite(number) && number > 0 ? Math.trunc(number) : null
}
function normalizeImageOutputSize(size: string): string {
return String(size || '').trim().replace(/\s*[xX×]\s*/g, 'x')
}
function getImageOutputPrice(row: ImageOutputPriceRow, quality: ImageOutputQuality): string | number {
return row.prices[quality] ?? ''
}
function getImageOutputRangePrice(row: ImageOutputPriceRangeRow, quality: ImageOutputQuality): string | number {
return row.prices[quality] ?? ''
}
function updateImageOutputSize(rowId: string, value: string | number) {
const row = imageOutputPriceRows.value.find(item => item.id === rowId)
if (!row) return
row.size = normalizeImageOutputSize(String(value ?? ''))
imageOutputPriceRows.value = [...imageOutputPriceRows.value]
syncToParent()
}
function updateImageOutputPrice(rowId: string, quality: ImageOutputQuality, value: string | number) {
const row = imageOutputPriceRows.value.find(item => item.id === rowId)
if (!row) return
const price = parseOptionalFloat(value)
if (price == null) {
delete row.prices[quality]
} else {
row.prices[quality] = price
}
imageOutputPriceRows.value = [...imageOutputPriceRows.value]
syncToParent()
}
function addImageOutputSizeRow() {
const usedSizes = new Set(imageOutputPriceRows.value.map(row => normalizeImageOutputSize(row.size)).filter(Boolean))
const suggestedSize = DEFAULT_IMAGE_OUTPUT_SIZES.find(size => !usedSizes.has(size)) || ''
imageOutputPriceRows.value = [...imageOutputPriceRows.value, createImageOutputPriceRow(suggestedSize)]
syncToParent()
}
function removeImageOutputSizeRow(rowId: string) {
imageOutputPriceRows.value = imageOutputPriceRows.value.filter(row => row.id !== rowId)
syncToParent()
}
function updateImageOutputRangeLimit(rowId: string, value: string | number) {
const row = imageOutputPriceRangeRows.value.find(item => item.id === rowId)
if (!row) return
row.upToPixels = String(value ?? '')
imageOutputPriceRangeRows.value = [...imageOutputPriceRangeRows.value]
syncToParent()
}
function updateImageOutputRangePrice(rowId: string, quality: ImageOutputQuality, value: string | number) {
const row = imageOutputPriceRangeRows.value.find(item => item.id === rowId)
if (!row) return
const price = parseOptionalFloat(value)
if (price == null) {
delete row.prices[quality]
} else {
row.prices[quality] = price
}
imageOutputPriceRangeRows.value = [...imageOutputPriceRangeRows.value]
syncToParent()
}
function addImageOutputRangeRow() {
const usedLimits = new Set(imageOutputPriceRangeRows.value.map(row => parseOptionalInteger(row.upToPixels)).filter((value): value is number => value !== null))
const suggestedLimit = DEFAULT_IMAGE_OUTPUT_PIXEL_LIMITS.find(limit => !usedLimits.has(limit))
imageOutputPriceRangeRows.value = [...imageOutputPriceRangeRows.value, createImageOutputPriceRangeRow(suggestedLimit ? String(suggestedLimit) : '')]
syncToParent()
}
function removeImageOutputRangeRow(rowId: string) {
imageOutputPriceRangeRows.value = imageOutputPriceRangeRows.value.filter(row => row.id !== rowId)
syncToParent()
}
function updateImageOutputPriceDefault(value: string | number) {
imageOutputPriceDefault.value = String(value ?? '')
syncToParent()
}
function parseFloatInput(value: string | number): number {
const num = typeof value === 'string' ? parseFloat(value) : value
return isNaN(num) ? 0 : num

View File

@@ -4,7 +4,6 @@ import {
EMBEDDING_API_FORMATS,
buildGlobalModelCreatePayload,
buildGlobalModelUpdatePayload,
getModelDirectoryEmptyText,
} from '../global-model-form-helpers'
const embeddingPricing = {
@@ -60,26 +59,4 @@ describe('global model form embedding payload helpers', () => {
api_formats: ['jina:embedding'],
})
})
it('surfaces manual-add guidance when the online model directory is unavailable', () => {
expect(getModelDirectoryEmptyText({
searchQuery: '',
manualModelMode: false,
modelListLoadFailed: true,
})).toBe('模型目录加载失败,请使用手动添加继续创建')
expect(getModelDirectoryEmptyText({
searchQuery: '',
manualModelMode: true,
modelListLoadFailed: false,
})).toBe('已切换到手动添加,可在右侧填写模型信息')
})
it('keeps search empty state ahead of manual/offline guidance', () => {
expect(getModelDirectoryEmptyText({
searchQuery: 'local-model',
manualModelMode: true,
modelListLoadFailed: true,
})).toBe('未找到模型')
})
})

View File

@@ -22,19 +22,6 @@ export interface GlobalModelFormPayloadState {
is_active?: boolean
}
export interface ModelDirectoryEmptyTextState {
searchQuery: string
manualModelMode: boolean
modelListLoadFailed: boolean
}
export function getModelDirectoryEmptyText(state: ModelDirectoryEmptyTextState): string {
if (state.searchQuery) return '未找到模型'
if (state.modelListLoadFailed) return '模型目录加载失败,请使用手动添加继续创建'
if (state.manualModelMode) return '已切换到手动添加,可在右侧填写模型信息'
return '加载中...'
}
function cleanGlobalModelConfig(form: GlobalModelFormPayloadState): Record<string, unknown> | undefined {
return form.config && Object.keys(form.config).length > 0 ? form.config : undefined
}

View File

@@ -1031,6 +1031,7 @@ import { log } from '@/utils/logger'
import AlertDialog from '@/components/common/AlertDialog.vue'
import EndpointConditionEditor from './EndpointConditionEditor.vue'
import ProxyNodeSelect from './ProxyNodeSelect.vue'
import { getDefaultEndpointPath, normalizeEndpointApiFormat } from './endpoint-default-paths'
import { useProxyNodesStore } from '@/stores/proxy-nodes'
import {
createEndpoint,
@@ -1814,13 +1815,6 @@ function hasDefaultBodyRules(apiFormat: string): boolean {
return (defaultBodyRulesByFormat.value[cacheKey]?.length || 0) > 0
}
function normalizeLegacyOpenAIFormatAlias(apiFormat: string): string {
switch (apiFormat.trim().toLowerCase()) {
default:
return apiFormat.trim().toLowerCase()
}
}
async function loadDefaultBodyRulesForFormat(apiFormat: string, force = false): Promise<BodyRule[]> {
if (!apiFormat) return []
const providerType = (props.provider?.provider_type || '').toLowerCase()
@@ -1857,26 +1851,12 @@ async function preloadDefaultBodyRules(endpoints: ProviderEndpoint[]): Promise<v
// 获取指定 API 格式的默认路径
function getDefaultPath(apiFormat: string, baseUrl?: string): string {
const providerType = (props.provider?.provider_type || '').toLowerCase()
const normalizedApiFormat = normalizeLegacyOpenAIFormatAlias(apiFormat)
if (providerType === 'vertex_ai') {
if (normalizedApiFormat === 'gemini:generate_content') {
return '/v1/publishers/google/models/{model}:{action}'
}
if (normalizedApiFormat === 'claude:messages') {
return '/v1/projects/{project_id}/locations/{region}/publishers/anthropic/models/{model}:{action}'
}
}
const format = apiFormats.value.find(f => f.value === normalizedApiFormat)
const defaultPath = format?.default_path || ''
// Codex 端点使用 /responses 而非 /v1/responses
const isCodex = providerType
? providerType === 'codex'
: (!!baseUrl && isCodexUrl(baseUrl))
if (normalizedApiFormat === 'openai:responses' && isCodex) {
return '/responses'
}
return defaultPath
return getDefaultEndpointPath({
apiFormat,
providerType,
baseUrl,
apiFormats: apiFormats.value,
})
}
function getDisplayedPath(endpoint: ProviderEndpoint): string {
@@ -1886,12 +1866,6 @@ function getDisplayedPath(endpoint: ProviderEndpoint): string {
return getEndpointEditState(endpoint.id)?.path ?? (endpoint.custom_path || '')
}
// 判断是否是 Codex OAuth 端点
function isCodexUrl(baseUrl: string): boolean {
const url = baseUrl.replace(/\/+$/, '')
return url.includes('/backend-api/codex') || url.endsWith('/codex')
}
// 读取端点的上游流式策略endpoint.config.upstream_stream_policy
function getEndpointUpstreamStreamPolicy(endpoint: ProviderEndpoint): string {
const cfg = endpoint.config || {}
@@ -3284,7 +3258,7 @@ function getCurrentUpstreamStreamPolicy(endpoint: ProviderEndpoint): string {
function isUpstreamStreamPolicyLocked(endpoint: ProviderEndpoint): boolean {
return (props.provider?.provider_type || '').toLowerCase() === 'codex'
&& normalizeLegacyOpenAIFormatAlias(endpoint.api_format) === 'openai:responses'
&& normalizeEndpointApiFormat(endpoint.api_format) === 'openai:responses'
}
// 获取上游流式按钮的样式类

View File

@@ -249,7 +249,7 @@
@update:model-value="(v) => form.concurrent_limit = parseNullableNumberInput(v, { min: 0 })"
/>
<p class="text-xs text-muted-foreground mt-0.5">
同一时间允许使用该 Key 的最大请求数,留空或 0 表示不限制
留空或 0 表示不限制
</p>
</div>
<div>
@@ -291,25 +291,6 @@
</div>
</div>
<!-- 能力标签 -->
<div v-if="availableCapabilities.length > 0">
<Label class="text-xs mb-1.5 block">能力标签</Label>
<div class="flex flex-wrap gap-1.5">
<button
v-for="cap in availableCapabilities"
:key="cap.name"
type="button"
class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md border text-sm transition-colors"
:class="form.capabilities[cap.name]
? 'bg-primary/10 border-primary/50 text-primary'
: 'bg-card border-border hover:bg-muted/50 text-muted-foreground'"
@click="form.capabilities[cap.name] = !form.capabilities[cap.name]"
>
{{ cap.display_name }}
</button>
</div>
</div>
<!-- 自动获取模型 -->
<div class="space-y-3 py-2 px-3 rounded-md border border-border/60 bg-muted/30">
<div class="flex items-center justify-between">
@@ -376,7 +357,7 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { ref, computed, watch } from 'vue'
import {
Dialog,
Button,
@@ -394,17 +375,14 @@ import { useToast } from '@/composables/useToast'
import { useFormDialog } from '@/composables/useFormDialog'
import { parseApiError } from '@/utils/errorParser'
import { parseNumberInput, parseNullableNumberInput } from '@/utils/form'
import { log } from '@/utils/logger'
import JsonImportInput from '@/components/common/JsonImportInput.vue'
import {
addProviderKey,
updateProviderKey,
getAllCapabilities,
sortApiFormats,
type EndpointAPIKey,
type EndpointAPIKeyUpdate,
type ProviderEndpoint,
type CapabilityDefinition,
type ProviderType
} from '@/api/endpoints'
import { formatApiFormat, normalizeApiFormatAlias, formatSupportsAuthOverride } from '@/api/endpoints/types/api-format'
@@ -467,10 +445,10 @@ function getAuthTypeOptions(providerType: ProviderType | null): AuthTypeOption[]
function getVertexAllowedFormatsByAuth(authType: ProviderKeyFormAuthType): Set<string> {
if (authType === 'api_key') {
return new Set(['gemini:generate_content'])
return new Set(['gemini:generate_content', 'gemini:embedding'])
}
if (authType === 'service_account') {
return new Set(['gemini:generate_content', 'claude:messages'])
return new Set(['gemini:generate_content', 'gemini:embedding', 'claude:messages'])
}
return new Set()
}
@@ -710,9 +688,6 @@ const authTypeSelectId = computed(() => `auth-type-${formNonce.value}`)
const keyNameFieldName = computed(() => `key-name-field-${formNonce.value}`)
const apiKeyFieldName = computed(() => `api-key-field-${formNonce.value}`)
// 可用的能力列表
const availableCapabilities = ref<CapabilityDefinition[]>([])
// 新增密钥时默认不自动开启上游模型获取
const defaultAutoFetchModels = computed(() => false)
@@ -732,7 +707,6 @@ const form = ref({
max_probe_interval_minutes: 32,
note: '',
is_active: true,
capabilities: {} as Record<string, boolean>,
auto_fetch_models: false,
model_include_patterns_text: '', // 包含规则文本(逗号分隔)
model_exclude_patterns_text: '' // 排除规则文本(逗号分隔)
@@ -789,19 +763,6 @@ watch(
{ deep: true, immediate: true }
)
// 加载能力列表
async function loadCapabilities() {
try {
availableCapabilities.value = await getAllCapabilities()
} catch (err) {
log.error('Failed to load capabilities:', err)
}
}
onMounted(() => {
loadCapabilities()
})
// API 格式切换
function toggleApiFormat(format: string) {
const index = form.value.api_formats.indexOf(format)
@@ -840,7 +801,6 @@ function resetForm() {
max_probe_interval_minutes: 32,
note: '',
is_active: true,
capabilities: {},
auto_fetch_models: defaultAutoFetchModels.value,
model_include_patterns_text: '',
model_exclude_patterns_text: ''
@@ -892,7 +852,6 @@ function loadKeyData() {
max_probe_interval_minutes: props.editingKey.max_probe_interval_minutes ?? 32,
note: props.editingKey.note || '',
is_active: props.editingKey.is_active,
capabilities: { ...(props.editingKey.capabilities || {}) },
auto_fetch_models: props.editingKey.auto_fetch_models ?? false,
model_include_patterns_text: (props.editingKey.model_include_patterns || []).join(', '),
model_exclude_patterns_text: (props.editingKey.model_exclude_patterns || []).join(', ')
@@ -979,15 +938,6 @@ async function handleSave() {
return
}
// 过滤出有效的能力配置(只包含值为 true 的)
const activeCapabilities: Record<string, boolean> = {}
for (const [key, value] of Object.entries(form.value.capabilities)) {
if (value) {
activeCapabilities[key] = true
}
}
const capabilitiesData = Object.keys(activeCapabilities).length > 0 ? activeCapabilities : null
saving.value = true
try {
// 准备 rate_multipliers 数据:只保留已选中格式的倍率配置
@@ -1025,7 +975,6 @@ async function handleSave() {
max_probe_interval_minutes: form.value.max_probe_interval_minutes,
note: form.value.note,
is_active: form.value.is_active,
capabilities: capabilitiesData,
allowed_models: shouldClearAllowedModels ? null : undefined,
auto_fetch_models: form.value.auto_fetch_models,
model_include_patterns: parsePatternText(form.value.model_include_patterns_text),
@@ -1059,7 +1008,6 @@ async function handleSave() {
cache_ttl_minutes: form.value.cache_ttl_minutes,
max_probe_interval_minutes: form.value.max_probe_interval_minutes,
note: form.value.note,
capabilities: capabilitiesData || undefined,
auto_fetch_models: form.value.auto_fetch_models,
model_include_patterns: parsePatternText(form.value.model_include_patterns_text),
model_exclude_patterns: parsePatternText(form.value.model_exclude_patterns_text)

View File

@@ -51,7 +51,7 @@
@update:model-value="(v: string) => { selectedProxyNodeId = v; proxyPopoverOpen = false }"
/>
<p class="text-[10px] text-muted-foreground">
{{ selectedProxyNodeId ? '授权、刷新、额度查询均走此代理' : '未设置,依次回退到提供商代理 → 系统代理' }}
{{ selectedProxyNodeId ? `${providerCredentialActionLabel}、刷新、额度查询均走此代理` : '未设置,依次回退到提供商代理 → 系统代理' }}
</p>
</div>
</PopoverContent>
@@ -60,7 +60,10 @@
<div class="space-y-4">
<!-- Tab 切换 -->
<div class="flex rounded-lg border border-border p-0.5 bg-muted/30">
<div
v-if="showAuthorizationMode"
class="flex rounded-lg border border-border p-0.5 bg-muted/30"
>
<button
class="flex-1 px-3 py-1.5 text-xs font-medium rounded-md transition-all"
:class="[
@@ -79,7 +82,7 @@
: 'text-muted-foreground hover:text-foreground'"
@click="switchMode('import')"
>
导入授权
{{ importModeLabel }}
</button>
</div>
@@ -458,11 +461,12 @@
v-model="importText"
:disabled="importing"
:reset-key="importInputResetKey"
drop-title="拖入授权文件或点击选择"
drop-hint="支持 .json / .txt可多选"
manual-placeholder="粘贴 Refresh Token / Access Token JSON 内容"
paste-toggle-text="或手动粘贴 Token"
file-toggle-text="或选择 JSON 文件导入"
:drop-title="importDropTitle"
:drop-hint="importDropHint"
:manual-placeholder="importManualPlaceholder"
:manual-description="importManualDescription"
:paste-toggle-text="importPasteToggleText"
:file-toggle-text="importFileToggleText"
textarea-class="min-h-[200px] text-xs font-mono break-all !rounded-xl"
@error="handleImportInputError"
/>
@@ -523,7 +527,7 @@
取消
</Button>
<Button
v-if="mode === 'oauth' && !isKiroProvider"
v-if="mode === 'oauth' && showAuthorizationMode && !isKiroProvider"
:disabled="!canCompleteOAuth"
@click="handleCompleteOAuth"
>
@@ -541,7 +545,7 @@
:disabled="!canImport"
@click="handleImport"
>
{{ importing ? (importTask ? `导入中 ${importTask.progress_percent}%` : '导入中...') : '导入' }}
{{ importing ? (importTask ? `导入中 ${importTask.progress_percent}%` : '导入中...') : importButtonLabel }}
</Button>
</template>
</Dialog>
@@ -644,7 +648,7 @@ function getSelectedNodeLabel(): string {
// 模式
type DialogMode = 'oauth' | 'import'
const mode = ref<DialogMode>('oauth')
const mode = ref<DialogMode>((props.providerType || '').toLowerCase() === 'grok' ? 'import' : 'oauth')
// OAuth 状态
interface OAuthState {
@@ -736,6 +740,9 @@ const importPolling = ref(false)
const isOpen = computed(() => props.open)
const isKiroProvider = computed(() => (props.providerType || '').toLowerCase() === 'kiro')
const isGrokProvider = computed(() => (props.providerType || '').toLowerCase() === 'grok')
const showAuthorizationMode = computed(() => !isGrokProvider.value)
const defaultMode = computed<DialogMode>(() => (isGrokProvider.value ? 'import' : 'oauth'))
const isSocialDeviceAuth = computed(() =>
device.value.auth_type === 'google' || device.value.auth_type === 'github'
@@ -782,6 +789,32 @@ const canImport = computed(() => {
return importText.value.trim().length > 0 && !importing.value
})
const importModeLabel = computed(() => (isGrokProvider.value ? '导入账号' : '导入授权'))
const importButtonLabel = computed(() => (isGrokProvider.value ? '导入账号' : '导入'))
const importDropTitle = computed(() => (
isGrokProvider.value ? '拖入 Grok 账号文件或点击选择' : '拖入授权文件或点击选择'
))
const importDropHint = computed(() => (
isGrokProvider.value ? '支持 .json / .txt可多选、批量导入' : '支持 .json / .txt可多选'
))
const importManualPlaceholder = computed(() => (
isGrokProvider.value
? '粘贴 Grok sso/session token支持每行一个或粘贴包含 token、sso_token、access_token、plan_type、pool_tier 的 JSON'
: '粘贴 Refresh Token / Access Token 或 JSON 内容'
))
const importManualDescription = computed(() => (
isGrokProvider.value
? 'plan_type / pool_tier 会作为账号套餐与能力特征保存,不是路由池选择。'
: ''
))
const importPasteToggleText = computed(() => (
isGrokProvider.value ? '或手动粘贴 Grok Token' : '或手动粘贴 Token'
))
const importFileToggleText = computed(() => (
isGrokProvider.value ? '或选择 Grok Token 文件导入' : '或选择 JSON 文件导入'
))
const providerCredentialActionLabel = computed(() => (isGrokProvider.value ? '导入' : '授权'))
function stopImportPolling() {
if (importPollTimer) {
clearTimeout(importPollTimer)
@@ -923,7 +956,7 @@ function resetDeviceRuntimeState() {
device.value.error = ''
}
function isKiroDeviceAuthOptionDisabled(authType: DeviceAuthType): boolean {
function isKiroDeviceAuthOptionDisabled(_authType: DeviceAuthType): boolean {
if (device.value.starting) {
return !isSocialDeviceAuth.value
}
@@ -976,11 +1009,12 @@ function resetForm() {
importInputResetKey.value += 1
proxyPopoverOpen.value = false
selectedProxyNodeId.value = ''
mode.value = 'oauth'
mode.value = defaultMode.value
}
function switchMode(newMode: DialogMode) {
if (mode.value === newMode) return
if (newMode === 'oauth' && !showAuthorizationMode.value) return
mode.value = newMode
if (newMode === 'oauth') {
@@ -1011,6 +1045,7 @@ function openAuthorizationUrl() {
async function initOAuth() {
if (!props.providerId) return
if (!showAuthorizationMode.value) return
if (isKiroProvider.value) return
if (oauth.value.starting) return
@@ -1095,6 +1130,12 @@ function parseImportText(text: string): {
account_id?: string
account_user_id?: string
plan_type?: string
pool_tier?: string
sso_rw_token?: string
cf_cookies?: string
cf_clearance?: string
user_agent?: string
browser_profile?: string
user_id?: string
account_name?: string
} | null {
@@ -1106,30 +1147,50 @@ function parseImportText(text: string): {
return { refresh_token: trimmed }
}
if (isGrokProvider.value) {
const cookieImport = parseGrokCookieImport(trimmed)
if (cookieImport) {
return cookieImport
}
}
try {
const parsed: unknown = JSON.parse(trimmed)
if (typeof parsed === 'object' && parsed !== null) {
const obj = parsed as Record<string, unknown>
const grokCookieImport = isGrokProvider.value
? parseGrokCookieImport(normalizeStringField(obj.cookie) ?? normalizeStringField(obj.cookieHeader) ?? '')
: null
const refreshToken = obj.refresh_token
const refreshTokenCamel = obj.refreshToken
const accessToken = obj.access_token
const accessTokenCamel = obj.accessToken
const grokSsoToken = isGrokProvider.value
? normalizeStringField(obj.sso_token) ?? normalizeStringField(obj.ssoToken) ?? normalizeStringField(obj.token) ?? grokCookieImport?.access_token
: undefined
const normalizedRefreshToken = typeof refreshToken === 'string' && refreshToken.trim()
? refreshToken.trim()
: (typeof refreshTokenCamel === 'string' && refreshTokenCamel.trim() ? refreshTokenCamel.trim() : undefined)
const normalizedAccessToken = typeof accessToken === 'string' && accessToken.trim()
? accessToken.trim()
: (typeof accessTokenCamel === 'string' && accessTokenCamel.trim() ? accessTokenCamel.trim() : undefined)
if (normalizedRefreshToken || normalizedAccessToken) {
const importedAccessToken = normalizedAccessToken ?? grokSsoToken
if (normalizedRefreshToken || importedAccessToken) {
return {
refresh_token: normalizedRefreshToken,
access_token: normalizedAccessToken,
access_token: importedAccessToken,
expires_at: normalizeNumberField(obj.expires_at) ?? normalizeNumberField(obj.expiresAt),
name: (typeof obj.name === 'string' ? obj.name : undefined) || (typeof obj.oauth_email === 'string' ? obj.oauth_email : undefined),
email: normalizeStringField(obj.email) ?? normalizeStringField(obj.oauth_email),
account_id: normalizeStringField(obj.account_id) ?? normalizeStringField(obj.accountId) ?? normalizeStringField(obj.chatgpt_account_id) ?? normalizeStringField(obj.chatgptAccountId),
account_user_id: normalizeStringField(obj.account_user_id) ?? normalizeStringField(obj.accountUserId) ?? normalizeStringField(obj.chatgpt_account_user_id) ?? normalizeStringField(obj.chatgptAccountUserId),
plan_type: normalizeStringField(obj.plan_type) ?? normalizeStringField(obj.planType) ?? normalizeStringField(obj.chatgpt_plan_type) ?? normalizeStringField(obj.chatgptPlanType),
pool_tier: isGrokProvider.value ? normalizeStringField(obj.pool_tier) ?? normalizeStringField(obj.poolTier) ?? normalizeStringField(obj.tier) : undefined,
sso_rw_token: isGrokProvider.value ? normalizeStringField(obj.sso_rw_token) ?? normalizeStringField(obj.ssoRwToken) ?? grokCookieImport?.sso_rw_token : undefined,
cf_cookies: isGrokProvider.value ? normalizeStringField(obj.cf_cookies) ?? normalizeStringField(obj.cfCookies) ?? grokCookieImport?.cf_cookies : undefined,
cf_clearance: isGrokProvider.value ? normalizeStringField(obj.cf_clearance) ?? normalizeStringField(obj.cfClearance) ?? grokCookieImport?.cf_clearance : undefined,
user_agent: isGrokProvider.value ? normalizeStringField(obj.user_agent) ?? normalizeStringField(obj.userAgent) ?? grokCookieImport?.user_agent : undefined,
browser_profile: isGrokProvider.value ? normalizeStringField(obj.browser_profile) ?? normalizeStringField(obj.browserProfile) ?? normalizeStringField(obj.browser) ?? normalizeStringField(obj.impersonate) ?? grokCookieImport?.browser_profile : undefined,
user_id: normalizeStringField(obj.user_id) ?? normalizeStringField(obj.userId) ?? normalizeStringField(obj.chatgpt_user_id) ?? normalizeStringField(obj.chatgptUserId),
account_name: normalizeStringField(obj.account_name) ?? normalizeStringField(obj.accountName),
}
@@ -1147,6 +1208,72 @@ function parseImportText(text: string): {
return { refresh_token: trimmed }
}
function parseGrokCookieImport(text: string): {
access_token: string
sso_rw_token?: string
cf_cookies?: string
cf_clearance?: string
user_agent?: string
browser_profile?: string
user_id?: string
} | null {
const cookies = parseCookieHeader(text)
const sso = cookies.get('sso')
if (!sso) return null
const userAgent = currentBrowserUserAgent()
return {
access_token: sso,
sso_rw_token: cookies.get('sso-rw'),
cf_cookies: buildGrokCookieProfile(cookies),
cf_clearance: cookies.get('cf_clearance'),
user_agent: userAgent,
browser_profile: inferGrokBrowserProfile(userAgent),
user_id: cookies.get('x-userid'),
}
}
function currentBrowserUserAgent(): string | undefined {
const value = typeof navigator !== 'undefined' ? navigator.userAgent?.trim() : ''
return value || undefined
}
function inferGrokBrowserProfile(userAgent: string | undefined): string | undefined {
const value = (userAgent || '').toLowerCase()
if (!value) return 'chrome136'
if (value.includes('firefox/')) return 'firefox'
if (value.includes('safari/') && !value.includes('chrome/') && !value.includes('chromium/')) {
return value.includes('iphone') || value.includes('ipad') ? 'safari_ios' : 'safari'
}
return 'chrome136'
}
function buildGrokCookieProfile(cookies: Map<string, string>): string | undefined {
const parts: string[] = []
for (const [name, value] of cookies) {
if (name === 'sso' || name === 'sso-rw') continue
parts.push(`${name}=${value}`)
}
return parts.length > 0 ? parts.join('; ') : undefined
}
function parseCookieHeader(text: string): Map<string, string> {
const normalized = text.trim().replace(/^cookie:\s*/i, '')
const cookies = new Map<string, string>()
for (const segment of normalized.split(';')) {
const part = segment.trim()
if (!part) continue
const separator = part.indexOf('=')
if (separator <= 0) continue
const name = part.slice(0, separator).trim().toLowerCase()
const value = part.slice(separator + 1).trim()
if (name && value) {
cookies.set(name, value)
}
}
return cookies
}
function normalizeStringField(value: unknown): string | undefined {
return typeof value === 'string' && value.trim() ? value.trim() : undefined
}
@@ -1404,6 +1531,10 @@ onBeforeUnmount(() => {
watch(() => props.open, (newOpen) => {
if (newOpen) {
proxyNodesStore.ensureLoaded()
mode.value = defaultMode.value
if (!showAuthorizationMode.value) {
return
}
if (isKiroProvider.value) {
void ensureKiroSocialDeviceAuth()
} else {
@@ -1417,6 +1548,10 @@ watch(() => props.open, (newOpen) => {
watch(
() => [props.open, props.providerId, props.providerType] as const,
() => {
if (props.open && !showAuthorizationMode.value) {
mode.value = 'import'
return
}
if (props.open && isKiroProvider.value && mode.value === 'oauth') {
void ensureKiroSocialDeviceAuth()
}

View File

@@ -88,7 +88,7 @@
@update:model-value="(v) => form.concurrent_limit = parseNullableNumberInput(v, { min: 0 })"
/>
<p class="text-xs text-muted-foreground mt-0.5">
同一时间允许使用该 Key 的最大请求数,留空或 0 表示不限制
留空或 0 表示不限制
</p>
</div>
<div>

View File

@@ -858,6 +858,7 @@ const PROVIDER_TYPE_LABELS: Record<string, string> = {
gemini_cli: 'Gemini CLI',
antigravity: 'Antigravity',
kiro: 'Kiro',
grok: 'Grok',
}
function formatProviderType(type?: string): string {

View File

@@ -346,7 +346,7 @@
<Copy class="w-2.5 h-2.5" />
</Button>
<!-- OAuth 状态失效/过期/倒计时和刷新按钮 -->
<template v-if="shouldShowOAuthRefreshControl(key)">
<template v-if="shouldShowOAuthRefreshControl(key, provider.provider_type)">
<!-- 账号级别异常醒目提示 + 清除按钮 -->
<template v-if="isAccountLevelBlock(key)">
<Badge
@@ -425,8 +425,9 @@
v-if="key.circuit_breaker_open"
variant="destructive"
class="text-[10px] px-1.5 py-0 shrink-0"
:title="getKeyCircuitBreakerTitle(key)"
>
熔断
熔断{{ getKeyCircuitProbeCountdown(key) }}
</Badge>
<!-- 健康度 -->
<div
@@ -448,11 +449,11 @@
</span>
</div>
<Button
v-if="key.circuit_breaker_open || (key.health_score !== undefined && key.health_score < 0.5)"
v-if="isKeyRecoverable(key)"
variant="ghost"
size="icon"
class="h-7 w-7 text-green-600"
title="刷新健康状态"
:title="getRecoverKeyTitle(key)"
@click="handleRecoverKey(key)"
>
<RefreshCw class="w-3.5 h-3.5" />
@@ -1293,6 +1294,7 @@ import type {
AntigravityModelQuota,
CodexUpstreamMetadata,
ChatGPTWebUpstreamMetadata,
GrokUpstreamMetadata,
KiroUpstreamMetadata,
QuotaStatusSnapshot,
QuotaWindowSnapshot,
@@ -1964,7 +1966,7 @@ function quotaSnapshotHasDisplayData(quota: QuotaStatusSnapshot | null | undefin
function getQuotaSnapshotForProvider(
key: EndpointAPIKey,
providerType: 'codex' | 'kiro' | 'antigravity' | 'chatgpt_web' | 'gemini_cli',
providerType: 'codex' | 'kiro' | 'antigravity' | 'chatgpt_web' | 'gemini_cli' | 'grok',
): QuotaStatusSnapshot | null {
const quota = key.status_snapshot?.quota
if (!quota) return null
@@ -2168,6 +2170,66 @@ function hasKiroQuotaDisplayData(key: EndpointAPIKey): boolean {
return !!kiro && (kiro.usage_percentage !== undefined || kiro.usage_limit !== undefined)
}
type GrokQuotaDisplay = GrokUpstreamMetadata & {
usage_percentage?: number
usage_limit?: number
current_usage?: number
remaining?: number
next_reset_at?: number
}
function getGrokQuotaDisplay(key: EndpointAPIKey): GrokQuotaDisplay | null {
const quota = getQuotaSnapshotForProvider(key, 'grok')
if (!quota) return null
const display: GrokQuotaDisplay = {}
const updatedAt = getQuotaSnapshotUpdatedAt(quota)
if (updatedAt !== undefined) display.updated_at = updatedAt
if (quota.plan_type) display.plan_type = quota.plan_type
if (quota.pool_tier) display.pool_tier = quota.pool_tier
const code = String(quota.code || '').trim().toLowerCase()
if (code === 'banned' || code === 'forbidden') {
display.is_banned = true
if (quota.reason) display.ban_reason = quota.reason
}
const usageWindow =
getQuotaWindow(quota, 'usage')
?? getQuotaWindowByScope(quota, 'account')[0]
?? getQuotaWindowByScope(quota, 'model')
.map(window => ({
window,
remainingPercent: getQuotaWindowRemainingPercent(window),
}))
.filter((item): item is { window: QuotaWindowSnapshot, remainingPercent: number } => item.remainingPercent !== undefined)
.sort((a, b) => a.remainingPercent - b.remainingPercent)[0]?.window
?? null
if (usageWindow) {
const usedPercent = getQuotaWindowUsedPercent(usageWindow)
if (usedPercent !== undefined) display.usage_percentage = usedPercent
if (typeof usageWindow.used_value === 'number') display.current_usage = usageWindow.used_value
if (typeof usageWindow.limit_value === 'number') display.usage_limit = usageWindow.limit_value
if (typeof usageWindow.remaining_value === 'number') display.remaining = usageWindow.remaining_value
const nextResetAt =
getQuotaWindowResetAt(usageWindow)
?? (() => {
const resetSeconds = getQuotaWindowResetSeconds(usageWindow)
if (updatedAt === undefined || resetSeconds === undefined) return undefined
return updatedAt + resetSeconds
})()
if (nextResetAt !== undefined) display.next_reset_at = nextResetAt
}
return Object.keys(display).length > 0 ? display : null
}
function hasGrokQuotaDisplayData(key: EndpointAPIKey): boolean {
const grok = getGrokQuotaDisplay(key)
return !!grok && (grok.usage_percentage !== undefined || grok.usage_limit !== undefined)
}
type ChatGPTWebQuotaDisplay = ChatGPTWebUpstreamMetadata & {
image_quota_remaining_percent?: number
image_quota_used_percent?: number
@@ -2435,6 +2497,28 @@ function shouldAutoRefreshKiroQuota(): boolean {
return false
}
function shouldAutoRefreshGrokQuota(): boolean {
if (provider.value?.provider_type !== 'grok') return false
const now = Math.floor(Date.now() / 1000)
for (const { key } of allKeys.value) {
if (!key.is_active) continue
if (isTokenExpiringSoon(key, now)) return true
if (!hasGrokQuotaDisplayData(key)) {
return true
}
const updatedAt = getGrokQuotaDisplay(key)?.updated_at
if (typeof updatedAt !== 'number' || (now - updatedAt) > AUTO_QUOTA_REFRESH_STALE_SECONDS) {
return true
}
}
return false
}
function shouldAutoRefreshChatGPTWebQuota(): boolean {
if (provider.value?.provider_type !== 'chatgpt_web') return false
const now = Math.floor(Date.now() / 1000)
@@ -2541,7 +2625,7 @@ async function autoRefreshQuotaInBackground(options: { ignoreCooldown?: boolean
if (refreshingQuota.value) return
const providerType = provider.value?.provider_type
if (providerType !== 'codex' && providerType !== 'antigravity' && providerType !== 'kiro' && providerType !== 'chatgpt_web') return
if (providerType !== 'codex' && providerType !== 'antigravity' && providerType !== 'kiro' && providerType !== 'chatgpt_web' && providerType !== 'grok') return
// 检查是否需要刷新
let shouldRefresh = false
@@ -2551,6 +2635,8 @@ async function autoRefreshQuotaInBackground(options: { ignoreCooldown?: boolean
shouldRefresh = shouldAutoRefreshAntigravityQuota()
} else if (providerType === 'kiro') {
shouldRefresh = shouldAutoRefreshKiroQuota()
} else if (providerType === 'grok') {
shouldRefresh = shouldAutoRefreshGrokQuota()
} else if (providerType === 'chatgpt_web') {
shouldRefresh = shouldAutoRefreshChatGPTWebQuota()
}
@@ -2564,6 +2650,8 @@ async function autoRefreshQuotaInBackground(options: { ignoreCooldown?: boolean
hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && hasAntigravityQuotaDisplayData(key))
} else if (providerType === 'kiro') {
hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && hasKiroQuotaDisplayData(key))
} else if (providerType === 'grok') {
hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && hasGrokQuotaDisplayData(key))
} else if (providerType === 'chatgpt_web') {
hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && hasChatGPTWebQuotaDisplayData(key))
}
@@ -3030,6 +3118,9 @@ function formatOAuthPlanType(planType: string): string {
team: 'Team',
enterprise: 'Enterprise',
ultra: 'Ultra',
basic: 'Basic',
super: 'Super',
heavy: 'Heavy',
}
return labels[planType.toLowerCase()] || planType
}
@@ -3377,6 +3468,9 @@ function getOAuthPlanTypeClass(planType: string): string {
ultra: 'border-amber-500/50 text-amber-600 dark:text-amber-400',
'pro+': 'border-purple-500/50 text-purple-600 dark:text-purple-400',
power: 'border-amber-500/50 text-amber-600 dark:text-amber-400',
basic: 'border-primary/50 text-primary',
super: 'border-green-500/50 text-green-600 dark:text-green-400',
heavy: 'border-amber-500/50 text-amber-600 dark:text-amber-400',
}
return classes[planType.toLowerCase()] || ''
}
@@ -3412,6 +3506,65 @@ function getHealthScoreBarColor(score: number): string {
return 'bg-red-500 dark:bg-red-400'
}
function isKeyRecoverable(key: EndpointAPIKey): boolean {
return Boolean(
key.circuit_breaker_open
|| (key.health_score !== undefined && key.health_score < 0.5)
)
}
function getOpenCircuitEntries(key: EndpointAPIKey): Array<[string, NonNullable<EndpointAPIKey['circuit_breaker_by_format']>[string]]> {
return Object.entries(key.circuit_breaker_by_format || {})
.filter(([, value]) => value?.open === true)
}
function getKeyCircuitProbeCountdown(key: EndpointAPIKey): string {
void countdownTick.value
const nextProbe = getOpenCircuitEntries(key)
.map(([, value]) => {
if (typeof value.next_probe_at_unix_secs === 'number' && Number.isFinite(value.next_probe_at_unix_secs)) {
return value.next_probe_at_unix_secs * 1000
}
if (value.next_probe_at) {
const ms = new Date(value.next_probe_at).getTime()
return Number.isFinite(ms) ? ms : null
}
return null
})
.filter((value): value is number => value !== null)
.sort((a, b) => a - b)[0]
if (!nextProbe) {
return ''
}
const diffMs = nextProbe - Date.now()
return diffMs > 0 ? ` ${formatCountdown(diffMs)}` : ' 探测中'
}
function getKeyCircuitBreakerTitle(key: EndpointAPIKey): string {
const entries = getOpenCircuitEntries(key)
if (entries.length === 0) return '熔断器已打开'
const parts = entries.map(([format, value]) => {
const label = formatApiFormatShort(format)
const reason = value.reason ? `原因: ${value.reason}` : '原因: 连续失败'
const interval = typeof value.probe_interval_minutes === 'number'
? `探测间隔: ${value.probe_interval_minutes} 分钟`
: ''
const countdown = getFormatProbeCountdown(key, format).trim()
return [label, reason, interval, countdown ? `状态: ${countdown}` : '']
.filter(Boolean)
.join(' / ')
})
parts.push('点击恢复按钮可重置熔断器')
return parts.join('\n')
}
function getRecoverKeyTitle(key: EndpointAPIKey): string {
if (key.circuit_breaker_open) {
return '重置熔断器并恢复健康状态'
}
return '刷新健康状态'
}
// 获取自动获取模型状态的 title 提示
function getAutoFetchStatusTitle(key: EndpointAPIKey): string {
const parts: string[] = ['自动获取模型已启用']
@@ -3453,10 +3606,11 @@ function getFormatProbeCountdown(key: EndpointAPIKey, format: string): string {
}
}
// 等待探测
if (formatData.next_probe_at) {
const nextProbe = new Date(formatData.next_probe_at)
const now = new Date()
const diffMs = nextProbe.getTime() - now.getTime()
if (formatData.next_probe_at_unix_secs || formatData.next_probe_at) {
const nextProbeMs = typeof formatData.next_probe_at_unix_secs === 'number'
? formatData.next_probe_at_unix_secs * 1000
: new Date(formatData.next_probe_at || '').getTime()
const diffMs = nextProbeMs - Date.now()
if (diffMs > 0) {
return ` ${formatCountdown(diffMs)}`
} else {

View File

@@ -60,6 +60,9 @@
<SelectItem value="gemini_cli">
Gemini CLI
</SelectItem>
<SelectItem value="grok">
Grok
</SelectItem>
<SelectItem value="kiro">
Kiro
</SelectItem>
@@ -87,6 +90,9 @@
<SelectItem value="gemini_cli">
Gemini CLI
</SelectItem>
<SelectItem value="grok">
Grok
</SelectItem>
<SelectItem value="kiro">
Kiro
</SelectItem>
@@ -269,6 +275,22 @@
/>
</div>
<div
v-if="form.provider_type === 'kiro'"
class="flex items-center justify-between p-3 border rounded-lg bg-muted/50"
>
<div class="space-y-0.5">
<span class="text-sm font-medium">模拟缓存模式</span>
<p class="text-xs text-muted-foreground leading-relaxed">
启用后仅对 Kiro 请求模拟 prompt cache 读写计量。
</p>
</div>
<Switch
:model-value="form.kiro_simulated_cache_enabled"
@update:model-value="(v: boolean) => form.kiro_simulated_cache_enabled = v"
/>
</div>
<div class="flex items-center justify-between gap-4 p-3 border rounded-lg bg-muted/50">
<div class="space-y-0.5">
<span class="text-sm font-medium">敏感信息保护</span>
@@ -355,7 +377,7 @@ const defaultPriority = computed(() => {
// 表单数据
const form = ref({
name: '',
provider_type: 'custom' as 'custom' | 'vertex_ai' | 'claude_code' | 'codex' | 'chatgpt_web' | 'gemini_cli' | 'antigravity' | 'kiro',
provider_type: 'custom' as 'custom' | 'vertex_ai' | 'claude_code' | 'codex' | 'chatgpt_web' | 'gemini_cli' | 'antigravity' | 'kiro' | 'grok',
description: '',
website: '',
// 计费配置
@@ -377,6 +399,8 @@ const form = ref({
request_timeout: undefined as number | undefined,
// 号池模式
pool_mode_enabled: false,
// Kiro 专属配置
kiro_simulated_cache_enabled: false,
})
// 重置表单
@@ -403,6 +427,8 @@ function resetForm() {
request_timeout: undefined,
// 号池模式
pool_mode_enabled: false,
// Kiro 专属配置
kiro_simulated_cache_enabled: false,
}
}
@@ -433,6 +459,8 @@ function loadProviderData() {
request_timeout: props.provider.request_timeout ?? undefined,
// 号池模式
pool_mode_enabled: poolAdvanced !== null,
// Kiro 专属配置
kiro_simulated_cache_enabled: props.provider.kiro_simulated_cache_enabled ?? false,
}
}
@@ -451,6 +479,9 @@ watch(() => form.value.provider_type, () => {
if (!isEditMode.value) {
form.value.pool_mode_enabled = false
}
if (form.value.provider_type !== 'kiro') {
form.value.kiro_simulated_cache_enabled = false
}
})
// 提交表单
@@ -500,6 +531,15 @@ const handleSubmit = async () => {
pool_advanced: form.value.pool_mode_enabled
? (currentPoolAdvanced ?? {})
: null,
...(form.value.provider_type === 'kiro'
? {
config: {
kiro: {
simulated_cache_enabled: form.value.kiro_simulated_cache_enabled,
},
},
}
: {}),
}
if (isEditMode.value && props.provider) {

View File

@@ -11,13 +11,27 @@
class="space-y-4"
@submit.prevent="handleSubmit"
>
<!-- 添加模式选择本地全局模型 -->
<!-- 添加模式选择或手动创建本地全局模型 -->
<div
v-if="!isEditing"
class="space-y-3"
>
<div class="space-y-1.5">
<Label for="global-model">选择已有模型 *</Label>
<div class="flex items-center justify-between gap-3">
<Label for="global-model">选择已有模型或手动添加 *</Label>
<Button
type="button"
variant="ghost"
size="sm"
class="h-7 px-2 text-xs"
@click="manualGlobalModelMode = !manualGlobalModelMode"
>
{{ manualGlobalModelMode ? '选择已有模型' : '手动添加' }}
</Button>
</div>
<div
v-if="!manualGlobalModelMode"
class="space-y-2"
>
<Select
:model-value="form.global_model_id"
:disabled="loadingGlobalModels"
@@ -37,11 +51,44 @@
</SelectContent>
</Select>
</div>
<div
v-else
class="rounded-lg border border-border/60 bg-muted/20 p-3 space-y-3"
>
<div class="grid grid-cols-2 gap-3">
<div class="space-y-1.5">
<Label
for="manual-global-model-name"
class="text-xs"
>模型ID *</Label>
<Input
id="manual-global-model-name"
v-model="form.manual_global_model_name"
placeholder="如 gpt-4o-mini"
@update:model-value="syncManualProviderName"
/>
</div>
<div class="space-y-1.5">
<Label
for="manual-global-model-display-name"
class="text-xs"
>显示名称</Label>
<Input
id="manual-global-model-display-name"
v-model="form.manual_global_model_display_name"
placeholder="默认使用模型ID"
/>
</div>
</div>
<p class="text-xs text-muted-foreground">
无法联网获取模型目录时可直接填写模型ID保存时会先创建本地全局模型再添加到当前 Provider
</p>
</div>
<p
v-if="availableGlobalModels.length === 0 && !loadingGlobalModels"
v-if="availableGlobalModels.length === 0 && !loadingGlobalModels && !manualGlobalModelMode"
class="text-xs text-muted-foreground"
>
没有可选择的本地全局模型请先在模型管理中添加全局模型
没有可选择的本地全局模型可以切换到手动添加继续保存
</p>
<div class="space-y-1.5">
<Label
@@ -94,6 +141,24 @@
</div>
</div>
<div class="rounded-lg border border-border/60 bg-muted/20 px-3 py-2">
<div class="flex items-start gap-2">
<Checkbox
:checked="isImageGenerationEnabled"
class="mt-0.5"
@update:checked="setImageGenerationEnabled"
/>
<div class="space-y-1">
<div class="text-sm font-medium">
图片模型
</div>
<p class="text-xs text-muted-foreground">
启用图片输出计费并展开尺寸 × 质量矩阵价格
</p>
</div>
</div>
</div>
<!-- 价格配置 -->
<div class="space-y-4">
<h4 class="font-semibold text-sm border-b pb-2">
@@ -103,6 +168,7 @@
ref="tieredPricingEditorRef"
v-model="tieredPricing"
:show-cache1h="showCache1h"
:show-image-pricing="isImageGenerationEnabled"
/>
<!-- 按次计费 -->
@@ -249,11 +315,12 @@ import {
SelectContent,
SelectItem,
Badge,
Checkbox,
} from '@/components/ui'
import { useToast } from '@/composables/useToast'
import { parseNumberInput, sortResolutionEntries } from '@/utils/form'
import { createModel, updateModel, getProviderModels } from '@/api/endpoints/models'
import { listGlobalModels, type GlobalModelResponse } from '@/api/global-models'
import { createGlobalModel, listGlobalModels, type GlobalModelResponse } from '@/api/global-models'
import TieredPricingEditor from '@/features/models/components/TieredPricingEditor.vue'
import type { Model, TieredPricingConfig } from '@/api/endpoints'
import {
@@ -290,10 +357,28 @@ const selectedGlobalModel = computed(() => {
})
const selectedGlobalModelSupportsEmbedding = computed(() => modelSupportsEmbedding(selectedGlobalModel.value))
const selectedGlobalModelSupportsImageGeneration = computed(() => modelSupportsImageGeneration(selectedGlobalModel.value))
const editingModelSupportsEmbedding = computed(() => {
return props.editingModel?.effective_supports_embedding === true
|| modelSupportsEmbedding(props.editingModel)
})
const editingModelSupportsImageGeneration = computed(() => {
return props.editingModel?.effective_supports_image_generation === true
|| modelSupportsImageGeneration(props.editingModel)
})
const isImageGenerationEnabled = computed(() => {
if (imageGenerationExplicitOverride.value !== null) {
return imageGenerationExplicitOverride.value
}
if (form.value.supports_image_generation !== undefined) {
return form.value.supports_image_generation === true
}
const supportsImageGeneration = isEditing.value
? editingModelSupportsImageGeneration.value
: selectedGlobalModelSupportsImageGeneration.value
return supportsImageGeneration || tieredPricingHasImageOutputPricing(tieredPricing.value)
})
// 1h 缓存定价始终显示
const showCache1h = true
@@ -302,6 +387,7 @@ const showCache1h = true
const submitting = ref(false)
const loadingGlobalModels = ref(false)
const availableGlobalModels = ref<GlobalModelResponse[]>([])
const manualGlobalModelMode = ref(false)
// 阶梯计费配置
const tieredPricing = ref<TieredPricingConfig | null>(null)
@@ -336,9 +422,15 @@ const VIDEO_RESOLUTION_PRICE_PRESETS: Record<
],
}
const DEFAULT_MANUAL_GLOBAL_MODEL_PRICING: TieredPricingConfig = {
tiers: [{ up_to: null, input_price_per_1m: 0, output_price_per_1m: 0 }],
}
const form = ref({
global_model_id: '',
provider_model_name: '',
manual_global_model_name: '',
manual_global_model_display_name: '',
price_per_request: undefined as number | undefined,
config: {} as Record<string, unknown>,
// 能力配置
@@ -349,10 +441,12 @@ const form = ref({
supports_image_generation: undefined as boolean | undefined,
is_active: true
})
const imageGenerationExplicitOverride = ref<boolean | null>(null)
const canSubmitCreate = computed(() => {
if (isEditing.value) return true
if (!form.value.provider_model_name.trim()) return false
if (manualGlobalModelMode.value) return !!form.value.manual_global_model_name.trim()
return !!form.value.global_model_id
})
@@ -364,9 +458,12 @@ watch(() => props.open, async (newOpen) => {
// 编辑模式:填充表单
// 使用有效配置(合并全局模型的默认值)供用户查看和编辑
const effectiveConfig = props.editingModel.effective_config || props.editingModel.config || {}
const supportsImageGeneration = modelSupportsImageGeneration(props.editingModel)
form.value = {
global_model_id: props.editingModel.global_model_id || '',
provider_model_name: props.editingModel.provider_model_name || '',
manual_global_model_name: '',
manual_global_model_display_name: '',
// 显示有效的按次计费价格(继承自全局模型)
price_per_request: props.editingModel.effective_price_per_request ?? props.editingModel.price_per_request ?? undefined,
config: effectiveConfig ? JSON.parse(JSON.stringify(effectiveConfig)) : {},
@@ -374,7 +471,7 @@ watch(() => props.open, async (newOpen) => {
supports_function_calling: props.editingModel.supports_function_calling ?? undefined,
supports_streaming: props.editingModel.supports_streaming ?? undefined,
supports_extended_thinking: props.editingModel.supports_extended_thinking ?? undefined,
supports_image_generation: props.editingModel.supports_image_generation ?? undefined,
supports_image_generation: supportsImageGeneration ? true : props.editingModel.supports_image_generation ?? undefined,
is_active: props.editingModel.is_active
}
// 从有效配置中加载视频费用
@@ -425,9 +522,12 @@ watch(tieredPricing, (newValue) => {
// 重置表单
function resetForm() {
imageGenerationExplicitOverride.value = null
form.value = {
global_model_id: '',
provider_model_name: '',
manual_global_model_name: '',
manual_global_model_display_name: '',
price_per_request: undefined,
config: {},
supports_vision: undefined,
@@ -443,14 +543,80 @@ function resetForm() {
tieredPricingModified.value = false
originalTieredPricing.value = ''
availableGlobalModels.value = []
manualGlobalModelMode.value = false
}
function handleGlobalModelSelect(value: string) {
imageGenerationExplicitOverride.value = null
form.value.supports_image_generation = undefined
form.value.global_model_id = value
const selectedModel = availableGlobalModels.value.find(model => model.id === value)
form.value.provider_model_name = selectedModel?.name || form.value.provider_model_name
}
function modelSupportsImageGeneration(model: {
supported_capabilities?: string[] | null
supports_image_generation?: boolean | null
effective_supports_image_generation?: boolean | null
default_tiered_pricing?: TieredPricingConfig | null
tiered_pricing?: TieredPricingConfig | null
effective_tiered_pricing?: TieredPricingConfig | null
config?: Record<string, unknown> | null
} | null | undefined): boolean {
if (!model) return false
if (model.effective_supports_image_generation === true) return true
if (model.supports_image_generation === true) return true
const config = model.config || {}
return model.supported_capabilities?.includes('image_generation') === true
|| config.image_generation === true
|| config.model_type === 'image'
|| (Array.isArray(config.api_formats) && config.api_formats.some((format) => String(format).endsWith(':image')))
|| tieredPricingHasImageOutputPricing(model.default_tiered_pricing)
|| tieredPricingHasImageOutputPricing(model.tiered_pricing)
|| tieredPricingHasImageOutputPricing(model.effective_tiered_pricing)
}
function tieredPricingHasImageOutputPricing(pricing: TieredPricingConfig | null | undefined): boolean {
if (!pricing) return false
if (toFinitePrice(pricing.image_output_price_default) !== null) return true
if (Object.values(pricing.image_output_prices || {}).some((prices) => {
if (!prices || typeof prices !== 'object') return false
return Object.values(prices).some((price) => toFinitePrice(price) !== null)
})) return true
return (pricing.image_output_price_ranges || []).some((range) => {
if (!range || typeof range !== 'object') return false
const prices = range.prices && typeof range.prices === 'object'
? range.prices
: range as Record<string, unknown>
return Object.values(prices).some((price) => toFinitePrice(price) !== null)
})
}
function toFinitePrice(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) return value
if (typeof value === 'string' && value.trim()) {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : null
}
return null
}
function setImageGenerationEnabled(value: boolean | 'indeterminate') {
const enabled = value === true
imageGenerationExplicitOverride.value = enabled
form.value.supports_image_generation = enabled
}
function syncManualProviderName(value: string | number) {
const modelName = String(value || '').trim()
if (!form.value.provider_model_name.trim()) {
form.value.provider_model_name = modelName
}
if (!form.value.manual_global_model_display_name.trim()) {
form.value.manual_global_model_display_name = modelName
}
}
function getNested(obj: Record<string, unknown>, path: string): unknown {
if (!obj || typeof obj !== 'object') return undefined
const parts = path.split('.').filter(Boolean)
@@ -587,6 +753,28 @@ function _copyVideoPricingFromSelectedGlobal() {
configTouched.value = true
}
async function createManualGlobalModel(finalTieredPricing: TieredPricingConfig | null, cleanConfig: Record<string, unknown> | undefined): Promise<GlobalModelResponse> {
const modelName = form.value.manual_global_model_name.trim()
const displayName = form.value.manual_global_model_display_name.trim() || modelName
const supportedCapabilities = [
form.value.supports_vision === true ? 'vision' : null,
form.value.supports_function_calling === true ? 'function_calling' : null,
form.value.supports_streaming === true ? 'streaming' : null,
form.value.supports_extended_thinking === true ? 'extended_thinking' : null,
form.value.supports_image_generation === true ? 'image_generation' : null,
].filter((capability): capability is string => capability !== null)
return createGlobalModel({
name: modelName,
display_name: displayName,
default_price_per_request: form.value.price_per_request,
default_tiered_pricing: finalTieredPricing || DEFAULT_MANUAL_GLOBAL_MODEL_PRICING,
supported_capabilities: supportedCapabilities.length ? supportedCapabilities : undefined,
config: cleanConfig,
is_active: true,
})
}
// 加载可用的全局模型(排除已添加的)
async function loadAvailableGlobalModels() {
loadingGlobalModels.value = true
@@ -624,15 +812,16 @@ function handleClose(value: boolean) {
async function handleSubmit() {
if (submitting.value) return
if (!isEditing.value && !canSubmitCreate.value) {
showError('请选择模型并填写 Provider 模型名', '错误')
showError(manualGlobalModelMode.value ? '请填写模型ID和 Provider 模型名' : '请选择模型并填写 Provider 模型名', '错误')
return
}
submitting.value = true
try {
// 获取包含自动计算缓存价格的最终数据
const finalTiers = tieredPricingEditorRef.value?.getFinalTiers()
const finalTieredPricing = finalTiers ? { tiers: finalTiers } : tieredPricing.value
const finalTieredPricing = tieredPricingEditorRef.value?.getFinalPricing() ?? tieredPricing.value
const supportsImageGeneration = isImageGenerationEnabled.value
|| tieredPricingHasImageOutputPricing(finalTieredPricing)
// Apply billing (video) pricing into config.
applyVideoPricingToConfig(form.value.config)
@@ -651,30 +840,32 @@ async function handleSubmit() {
supportsFunctionCalling: form.value.supports_function_calling,
supportsStreaming: form.value.supports_streaming,
supportsExtendedThinking: form.value.supports_extended_thinking,
supportsImageGeneration: form.value.supports_image_generation,
supportsImageGeneration,
isActive: form.value.is_active
}))
showSuccess('模型配置已更新')
} else {
// 添加模式:只有用户修改了配置才提交 tiered_pricing否则保持继承关系
const selectedModel = availableGlobalModels.value.find(m => m.id === form.value.global_model_id)
const selectedModel = manualGlobalModelMode.value
? await createManualGlobalModel(finalTieredPricing, cleanConfig)
: availableGlobalModels.value.find(m => m.id === form.value.global_model_id)
if (!selectedModel) {
showError('请选择模型', '错误')
showError('请选择模型或切换到手动添加后填写模型ID', '错误')
return
}
await createModel(props.providerId, buildProviderModelCreatePayload({
globalModelId: selectedModel.id,
providerModelName: form.value.provider_model_name.trim(),
finalTieredPricing,
tieredPricingModified: tieredPricingModified.value,
pricePerRequest: form.value.price_per_request,
tieredPricingModified: manualGlobalModelMode.value ? false : tieredPricingModified.value,
pricePerRequest: manualGlobalModelMode.value ? undefined : form.value.price_per_request,
cleanConfig,
configTouched: configTouched.value,
configTouched: manualGlobalModelMode.value ? false : configTouched.value,
supportsVision: form.value.supports_vision,
supportsFunctionCalling: form.value.supports_function_calling,
supportsStreaming: form.value.supports_streaming,
supportsExtendedThinking: form.value.supports_extended_thinking,
supportsImageGeneration: form.value.supports_image_generation,
supportsImageGeneration,
isActive: form.value.is_active
}))
showSuccess('模型已添加')

View File

@@ -0,0 +1,389 @@
/* eslint-disable vue/one-component-per-file, vue/require-default-prop */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, nextTick, type App } from 'vue'
import OAuthAccountDialog from '@/features/providers/components/OAuthAccountDialog.vue'
const endpointMocks = vi.hoisted(() => ({
startProviderLevelOAuth: vi.fn(),
completeProviderLevelOAuth: vi.fn(),
importProviderRefreshToken: vi.fn(),
startBatchImportOAuthTask: vi.fn(),
getBatchImportOAuthTaskStatus: vi.fn(),
startDeviceAuthorize: vi.fn(),
pollDeviceAuthorize: vi.fn(),
getAwsRegions: vi.fn(),
}))
vi.mock('@/api/endpoints', () => endpointMocks)
vi.mock('@/components/ui', async () => {
const { defineComponent, h } = await import('vue')
const passthrough = (name: string, tag = 'div') => defineComponent({
name,
setup(_, { slots }) {
return () => h(tag, slots.default?.())
},
})
const Dialog = defineComponent({
name: 'DialogStub',
props: {
modelValue: Boolean,
},
setup(props, { slots }) {
return () => props.modelValue
? h('section', [slots.headerActions?.(), slots.default?.(), slots.footer?.()])
: null
},
})
const Button = defineComponent({
name: 'ButtonStub',
inheritAttrs: false,
props: {
disabled: Boolean,
variant: String,
size: String,
},
setup(props, { attrs, slots }) {
return () => h('button', {
...attrs,
disabled: props.disabled,
type: attrs.type ?? 'button',
}, slots.default?.())
},
})
const Textarea = defineComponent({
name: 'TextareaStub',
inheritAttrs: false,
props: {
modelValue: {
type: String,
default: '',
},
},
emits: ['update:modelValue'],
setup(props, { attrs, emit }) {
return () => h('textarea', {
...attrs,
value: props.modelValue,
onInput: (event: Event) => emit('update:modelValue', (event.target as HTMLTextAreaElement).value),
})
},
})
return {
Dialog,
Button,
Textarea,
Popover: passthrough('PopoverStub'),
PopoverTrigger: passthrough('PopoverTriggerStub'),
PopoverContent: passthrough('PopoverContentStub'),
}
})
vi.mock('radix-vue', async () => {
const { defineComponent, h } = await import('vue')
const passthrough = (name: string) => defineComponent({
name,
setup(_, { slots }) {
return () => h('div', slots.default?.())
},
})
return {
ComboboxAnchor: passthrough('ComboboxAnchorStub'),
ComboboxContent: passthrough('ComboboxContentStub'),
ComboboxEmpty: passthrough('ComboboxEmptyStub'),
ComboboxInput: passthrough('ComboboxInputStub'),
ComboboxItem: passthrough('ComboboxItemStub'),
ComboboxRoot: passthrough('ComboboxRootStub'),
ComboboxTrigger: passthrough('ComboboxTriggerStub'),
ComboboxViewport: passthrough('ComboboxViewportStub'),
}
})
vi.mock('@/components/common/JsonImportInput.vue', async () => {
const { defineComponent, h } = await import('vue')
return {
default: defineComponent({
name: 'JsonImportInputStub',
props: {
modelValue: {
type: String,
default: '',
},
dropTitle: {
type: String,
default: '',
},
dropHint: {
type: String,
default: '',
},
manualPlaceholder: {
type: String,
default: '',
},
manualDescription: {
type: String,
default: '',
},
pasteToggleText: {
type: String,
default: '',
},
fileToggleText: {
type: String,
default: '',
},
},
emits: ['update:modelValue'],
setup(props, { emit }) {
return () => h('div', [
h('p', { 'data-testid': 'drop-title' }, props.dropTitle),
h('p', { 'data-testid': 'drop-hint' }, props.dropHint),
h('p', { 'data-testid': 'manual-description' }, props.manualDescription),
h('p', props.pasteToggleText),
h('p', props.fileToggleText),
h('textarea', {
placeholder: props.manualPlaceholder,
value: props.modelValue,
onInput: (event: Event) => emit('update:modelValue', (event.target as HTMLTextAreaElement).value),
}),
])
},
}),
}
})
vi.mock('@/components/ui/Label.vue', () => ({}))
vi.mock('./ProxyNodeSelect.vue', () => ({}))
vi.mock('@/features/providers/components/ProxyNodeSelect.vue', async () => {
const { defineComponent, h } = await import('vue')
return {
default: defineComponent({
name: 'ProxyNodeSelectStub',
setup() {
return () => h('div')
},
}),
}
})
vi.mock('@/stores/proxy-nodes', () => ({
useProxyNodesStore: () => ({
nodes: [],
onlineNodes: [],
loading: false,
ensureLoaded: vi.fn(),
}),
}))
vi.mock('@/composables/useToast', () => ({
useToast: () => ({
success: vi.fn(),
error: vi.fn(),
}),
}))
vi.mock('@/composables/useClipboard', () => ({
useClipboard: () => ({
copyToClipboard: vi.fn(),
}),
}))
vi.mock('@/composables/useTotp', () => ({
useTotp: () => ({
code: { value: '' },
remaining: { value: 0 },
start: vi.fn(),
stop: vi.fn(),
}),
}))
vi.mock('lucide-vue-next', async () => {
const { defineComponent, h } = await import('vue')
const Icon = defineComponent({
name: 'IconStub',
setup() {
return () => h('span')
},
})
return {
UserPlus: Icon,
Copy: Icon,
ExternalLink: Icon,
Globe: Icon,
AlertCircle: Icon,
ShieldCheck: Icon,
ChevronsUpDown: Icon,
Check: Icon,
}
})
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
function mountDialog(providerType = 'grok') {
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(OAuthAccountDialog, {
open: true,
providerId: 'provider-1',
providerType,
})
app.mount(root)
mountedApps.push({ app, root })
return root
}
async function settle() {
await nextTick()
await Promise.resolve()
}
function getButton(root: HTMLElement, text: string) {
return Array.from(root.querySelectorAll('button'))
.find(button => button.textContent?.includes(text))
}
function getImportTextarea(root: HTMLElement) {
const textarea = root.querySelector('textarea')
if (!(textarea instanceof HTMLTextAreaElement)) {
throw new Error('Expected import textarea to exist')
}
return textarea
}
describe('OAuthAccountDialog Grok import', () => {
beforeEach(() => {
endpointMocks.startProviderLevelOAuth.mockReset()
endpointMocks.completeProviderLevelOAuth.mockReset()
endpointMocks.importProviderRefreshToken.mockReset()
endpointMocks.startBatchImportOAuthTask.mockReset()
endpointMocks.getBatchImportOAuthTaskStatus.mockReset()
endpointMocks.startDeviceAuthorize.mockReset()
endpointMocks.pollDeviceAuthorize.mockReset()
endpointMocks.getAwsRegions.mockReset()
endpointMocks.importProviderRefreshToken.mockResolvedValue({
provider_type: 'grok',
has_refresh_token: false,
email: 'grok@example.com',
replaced: false,
})
endpointMocks.startBatchImportOAuthTask.mockResolvedValue({
task_id: 'task-1',
status: 'submitted',
total: 2,
processed: 0,
success: 0,
failed: 0,
progress_percent: 0,
})
})
afterEach(() => {
for (const { app, root } of mountedApps.splice(0)) {
app.unmount()
root.remove()
}
})
it('opens Grok in import mode without starting unsupported OAuth', async () => {
const root = mountDialog('grok')
await settle()
expect(endpointMocks.startProviderLevelOAuth).not.toHaveBeenCalled()
expect(root.textContent).not.toContain('获取授权')
expect(root.querySelector('textarea')?.getAttribute('placeholder')).toContain('Grok sso/session token')
expect(root.textContent).toContain('plan_type / pool_tier')
expect(getButton(root, '导入账号')).toBeTruthy()
})
it('maps a single Grok JSON token into account metadata import payload', async () => {
const root = mountDialog('grok')
await settle()
const textarea = getImportTextarea(root)
textarea.value = JSON.stringify({
token: 'sso-1',
planType: 'super',
tier: 'heavy',
email: 'grok@example.com',
accountName: 'Grok Heavy',
})
textarea.dispatchEvent(new Event('input'))
await settle()
getButton(root, '导入账号')?.click()
await settle()
expect(endpointMocks.importProviderRefreshToken).toHaveBeenCalledWith('provider-1', {
access_token: 'sso-1',
account_name: 'Grok Heavy',
email: 'grok@example.com',
plan_type: 'super',
pool_tier: 'heavy',
sso_rw_token: undefined,
cf_cookies: undefined,
cf_clearance: undefined,
user_agent: undefined,
browser_profile: undefined,
proxy_node_id: undefined,
refresh_token: undefined,
expires_at: undefined,
name: undefined,
account_id: undefined,
account_user_id: undefined,
user_id: undefined,
})
})
it('keeps Grok multiline token import on the batch task path', async () => {
const root = mountDialog('grok')
await settle()
const textarea = getImportTextarea(root)
textarea.value = 'sso-1\nsso-2'
textarea.dispatchEvent(new Event('input'))
await settle()
getButton(root, '导入账号')?.click()
await settle()
expect(endpointMocks.startBatchImportOAuthTask).toHaveBeenCalledWith(
'provider-1',
'sso-1\nsso-2',
undefined,
)
expect(endpointMocks.importProviderRefreshToken).not.toHaveBeenCalled()
})
it('extracts Grok account fields from a pasted browser cookie header', async () => {
const root = mountDialog('grok')
await settle()
const textarea = getImportTextarea(root)
textarea.value = 'i18nextLng=zh; cf_clearance=cf-1; sso-rw=rw-1; sso=sso-1; x-userid=user-1'
textarea.dispatchEvent(new Event('input'))
await settle()
getButton(root, '导入账号')?.click()
await settle()
expect(endpointMocks.importProviderRefreshToken).toHaveBeenCalledWith('provider-1', expect.objectContaining({
access_token: 'sso-1',
sso_rw_token: 'rw-1',
cf_cookies: 'i18nextlng=zh; cf_clearance=cf-1; x-userid=user-1',
cf_clearance: 'cf-1',
user_agent: expect.any(String),
browser_profile: 'chrome136',
user_id: 'user-1',
}))
})
})

View File

@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest'
import { getDefaultEndpointPath } from '../endpoint-default-paths'
const apiFormats = [
{ value: 'gemini:generate_content', default_path: '/v1beta/models/{model}:{action}' },
{ value: 'gemini:embedding', default_path: '/v1beta/models/{model}:{action}' },
{ value: 'openai:responses', default_path: '/v1/responses' },
]
describe('endpoint default paths', () => {
it('uses Gemini Developer API paths for custom Gemini endpoints', () => {
expect(getDefaultEndpointPath({
apiFormat: 'gemini:generate_content',
providerType: 'custom',
apiFormats,
})).toBe('/v1beta/models/{model}:{action}')
expect(getDefaultEndpointPath({
apiFormat: 'gemini:embedding',
providerType: 'custom',
apiFormats,
})).toBe('/v1beta/models/{model}:{action}')
})
it('uses Vertex AI project/location paths for Vertex provider Gemini endpoints', () => {
expect(getDefaultEndpointPath({
apiFormat: 'gemini:generate_content',
providerType: 'vertex_ai',
apiFormats,
})).toBe('/v1/projects/{project_id}/locations/{region}/publishers/google/models/{model}:{action}')
expect(getDefaultEndpointPath({
apiFormat: 'gemini:embedding',
providerType: 'vertex_ai',
apiFormats,
})).toBe('/v1/projects/{project_id}/locations/{region}/publishers/google/models/{model}:predict')
})
it('keeps Codex Responses root path without duplicating /v1', () => {
expect(getDefaultEndpointPath({
apiFormat: 'openai:responses',
providerType: 'codex',
apiFormats,
})).toBe('/responses')
})
})

View File

@@ -350,6 +350,27 @@ describe('provider key concurrent_limit form behavior', () => {
}))
})
it('keeps Gemini embedding selectable for Vertex AI keys', async () => {
const root = mountDialog(KeyFormDialog, {
open: true,
endpoint: null,
editingKey: null,
providerId: 'provider-vertex',
providerType: 'vertex_ai',
availableApiFormats: ['gemini:generate_content', 'gemini:embedding', 'claude:messages'],
})
await settle()
expect(root.textContent).toContain('Gemini Embedding')
const serviceAccountOption = root.querySelector<HTMLButtonElement>('[data-select-item="service_account"]')
expect(serviceAccountOption).not.toBeNull()
serviceAccountOption?.click()
await settle()
expect(root.textContent).toContain('Gemini Embedding')
})
it('hydrates and serializes a positive concurrent_limit number from the normal key form', async () => {
const root = mountDialog(KeyFormDialog, {
open: true,

View File

@@ -49,7 +49,7 @@ describe('provider model form embedding helpers', () => {
expect('supports_embedding' in payload).toBe(false)
})
it('uses supplied provider model name in create payload', () => {
it('uses manually supplied provider model name in create payload', () => {
const payload = buildProviderModelCreatePayload({
globalModelId: 'gm-local-manual',
providerModelName: 'intranet-chat-model-v1',

View File

@@ -0,0 +1,47 @@
interface ApiFormatPathDefinition {
value: string
default_path: string
}
export function normalizeEndpointApiFormat(apiFormat: string): string {
switch (apiFormat.trim().toLowerCase()) {
default:
return apiFormat.trim().toLowerCase()
}
}
function isCodexUrl(baseUrl: string): boolean {
const url = baseUrl.replace(/\/+$/, '')
return url.includes('/backend-api/codex') || url.endsWith('/codex')
}
export function getDefaultEndpointPath(params: {
apiFormat: string
providerType?: string | null
baseUrl?: string
apiFormats: ApiFormatPathDefinition[]
}): string {
const providerType = (params.providerType || '').toLowerCase()
const normalizedApiFormat = normalizeEndpointApiFormat(params.apiFormat)
if (providerType === 'vertex_ai') {
if (normalizedApiFormat === 'gemini:generate_content') {
return '/v1/projects/{project_id}/locations/{region}/publishers/google/models/{model}:{action}'
}
if (normalizedApiFormat === 'gemini:embedding') {
return '/v1/projects/{project_id}/locations/{region}/publishers/google/models/{model}:predict'
}
if (normalizedApiFormat === 'claude:messages') {
return '/v1/projects/{project_id}/locations/{region}/publishers/anthropic/models/{model}:{action}'
}
}
const format = params.apiFormats.find(f => f.value === normalizedApiFormat)
const defaultPath = format?.default_path || ''
const isCodex = providerType
? providerType === 'codex'
: (!!params.baseUrl && isCodexUrl(params.baseUrl))
if (normalizedApiFormat === 'openai:responses' && isCodex) {
return '/responses'
}
return defaultPath
}

View File

@@ -443,9 +443,10 @@ const activeEndpoints = computed(() => (props.endpoints ?? [])
if (typeof endpoint.active_keys === 'number') {
return endpoint.is_active !== false
&& isModelTestableApiFormat(endpoint.api_format)
&& endpoint.active_keys > 0
&& (endpoint.active_keys > 0
|| isModelTestableEndpoint(endpoint, providerKeysState.value, props.provider.provider_type))
}
return isModelTestableEndpoint(endpoint, providerKeysState.value)
return isModelTestableEndpoint(endpoint, providerKeysState.value, props.provider.provider_type)
}))
const selectableTestEndpoints = computed(() => mappingTestEndpoints.value ?? activeEndpoints.value)
const parsedTestRequestHeaders = computed(() => parseModelTestRequestHeadersDraft(testRequestHeadersDraft.value))

View File

@@ -313,9 +313,10 @@ const activeEndpoints = computed(() => (props.endpoints ?? [])
if (typeof endpoint.active_keys === 'number') {
return endpoint.is_active !== false
&& isModelTestableApiFormat(endpoint.api_format)
&& endpoint.active_keys > 0
&& (endpoint.active_keys > 0
|| isModelTestableEndpoint(endpoint, props.providerKeys ?? [], props.provider.provider_type))
}
return isModelTestableEndpoint(endpoint, props.providerKeys ?? [])
return isModelTestableEndpoint(endpoint, props.providerKeys ?? [], props.provider.provider_type)
}))
const parsedTestRequestHeaders = computed(() => parseModelTestRequestHeadersDraft(testRequestHeadersDraft.value))
const testRequestHeadersError = computed(() => parsedTestRequestHeaders.value.error)

View File

@@ -58,7 +58,7 @@ describe('buildDefaultModelTestRequestBody', () => {
expect(body.input).toBeUndefined()
})
it('uses prompt payloads for openai image api formats', () => {
it('uses image prompt payloads for OpenAI image test requests', () => {
const body = JSON.parse(buildDefaultModelTestRequestBody('gpt-image-2', 'openai:image'))
expect(body).toEqual({
@@ -240,6 +240,8 @@ describe('isModelTestableApiFormat', () => {
it.each([
'openai:chat',
'openai:responses',
'openai:responses:compact',
'openai:image',
'claude:messages',
'gemini:generate_content',
'openai:image',
@@ -350,6 +352,23 @@ describe('isModelTestableEndpoint', () => {
is_active: true,
}, keys)).toBe(true)
})
it('lets fixed provider OAuth keys inherit testable endpoint formats', () => {
const keys = [{
api_formats: ['legacy:mismatch'],
auth_type: 'oauth',
is_active: true,
}]
expect(isModelTestableEndpoint({
api_format: 'openai:image',
is_active: true,
}, keys, 'chatgpt_web')).toBe(true)
expect(isModelTestableEndpoint({
api_format: 'openai:image',
is_active: true,
}, keys, 'custom')).toBe(false)
})
})
describe('formatModelTestDiagnostic', () => {

View File

@@ -15,6 +15,9 @@ export type ModelTestImageSource = {
export type ModelTestKeySource = {
api_formats?: string[] | null
is_active?: boolean | null
auth_type?: string | null
credential_kind?: string | null
oauth_managed?: boolean | null
}
const MODEL_TEST_UNSUPPORTED_API_FORMATS = new Set([
@@ -23,6 +26,20 @@ const MODEL_TEST_UNSUPPORTED_API_FORMATS = new Set([
'gemini:files',
])
const MODEL_TEST_OAUTH_INHERITS_PROVIDER_FORMATS = new Set([
'claude_code',
'codex',
'chatgpt_web',
'gemini_cli',
'vertex_ai',
'antigravity',
'kiro',
])
const MODEL_TEST_BEARER_INHERITS_PROVIDER_FORMATS = new Set([
'chatgpt_web',
])
const MODEL_TEST_DIAGNOSTIC_LABELS: Record<string, string> = {
pool_account_blocked: '账号已失效,需重新授权',
}
@@ -41,12 +58,15 @@ export function isModelTestableApiFormat(apiFormat: string | null | undefined):
export function modelTestKeySupportsEndpoint(
key: ModelTestKeySource,
endpoint: ModelTestEndpointSource,
providerType?: string | null,
): boolean {
if (key.is_active === false) return false
const endpointFormat = normalizeApiFormatAlias(endpoint.api_format)
if (!isModelTestableApiFormat(endpointFormat)) return false
if (modelTestKeyInheritsProviderFormats(key, providerType)) return true
const keyFormats = normalizeModelTestStringList(key.api_formats)
if (keyFormats.length === 0) return true
@@ -56,10 +76,32 @@ export function modelTestKeySupportsEndpoint(
export function isModelTestableEndpoint(
endpoint: ModelTestEndpointSource,
keys: ModelTestKeySource[],
providerType?: string | null,
): boolean {
return endpoint.is_active !== false
&& isModelTestableApiFormat(endpoint.api_format)
&& keys.some(key => modelTestKeySupportsEndpoint(key, endpoint))
&& keys.some(key => modelTestKeySupportsEndpoint(key, endpoint, providerType))
}
function modelTestKeyInheritsProviderFormats(
key: ModelTestKeySource,
providerType: string | null | undefined,
): boolean {
const normalizedProviderType = providerType?.trim().toLowerCase()
if (!normalizedProviderType) return false
const authType = key.auth_type?.trim().toLowerCase()
const credentialKind = key.credential_kind?.trim().toLowerCase()
const oauthManaged = key.oauth_managed === true
|| credentialKind === 'oauth_session'
|| authType === 'oauth'
if (oauthManaged && MODEL_TEST_OAUTH_INHERITS_PROVIDER_FORMATS.has(normalizedProviderType)) {
return true
}
return authType === 'bearer'
&& MODEL_TEST_BEARER_INHERITS_PROVIDER_FORMATS.has(normalizedProviderType)
}
export function selectPreferredModelTestEndpoint<T extends ModelTestEndpointSource>(

View File

@@ -149,14 +149,16 @@ export function buildDefaultModelTestRequestBody(
apiFormat?: string | null,
model?: ModelTestImageSource | null,
): string {
if (apiFormat?.trim().toLowerCase().endsWith(':embedding')) {
const normalizedApiFormat = normalizeApiFormatAlias(apiFormat ?? '')
if (normalizedApiFormat.endsWith(':embedding')) {
return JSON.stringify({
model: modelName,
input: 'This is a test embedding input.',
}, null, 2)
}
if (apiFormat?.trim().toLowerCase().endsWith(':rerank')) {
if (normalizedApiFormat.endsWith(':rerank')) {
return JSON.stringify({
model: modelName,
query: 'Apple',
@@ -171,7 +173,7 @@ export function buildDefaultModelTestRequestBody(
}, null, 2)
}
if (normalizeApiFormatAlias(apiFormat ?? '') === 'openai:image') {
if (normalizedApiFormat === 'openai:image') {
return JSON.stringify({
model: modelName,
prompt: DEFAULT_MODEL_TEST_MESSAGE,
@@ -181,7 +183,7 @@ export function buildDefaultModelTestRequestBody(
}, null, 2)
}
if (normalizeApiFormatAlias(apiFormat ?? '') === 'openai:responses' && modelSupportsImageGeneration(model)) {
if (normalizedApiFormat === 'openai:responses' && modelSupportsImageGeneration(model)) {
return JSON.stringify({
model: modelName,
input: DEFAULT_MODEL_TEST_MESSAGE,
@@ -271,4 +273,4 @@ export function parseModelTestRequestHeadersDraft(
emptyError: null,
invalidTypeError: '测试请求头必须是 JSON 对象',
})
}
}

View File

@@ -8,4 +8,10 @@ describe('providerTypeUtils', () => {
expect(isOAuthAccountProviderType('ChatGPT_Web')).toBe(true)
expect(isKeyManagedProviderType('chatgpt_web')).toBe(false)
})
it('treats Grok as an OAuth account provider', () => {
expect(isOAuthAccountProviderType('grok')).toBe(true)
expect(isOAuthAccountProviderType('GROK')).toBe(true)
expect(isKeyManagedProviderType('grok')).toBe(false)
})
})

View File

@@ -11,6 +11,7 @@ const oauthAccountProviderTypes = new Set([
'gemini_cli',
'antigravity',
'kiro',
'grok',
])
export const isOAuthAccountProviderType = (providerType?: string | null): boolean =>

View File

@@ -341,36 +341,6 @@
</span>
</span>
</div>
<div
v-if="activeCapabilities.length > 0"
class="info-item"
>
<span class="info-label">请求能力</span>
<span class="info-value">
<span class="capability-tags">
<span
v-for="cap in activeCapabilities"
:key="`required-${cap}`"
class="capability-tag active"
>{{ formatCapabilityLabel(cap) }}</span>
</span>
</span>
</div>
<div
v-if="keyCapabilities.length > 0"
class="info-item"
>
<span class="info-label">Key 能力</span>
<span class="info-value">
<span class="capability-tags">
<span
v-for="cap in keyCapabilities"
:key="`key-${cap}`"
class="capability-tag"
>{{ formatCapabilityLabel(cap) }}</span>
</span>
</span>
</div>
</div>
<div
@@ -1489,26 +1459,6 @@ const currentAttemptExtraDataDisplay = computed<Record<string, unknown> | null>(
return Object.keys(display).length > 0 ? display : null
})
// 计算当前尝试启用的能力标签(请求需要的能力)
const activeCapabilities = computed(() => {
if (!currentAttempt.value?.required_capabilities) return []
const caps = currentAttempt.value.required_capabilities
// 只返回值为 true 的能力
return Object.entries(caps)
.filter(([_, enabled]) => enabled)
.map(([key]) => key)
})
// 计算当前 Key 支持的能力标签
const keyCapabilities = computed(() => {
if (!currentAttempt.value?.key_capabilities) return []
const caps = currentAttempt.value.key_capabilities
// 只返回值为 true 的能力
return Object.entries(caps)
.filter(([_, enabled]) => enabled)
.map(([key]) => key)
})
const hasActiveImageProgress = computed(() => {
return rawTimeline.value.some((candidate) => {
const progress = normalizeImageProgress(candidate.image_progress)
@@ -1542,20 +1492,6 @@ const formatAuthTypeWithPlan = (authType: string, planType?: string): string =>
return typeName
}
// 格式化能力标签显示
const formatCapabilityLabel = (cap: string): string => {
const labels: Record<string, string> = {
'cache_1h': '1h缓存',
'cache_5min': '5min缓存',
'context_1m': '1M上下文',
'context_200k': '200K上下文',
'extended_thinking': '深度思考',
'vision': '视觉',
'function_calling': '函数调用',
}
return labels[cap] || cap
}
const poolSelectionLabel = (reason: string): string => {
const labels: Record<string, string> = {
sticky: '粘性会话',
@@ -2502,35 +2438,6 @@ function getDisplayStatus(attempt: CandidateRecord | null | undefined): string {
color: hsl(var(--muted-foreground));
}
/* 能力标签 */
.capability-tags {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.375rem;
}
.capability-tag {
display: inline-flex;
align-items: center;
padding: 0.15rem 0.5rem;
font-size: 0.7rem;
font-weight: 500;
color: hsl(var(--muted-foreground));
white-space: nowrap;
border-radius: 4px;
background: transparent;
border: 1px dashed hsl(var(--border));
transition: all 0.15s ease;
}
/* 被请求使用的能力(高亮边框) */
.capability-tag.active {
color: hsl(var(--primary));
border-color: hsl(var(--primary) / 0.5);
background: hsl(var(--primary) / 0.08);
}
.image-progress-block {
margin-top: 0.875rem;
padding: 0.75rem;

View File

@@ -275,6 +275,9 @@
<template v-if="perRequestCost > 0">
+ 按次费用 <span class="font-medium">${{ perRequestCost.toFixed(6) }}</span>
</template>
<template v-if="imageOutputCostTotal > 0">
+ 图片输出费用 <span class="font-medium">${{ imageOutputCostTotal.toFixed(6) }}</span>
</template>
<template v-if="videoCostTotal > 0">
+ {{ detail.video_billing?.task_type === 'image' ? '图像' : detail.video_billing?.task_type === 'audio' ? '音频' : '视频' }}费用 <span class="font-medium">${{ videoCostTotal.toFixed(6) }}</span>
</template>
@@ -426,7 +429,52 @@
</div>
</div>
<!-- ========== 4. 视频/图像/音频计费独立隔离与Token计费风格一致 ========== -->
<!-- ========== 4. 图片输出计费 ========== -->
<div
v-if="hasImageBillingDetail"
class="rounded-lg p-3 space-y-2 bg-primary/5 border border-primary/30 mb-3"
>
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1 sm:gap-2 text-xs">
<div class="flex items-center gap-2 flex-wrap">
<span class="font-medium text-primary">图片输出</span>
<Badge
variant="outline"
class="text-[10px] px-1.5 py-0 h-4"
>
{{ imageOutputBillingLabel }}
</Badge>
<span
v-if="imageOutputPricingDescriptor"
class="text-muted-foreground font-mono"
>{{ imageOutputPricingDescriptor }}</span>
</div>
<div class="text-muted-foreground flex items-center gap-2 flex-wrap">
<span
v-if="imageOutputPricePerImage !== null"
class="font-mono"
>{{ formatNumber(imageOutputCount) }} × ${{ imageOutputPricePerImage.toFixed(6) }}/ = ${{ imageOutputCostTotal.toFixed(6) }}</span>
</div>
</div>
<div class="flex items-center">
<div class="flex items-center flex-1">
<span class="text-xs text-muted-foreground w-[56px]">数量</span>
<span class="text-sm font-semibold font-mono flex-1 text-center">{{ formatNumber(imageOutputCount) }}</span>
<span class="text-xs font-mono">${{ imageOutputCostTotal.toFixed(6) }}</span>
</div>
<Separator
orientation="vertical"
class="h-4 mx-4"
/>
<div class="flex items-center flex-1">
<span class="text-xs text-muted-foreground w-[56px]">格式</span>
<span class="text-sm font-semibold font-mono flex-1 text-center">{{ imageOutputFormat || '-' }}</span>
<span class="text-xs font-mono text-muted-foreground">{{ imageOutputBillingLabel }}</span>
</div>
</div>
</div>
<!-- ========== 5. 视频/图像/音频计费独立隔离与Token计费风格一致 ========== -->
<div
v-if="detail.video_billing"
class="rounded-lg p-3 space-y-2 bg-primary/5 border border-primary/30"
@@ -943,6 +991,11 @@ function getNestedNumber(record: JsonRecord | null, ...path: string[]): number |
return toNumber(getNestedValue(record, ...path))
}
function getNestedString(record: JsonRecord | null, ...path: string[]): string | null {
const value = getNestedValue(record, ...path)
return typeof value === 'string' && value.trim() ? value.trim() : null
}
function normalizeCacheTtlPricing(value: unknown): CacheTTLPriceEntry[] {
if (!Array.isArray(value)) return []
return value
@@ -1080,6 +1133,10 @@ const billingResolvedVariables = computed<JsonRecord | null>(() =>
asRecord(billingSnapshot.value?.resolved_variables),
)
const billingResolvedDimensions = computed<JsonRecord | null>(() =>
asRecord(billingSnapshot.value?.resolved_dimensions),
)
const billingCostBreakdown = computed<JsonRecord | null>(() =>
asRecord(billingSnapshot.value?.cost_breakdown),
)
@@ -1421,6 +1478,108 @@ const effectiveRequestCost = computed(() => {
return 0
})
const effectiveImageOutputCost = computed(() =>
getNestedNumber(billingCostBreakdown.value, 'image_output_cost')
?? toNumber(detail.value?.image_output_cost)
?? 0,
)
const imageOutputCostTotal = computed(() => effectiveImageOutputCost.value)
const imageOutputPricePerImage = computed(() =>
getNestedNumber(billingResolvedVariables.value, 'image_output_price_per_image'),
)
const imageOutputCount = computed(() =>
getNestedNumber(billingResolvedDimensions.value, 'image_count')
?? getNestedNumber(traceRequestMetadata.value, 'billing_dimensions', 'image_count')
?? getNestedNumber(traceRequestMetadata.value, 'dimensions', 'image_count')
?? 0,
)
const imageOutputSize = computed(() =>
getNestedString(billingResolvedDimensions.value, 'image_size')
?? getNestedString(traceRequestMetadata.value, 'billing_dimensions', 'image_size')
?? getNestedString(traceRequestMetadata.value, 'dimensions', 'image_size'),
)
const imageOutputQuality = computed(() =>
getNestedString(billingResolvedDimensions.value, 'image_quality')
?? getNestedString(traceRequestMetadata.value, 'billing_dimensions', 'image_quality')
?? getNestedString(traceRequestMetadata.value, 'dimensions', 'image_quality'),
)
const imageOutputFormat = computed(() =>
getNestedString(billingResolvedDimensions.value, 'image_output_format')
?? getNestedString(traceRequestMetadata.value, 'billing_dimensions', 'image_output_format')
?? getNestedString(traceRequestMetadata.value, 'dimensions', 'image_output_format'),
)
const imagePriceKey = computed(() => {
const snapshotKey = getNestedString(billingResolvedDimensions.value, 'image_price_key')
if (snapshotKey) return snapshotKey
const fallbackKey = [imageOutputSize.value, imageOutputQuality.value].filter(Boolean).join(':')
return fallbackKey || null
})
const imageOutputPriceBucket = computed(() =>
getNestedString(billingResolvedDimensions.value, 'image_output_price_bucket'),
)
const imageOutputPixels = computed(() =>
getNestedNumber(billingResolvedDimensions.value, 'image_pixels')
?? parseImageSizePixels(imageOutputSize.value),
)
const imageOutputPricingMode = computed(() =>
getNestedString(billingResolvedDimensions.value, 'image_output_pricing_mode'),
)
const imageOutputPricingEnabled = computed(() =>
getNestedValue(billingResolvedDimensions.value, 'image_output_pricing_enabled') === true
|| imageOutputPricingMode.value === 'matrix'
|| imageOutputPricingMode.value === 'pixel_tiers'
|| imageOutputPricingMode.value === 'per_image'
|| imageOutputCostTotal.value > 0,
)
const imageOutputMatrixEnabled = computed(() => {
if (imageOutputPricingMode.value) return imageOutputPricingMode.value === 'matrix'
return getNestedValue(billingResolvedDimensions.value, 'image_output_matrix_enabled') === true
})
const imageOutputRangeEnabled = computed(() => {
if (imageOutputPricingMode.value) return imageOutputPricingMode.value === 'pixel_tiers'
return getNestedValue(billingResolvedDimensions.value, 'image_output_range_enabled') === true
})
const imageOutputBillingLabel = computed(() => {
if (imageOutputMatrixEnabled.value) return '矩阵计费'
if (imageOutputRangeEnabled.value) return '像素区间'
return '默认计费'
})
const imageOutputPricingDescriptor = computed(() => {
if (imageOutputMatrixEnabled.value && imagePriceKey.value) return imagePriceKey.value
const parts: string[] = []
if (imageOutputPriceBucket.value && imageOutputPriceBucket.value !== 'default') {
parts.push(formatImagePriceBucket(imageOutputPriceBucket.value))
}
const sizeQuality = [imageOutputSize.value, imageOutputQuality.value].filter(Boolean).join(' / ')
if (sizeQuality) parts.push(sizeQuality)
if (imageOutputRangeEnabled.value && imageOutputPixels.value !== null) {
parts.push(formatPixels(imageOutputPixels.value))
}
if (parts.length > 0) return parts.join(' · ')
if (imageOutputPriceBucket.value === 'default') return '默认价'
return null
})
const hasImageBillingDetail = computed(() =>
imageOutputPricingEnabled.value && (imageOutputCount.value > 0 || imageOutputCostTotal.value > 0),
)
const fallbackCacheTtlPricing = computed<CacheTTLPriceEntry[]>(() => {
const tierPricing = normalizeCacheTtlPricing(billingTierInfo.value?.cache_ttl_pricing)
if (tierPricing.length > 0) return tierPricing
@@ -2123,6 +2282,34 @@ function formatNumber(num: number): string {
return num.toLocaleString()
}
function parseImageSizePixels(size: string | null): number | null {
if (!size) return null
const normalized = size.trim().toLowerCase().replace(/\s+/g, '').replace(/×/g, 'x')
const [widthText, heightText] = normalized.split('x')
const width = Number(widthText)
const height = Number(heightText)
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return null
return Math.trunc(width * height)
}
function formatImagePriceBucket(bucket: string): string {
if (bucket === 'default') return '默认价'
if (bucket === 'unbounded') return '无上限'
const match = bucket.match(/^<=([0-9]+)px$/)
if (match) return `<= ${formatPixels(Number(match[1]))}`
return bucket
}
function formatPixels(value: number): string {
if (value >= 1_000_000) {
return `${(value / 1_000_000).toFixed(value % 1_000_000 === 0 ? 0 : 2)}M px`
}
if (value >= 1_000) {
return `${(value / 1_000).toFixed(0)}K px`
}
return `${value} px`
}
// 格式化响应时间,自动选择合适的单位
function formatResponseTime(ms: number): { value: string; unit: string } {
if (ms >= 1_000) {

View File

@@ -139,6 +139,19 @@
<!-- 分隔线 -->
<div class="hidden sm:block h-4 w-px bg-border" />
<!-- 列显示配置桌面端 -->
<MultiSelect
v-model="visibleColumnIds"
:options="columnSelectOptions"
placeholder="显示列"
trigger-class="hidden md:flex w-40 h-8 text-xs border-border/60"
dropdown-min-width="14rem"
:searchable="false"
/>
<!-- 分隔线 -->
<div class="hidden sm:block h-4 w-px bg-border" />
<!-- 自动刷新按钮 -->
<Button
variant="ghost"
@@ -295,36 +308,42 @@
<!-- 桌面端表格视图 -->
<Table
class="hidden md:table table-fixed w-full"
:class="[isAdmin ? 'min-w-[1120px]' : 'min-w-[960px]']"
:class="[desktopTableMinWidthClass]"
>
<colgroup v-if="isAdmin">
<col class="w-[8%]">
<col class="w-[12%]">
<col class="w-[14%]">
<col class="w-[16%]">
<col class="w-[15%]">
<col class="w-[10%]">
<col class="w-[10%]">
<col class="w-[6%]">
<col class="w-[9%]">
<col v-if="isColumnVisible('time')" class="w-[8%]">
<col v-if="isColumnVisible('user')" class="w-[12%]">
<col v-if="isColumnVisible('model')" class="w-[14%]">
<col v-if="isColumnVisible('provider')" class="w-[16%]">
<col v-if="isColumnVisible('api_format')" class="w-[15%]">
<col v-if="isColumnVisible('status')" class="w-[10%]">
<col v-if="isColumnVisible('tokens')" class="w-[10%]">
<col v-if="isColumnVisible('cost')" class="w-[6%]">
<col v-if="isColumnVisible('performance')" class="w-[9%]">
<col v-if="isColumnVisible('client_family')" class="w-[12%]">
<col v-if="isColumnVisible('client_ip')" class="w-[10%]">
<col v-if="isColumnVisible('user_agent')" class="w-[13%]">
</colgroup>
<colgroup v-else>
<col class="w-[9%]">
<col class="w-[17%]">
<col class="w-[22%]">
<col class="w-[14%]">
<col class="w-[10%]">
<col class="w-[11%]">
<col class="w-[7%]">
<col class="w-[10%]">
<col v-if="isColumnVisible('time')" class="w-[9%]">
<col v-if="isColumnVisible('key')" class="w-[17%]">
<col v-if="isColumnVisible('model')" class="w-[22%]">
<col v-if="isColumnVisible('api_format')" class="w-[14%]">
<col v-if="isColumnVisible('status')" class="w-[10%]">
<col v-if="isColumnVisible('tokens')" class="w-[11%]">
<col v-if="isColumnVisible('cost')" class="w-[7%]">
<col v-if="isColumnVisible('performance')" class="w-[10%]">
<col v-if="isColumnVisible('client_family')" class="w-[12%]">
<col v-if="isColumnVisible('client_ip')" class="w-[10%]">
<col v-if="isColumnVisible('user_agent')" class="w-[13%]">
</colgroup>
<TableHeader>
<TableRow class="border-b border-border/60 hover:bg-transparent">
<TableHead class="h-12 font-semibold w-[8%]">
<TableHead v-if="isColumnVisible('time')" class="h-12 font-semibold w-[8%]">
时间
</TableHead>
<SortableTableHead
v-if="isAdmin"
v-if="isAdmin && isColumnVisible('user')"
class="h-12 font-semibold w-[12%]"
column-key="user"
:sortable="false"
@@ -343,12 +362,13 @@
</template>
</SortableTableHead>
<TableHead
v-if="!isAdmin"
v-if="!isAdmin && isColumnVisible('key')"
class="h-12 font-semibold w-[17%]"
>
密钥
</TableHead>
<SortableTableHead
v-if="isColumnVisible('model')"
class="h-12 font-semibold"
:class="[isAdmin ? 'w-[14%]' : 'w-[22%]']"
column-key="model"
@@ -368,7 +388,7 @@
</template>
</SortableTableHead>
<SortableTableHead
v-if="isAdmin"
v-if="isAdmin && isColumnVisible('provider')"
class="h-12 font-semibold w-[16%]"
column-key="provider"
:sortable="false"
@@ -387,6 +407,7 @@
</template>
</SortableTableHead>
<SortableTableHead
v-if="isColumnVisible('api_format')"
class="h-12 font-semibold"
:class="[isAdmin ? 'w-[15%]' : 'w-[14%]']"
column-key="api_format"
@@ -406,6 +427,7 @@
</template>
</SortableTableHead>
<SortableTableHead
v-if="isColumnVisible('status')"
class="h-12 font-semibold w-[10%] text-center"
column-key="status"
:sortable="false"
@@ -424,24 +446,49 @@
/>
</template>
</SortableTableHead>
<TableHead class="h-12 font-semibold w-[10%] text-center">
<TableHead v-if="isColumnVisible('tokens')" class="h-12 font-semibold w-[10%] text-center">
Tokens
</TableHead>
<TableHead class="h-12 font-semibold w-[6%] text-right">
<TableHead v-if="isColumnVisible('cost')" class="h-12 font-semibold w-[6%] text-right">
费用
</TableHead>
<TableHead class="h-12 font-semibold w-[9%] text-right">
<TableHead v-if="isColumnVisible('performance')" class="h-12 font-semibold w-[9%] text-right">
<div class="flex flex-col items-end text-xs gap-0.5">
<span class="whitespace-nowrap">首字/总耗时</span>
<span class="text-muted-foreground font-normal">输出速度</span>
</div>
</TableHead>
<SortableTableHead
v-if="isColumnVisible('client_family')"
class="h-12 font-semibold w-[12%]"
column-key="client_family"
:sortable="false"
:filter-active="filterClientFamily !== '__all__'"
filter-title="筛选客户端"
filter-content-class="w-44 p-1 rounded-2xl border-border bg-card text-foreground shadow-2xl backdrop-blur-xl"
>
客户端
<template #filter="{ close }">
<TableFilterMenu
:model-value="filterClientFamily"
:options="clientFamilyFilterOptions"
@update:model-value="$emit('update:filterClientFamily', $event)"
@select="close"
/>
</template>
</SortableTableHead>
<TableHead v-if="isColumnVisible('client_ip')" class="h-12 font-semibold w-[10%]">
IP 地址
</TableHead>
<TableHead v-if="isColumnVisible('user_agent')" class="h-12 font-semibold w-[13%]">
User-Agent
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-if="records.length === 0">
<TableCell
:colspan="isAdmin ? 9 : 8"
:colspan="visibleColumnCount"
class="text-center py-12 text-muted-foreground"
>
暂无请求记录
@@ -455,7 +502,7 @@
@mousedown="handleRowMouseDown($event, record.id)"
@click="handleRowClick($event, record.id)"
>
<TableCell class="py-4 w-[8%] align-top">
<TableCell v-if="isColumnVisible('time')" class="py-4 w-[8%] align-top">
<div class="flex flex-col gap-0.5 leading-tight">
<span class="text-xs text-foreground tabular-nums whitespace-nowrap">
{{ formatRecordTime(record.created_at) }}
@@ -466,7 +513,7 @@
</div>
</TableCell>
<TableCell
v-if="isAdmin"
v-if="isAdmin && isColumnVisible('user')"
class="py-4 w-[12%] truncate"
:title="record.username || record.user_email || (record.user_id ? `User ${record.user_id}` : '已删除用户')"
>
@@ -485,7 +532,7 @@
</TableCell>
<!-- 用户页面的密钥列 -->
<TableCell
v-if="!isAdmin"
v-if="!isAdmin && isColumnVisible('key')"
class="py-4 w-[17%]"
:title="record.api_key?.name || '-'"
>
@@ -500,6 +547,7 @@
</div>
</TableCell>
<TableCell
v-if="isColumnVisible('model')"
class="font-medium py-4"
:class="[isAdmin ? 'w-[14%]' : 'w-[22%]']"
:title="getModelTooltip(record)"
@@ -531,7 +579,7 @@
>{{ record.model }}</span>
</TableCell>
<TableCell
v-if="isAdmin"
v-if="isAdmin && isColumnVisible('provider')"
class="py-4 w-[16%]"
>
<div class="flex min-w-0 items-center gap-1">
@@ -588,6 +636,7 @@
</div>
</TableCell>
<TableCell
v-if="isColumnVisible('api_format')"
class="py-4"
:class="[isAdmin ? 'w-[15%]' : 'w-[14%]']"
:title="getApiFormatTooltip(record)"
@@ -624,7 +673,7 @@
class="text-muted-foreground text-xs"
>-</span>
</TableCell>
<TableCell class="text-center py-4 w-[10%]">
<TableCell v-if="isColumnVisible('status')" class="text-center py-4 w-[10%]">
<!-- 优先显示请求状态 -->
<Badge
v-if="getDisplayStatus(record) === 'pending'"
@@ -675,7 +724,7 @@
{{ getStreamModeLabel(record) }}
</Badge>
</TableCell>
<TableCell class="py-4 w-[10%]">
<TableCell v-if="isColumnVisible('tokens')" class="py-4 w-[10%]">
<div class="grid w-full min-w-0 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] gap-x-1 text-xs leading-tight tabular-nums">
<span class="justify-self-end whitespace-nowrap text-right">
{{ formatTokens(getRecordEffectiveInputTokens(record)) }}
@@ -709,7 +758,7 @@
</span>
</div>
</TableCell>
<TableCell class="text-right py-4 w-[6%]">
<TableCell v-if="isColumnVisible('cost')" class="text-right py-4 w-[6%]">
<div class="flex flex-col items-end text-xs gap-0.5">
<span class="text-primary font-medium">{{ formatCurrency(record.cost || 0) }}</span>
<span
@@ -720,7 +769,7 @@
</span>
</div>
</TableCell>
<TableCell class="text-right py-4 w-[9%]">
<TableCell v-if="isColumnVisible('performance')" class="text-right py-4 w-[9%]">
<!-- pending/streaming 状态首字与动态总耗时保留在同一行 -->
<div
v-if="getDisplayStatus(record) === 'pending' || getDisplayStatus(record) === 'streaming'"
@@ -753,6 +802,32 @@
class="text-muted-foreground"
>-</span>
</TableCell>
<TableCell
v-if="isColumnVisible('client_family')"
class="py-4 w-[12%] text-xs"
:title="formatClientFamily(record.client_family)"
>
<Badge
variant="outline"
class="w-fit max-w-full border-border/60 text-muted-foreground"
>
<span class="truncate">{{ formatClientFamily(record.client_family) }}</span>
</Badge>
</TableCell>
<TableCell
v-if="isColumnVisible('client_ip')"
class="py-4 w-[10%] text-xs truncate"
:title="record.client_ip || '-'"
>
{{ record.client_ip || '-' }}
</TableCell>
<TableCell
v-if="isColumnVisible('user_agent')"
class="py-4 w-[13%] text-xs truncate"
:title="record.user_agent || '-'"
>
{{ formatUserAgent(record.user_agent) }}
</TableCell>
</TableRow>
</TableBody>
</Table>
@@ -775,7 +850,7 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useDebounceFn } from '@vueuse/core'
import { useDebounceFn, useLocalStorage } from '@vueuse/core'
import {
TableCard,
Badge,
@@ -815,7 +890,8 @@ import {
import { useRowClick } from '@/composables/useRowClick'
import { formatApiFormat } from '@/api/endpoints/types/api-format'
import type { DateRangeParams, UsageRecord } from '../types'
import { TimeRangePicker } from '@/components/common'
import { MultiSelect, TimeRangePicker } from '@/components/common'
import type { MultiSelectOption } from '@/components/common/MultiSelect.vue'
import ElapsedTimeText from './ElapsedTimeText.vue'
import ServerUserSelector from './ServerUserSelector.vue'
@@ -831,6 +907,67 @@ interface FilterOption {
disabled?: boolean
}
type UsageRecordColumnId =
| 'time'
| 'user'
| 'key'
| 'model'
| 'provider'
| 'api_format'
| 'status'
| 'tokens'
| 'cost'
| 'performance'
| 'client_family'
| 'client_ip'
| 'user_agent'
interface UsageRecordColumnOption {
id: UsageRecordColumnId
label: string
adminOnly?: boolean
userOnly?: boolean
}
const USAGE_RECORD_COLUMN_OPTIONS: UsageRecordColumnOption[] = [
{ id: 'time', label: '时间' },
{ id: 'user', label: '用户', adminOnly: true },
{ id: 'key', label: '密钥', userOnly: true },
{ id: 'model', label: '模型' },
{ id: 'provider', label: '提供商', adminOnly: true },
{ id: 'api_format', label: 'API格式' },
{ id: 'status', label: '类型/状态' },
{ id: 'tokens', label: 'Tokens' },
{ id: 'cost', label: '费用' },
{ id: 'performance', label: '耗时/速度' },
{ id: 'client_family', label: '客户端类型' },
{ id: 'client_ip', label: 'IP 地址' },
{ id: 'user_agent', label: 'User-Agent' },
]
const DEFAULT_ADMIN_COLUMNS: UsageRecordColumnId[] = [
'time',
'user',
'model',
'provider',
'api_format',
'status',
'tokens',
'cost',
'performance',
]
const DEFAULT_USER_COLUMNS: UsageRecordColumnId[] = [
'time',
'key',
'model',
'api_format',
'status',
'tokens',
'cost',
'performance',
]
const props = defineProps<{
records: UsageRecord[]
isAdmin: boolean
@@ -845,9 +982,11 @@ const props = defineProps<{
filterProvider: string
filterApiFormat: string
filterStatus: string
filterClientFamily: string
availableUsers: UserOption[]
availableModels: string[]
availableProviders: string[]
availableClientFamilies: string[]
// 分页
currentPage: number
pageSize: number
@@ -865,6 +1004,7 @@ const emit = defineEmits<{
'update:filterProvider': [value: string]
'update:filterApiFormat': [value: string]
'update:filterStatus': [value: string]
'update:filterClientFamily': [value: string]
'update:currentPage': [value: number]
'update:pageSize': [value: number]
'update:autoRefresh': [value: boolean]
@@ -888,6 +1028,74 @@ const AVAILABLE_API_FORMATS = [
// 使用模块级常量
const availableApiFormats = AVAILABLE_API_FORMATS
const adminVisibleColumnIds = useLocalStorage<UsageRecordColumnId[]>(
'usage-records-visible-columns-admin',
DEFAULT_ADMIN_COLUMNS,
)
const userVisibleColumnIds = useLocalStorage<UsageRecordColumnId[]>(
'usage-records-visible-columns-user',
DEFAULT_USER_COLUMNS,
)
const roleColumnOptions = computed(() => USAGE_RECORD_COLUMN_OPTIONS.filter((column) => {
if (column.adminOnly && !props.isAdmin) return false
if (column.userOnly && props.isAdmin) return false
return true
}))
const roleColumnIds = computed(() => new Set(roleColumnOptions.value.map(column => column.id)))
function sanitizeColumnIds(
ids: readonly string[],
fallback: readonly UsageRecordColumnId[],
): UsageRecordColumnId[] {
const seen = new Set<UsageRecordColumnId>()
const sanitized = ids.filter((id): id is UsageRecordColumnId => {
if (!roleColumnIds.value.has(id as UsageRecordColumnId)) return false
if (seen.has(id as UsageRecordColumnId)) return false
seen.add(id as UsageRecordColumnId)
return true
})
return sanitized.length > 0 ? sanitized : [...fallback]
}
const visibleColumnIds = computed<UsageRecordColumnId[]>({
get: () => sanitizeColumnIds(
props.isAdmin ? adminVisibleColumnIds.value : userVisibleColumnIds.value,
props.isAdmin ? DEFAULT_ADMIN_COLUMNS : DEFAULT_USER_COLUMNS,
),
set: (value) => {
const sanitized = sanitizeColumnIds(value, props.isAdmin ? DEFAULT_ADMIN_COLUMNS : DEFAULT_USER_COLUMNS)
if (props.isAdmin) {
adminVisibleColumnIds.value = sanitized
} else {
userVisibleColumnIds.value = sanitized
}
},
})
const visibleColumnSet = computed(() => new Set<UsageRecordColumnId>(visibleColumnIds.value))
const visibleColumnCount = computed(() => visibleColumnIds.value.length)
const desktopTableMinWidthClass = computed(() => {
const metadataColumnCount = visibleColumnIds.value.filter(column => (
column === 'client_family' ||
column === 'client_ip' ||
column === 'user_agent'
)).length
if (metadataColumnCount >= 3) return 'min-w-[1520px]'
if (metadataColumnCount > 0) return 'min-w-[1320px]'
return props.isAdmin ? 'min-w-[1120px]' : 'min-w-[960px]'
})
const columnSelectOptions = computed<MultiSelectOption[]>(() => roleColumnOptions.value.map(column => ({
value: column.id,
label: column.label,
})))
function isColumnVisible(column: UsageRecordColumnId): boolean {
return visibleColumnSet.value.has(column)
}
const modelFilterOptions = computed<FilterOption[]>(() => [
{ value: '__all__', label: '全部模型' },
...props.availableModels.map((model) => ({
@@ -904,6 +1112,34 @@ const providerFilterOptions = computed<FilterOption[]>(() => [
})),
])
function formatClientFamily(value: string | null | undefined): string {
const normalized = value?.trim().toLowerCase()
if (!normalized) return '-'
if (normalized === 'codex') return 'Codex'
if (normalized === 'codex_vscode') return 'Codex VS Code'
if (normalized === 'claude_code') return 'Claude Code'
if (normalized === 'opencode') return 'OpenCode'
if (normalized === 'gemini_cli') return 'Gemini CLI'
if (normalized === 'openai_js_sdk') return 'OpenAI JS SDK'
if (normalized === 'generic') return '通用客户端'
return value?.trim() || '-'
}
const clientFamilyFilterOptions = computed<FilterOption[]>(() => {
const families = new Set<string>(props.availableClientFamilies)
props.records.forEach((record) => {
const family = record.client_family?.trim()
if (family) families.add(family)
})
return [
{ value: '__all__', label: '全部客户端' },
...Array.from(families).sort().map((family) => ({
value: family,
label: formatClientFamily(family),
})),
]
})
const apiFormatFilterOptions = computed<FilterOption[]>(() => [
{ value: '__all__', label: '全部格式' },
...availableApiFormats.map((format) => ({
@@ -1055,6 +1291,12 @@ function formatOutputRateTokensPerSecond(outputRate: number | null | undefined):
return `${value} tokens/s`
}
function formatUserAgent(value: string | null | undefined): string {
const userAgent = value?.trim()
if (!userAgent) return '-'
return userAgent.length > 48 ? `${userAgent.slice(0, 45)}...` : userAgent
}
// useDebounceFn 自动处理清理,无需 onUnmounted
// 判断是否应该显示格式转换信息

View File

@@ -55,6 +55,12 @@ vi.mock('@/components/common', async () => {
const { defineComponent, h } = await import('vue')
return {
MultiSelect: defineComponent({
name: 'MultiSelectStub',
setup() {
return () => h('div')
},
}),
TimeRangePicker: defineComponent({
name: 'TimeRangePickerStub',
setup() {
@@ -135,9 +141,11 @@ function mountUsageRecordsTable(records: UsageRecord[], overrides: Record<string
filterProvider: '__all__',
filterApiFormat: '__all__',
filterStatus: '__all__',
filterClientFamily: '__all__',
availableUsers: [],
availableModels: [],
availableProviders: [],
availableClientFamilies: [],
currentPage: 1,
pageSize: 20,
totalRecords: records.length,

View File

@@ -30,6 +30,7 @@ export interface FilterParams {
provider?: string
api_format?: string
status?: string
client_family?: string
}
function isUsageProviderVisible(provider: string | undefined | null): provider is string {
@@ -371,6 +372,9 @@ export function useUsageData(options: UseUsageDataOptions) {
if (filters?.status) {
params.status = filters.status
}
if (filters?.client_family) {
params.client_family = filters.client_family
}
const response = await usageApi.getAllUsageRecords(params)
if (requestId !== loadRecordsRequestId) {

View File

@@ -106,12 +106,17 @@ export interface UsageRecord {
total_tokens: number
cost: number
actual_cost?: number
response_time_ms?: number
first_byte_time_ms?: number // 首字时间 (TTFB)
response_time_ms?: number | null
first_byte_time_ms?: number | null // 首字时间 (TTFB)
is_stream: boolean
upstream_is_stream?: boolean
client_requested_stream?: boolean
client_is_stream?: boolean
client_family?: string | null
client_ip?: string | null
user_agent?: string | null
request_path?: string | null
request_path_and_query?: string | null
status_code?: number
error_message?: string
status?: RequestStatus // 请求状态: pending, streaming, completed, failed
@@ -142,7 +147,8 @@ export type FilterStatusValue =
'active' |
'failed' |
'cancelled' |
'has_fallback'
'has_fallback' |
'has_retry'
// 默认统计状态
export function createDefaultStats(): UsageStatsState {

View File

@@ -339,6 +339,43 @@
<RouterView />
<Dialog
v-model="requiredAnnouncementOpen"
persistent
size="lg"
title="必读公告"
description="请确认后继续使用"
>
<div
v-if="currentRequiredAnnouncement"
class="space-y-4"
>
<div>
<h3 class="text-lg font-semibold text-foreground">
{{ currentRequiredAnnouncement.title }}
</h3>
<p class="mt-1 text-xs text-muted-foreground">
{{ formatRequiredAnnouncementDate(currentRequiredAnnouncement.created_at) }}
</p>
</div>
<!-- eslint-disable vue/no-v-html -->
<div
class="prose prose-sm dark:prose-invert max-h-[50vh] max-w-none overflow-y-auto"
v-html="renderRequiredAnnouncement(currentRequiredAnnouncement.content)"
/>
<!-- eslint-enable vue/no-v-html -->
</div>
<template #footer>
<Button
type="button"
:disabled="acknowledgingRequiredAnnouncement"
@click="acknowledgeRequiredAnnouncement"
>
{{ acknowledgingRequiredAnnouncement ? '确认中...' : '确认已读' }}
</Button>
</template>
</Dialog>
<!-- 更新提示弹窗 -->
<UpdateDialog
v-if="updateInfo"
@@ -355,13 +392,16 @@
<script setup lang="ts">
import { computed, ref, watch, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { marked } from 'marked'
import { useAuthStore } from '@/stores/auth'
import { useModuleStore } from '@/stores/modules'
import { useDarkMode } from '@/composables/useDarkMode'
import { useSiteInfo } from '@/composables/useSiteInfo'
import { isDemoMode } from '@/config/demo'
import { adminApi, type CheckUpdateResponse } from '@/api/admin'
import { announcementApi, type Announcement } from '@/api/announcements'
import Button from '@/components/ui/button.vue'
import { Dialog } from '@/components/ui'
import AppShell from '@/components/layout/AppShell.vue'
import SidebarNav from '@/components/layout/SidebarNav.vue'
import HeaderLogo from '@/components/HeaderLogo.vue'
@@ -393,6 +433,7 @@ import {
Wallet,
CreditCard,
Package,
Gift,
Menu,
X,
Puzzle,
@@ -406,6 +447,7 @@ import {
import GithubIcon from '@/components/icons/GithubIcon.vue'
import { BUILTIN_TOOL_BREADCRUMBS } from '@/config/builtin-tools'
import { prefetchAdminNavigationTarget } from '@/utils/adminNavigationPrefetch'
import { sanitizeMarkdown } from '@/utils/sanitize'
const router = useRouter()
const route = useRoute()
@@ -418,6 +460,15 @@ const isAdmin = computed(() => authStore.user?.role === 'admin')
const showAuthError = ref(false)
const mobileMenuOpen = ref(false)
const requiredAnnouncements = ref<Announcement[]>([])
const acknowledgingRequiredAnnouncement = ref(false)
const requiredAnnouncementOpen = computed({
get: () => requiredAnnouncements.value.length > 0,
set: (value) => {
if (value) void loadRequiredAnnouncements()
}
})
const currentRequiredAnnouncement = computed(() => requiredAnnouncements.value[0] ?? null)
// 更新检查相关
const showUpdateDialog = ref(false)
@@ -559,10 +610,45 @@ watch(
() => [authStore.user, authStore.token] as const,
() => {
showAuthError.value = !!authStore.user && !authStore.token
if (authStore.user && authStore.token) {
void loadRequiredAnnouncements()
} else {
requiredAnnouncements.value = []
}
},
{ immediate: true }
)
async function loadRequiredAnnouncements() {
if (!authStore.user || !authStore.token) return
try {
const response = await announcementApi.getRequiredUnreadAnnouncements()
requiredAnnouncements.value = response.items.filter(item => item.requires_ack && !item.is_read)
} catch {
requiredAnnouncements.value = []
}
}
function renderRequiredAnnouncement(content: string): string {
return sanitizeMarkdown(marked(content || '') as string)
}
function formatRequiredAnnouncementDate(value: string): string {
return new Date(value).toLocaleString('zh-CN')
}
async function acknowledgeRequiredAnnouncement() {
const announcement = currentRequiredAnnouncement.value
if (!announcement) return
acknowledgingRequiredAnnouncement.value = true
try {
await announcementApi.markAsRead(announcement.id)
requiredAnnouncements.value = requiredAnnouncements.value.slice(1)
} finally {
acknowledgingRequiredAnnouncement.value = false
}
}
onMounted(() => {
window.addEventListener('storage', handleStorageChange)
document.addEventListener('visibilitychange', handleVisibilityChange)
@@ -573,6 +659,7 @@ onMounted(() => {
moduleStore.fetchModules()
}
void loadVersionStatus()
void loadRequiredAnnouncements()
// 延迟检查更新,避免影响页面加载
setTimeout(() => {
@@ -640,6 +727,7 @@ const navigation = computed(() => {
items: [
{ name: '钱包中心', href: '/dashboard/wallet', icon: Wallet },
{ name: '套餐中心', href: '/dashboard/billing', icon: Package },
{ name: '我的邀请', href: '/dashboard/referral', icon: Gift },
{ name: '使用统计', href: '/dashboard/usage', icon: BarChart3 },
]
}
@@ -702,6 +790,7 @@ const navigation = computed(() => {
{ name: '钱包管理', href: '/admin/wallets', icon: Wallet },
{ name: '支付配置', href: '/admin/payment-gateways', icon: CreditCard },
{ name: '套餐管理', href: '/admin/billing-plans', icon: Package },
{ name: '邀请返利', href: '/admin/referrals', icon: Gift },
{ name: '异步任务', href: '/admin/async-tasks', icon: Zap },
{ name: '使用记录', href: '/admin/usage', icon: BarChart3 },
]

View File

@@ -18,6 +18,18 @@ const routes: RouteRecordRaw[] = [
component: () => importWithRetry(() => import('@/views/public/Home.vue')),
meta: { requiresAuth: false }
},
{
path: '/register',
name: 'RegisterEntry',
component: () => importWithRetry(() => import('@/views/public/Home.vue')),
meta: { requiresAuth: false }
},
{
path: '/privacy-policy',
name: 'PrivacyPolicy',
component: () => importWithRetry(() => import('@/views/public/PrivacyPolicy.vue')),
meta: { requiresAuth: false }
},
{
path: '/guide',
@@ -132,6 +144,11 @@ const routes: RouteRecordRaw[] = [
name: 'BillingPlans',
component: () => importWithRetry(() => import('@/views/user/BillingPlans.vue'))
},
{
path: 'referral',
name: 'ReferralCenter',
component: () => importWithRetry(() => import('@/views/user/ReferralCenter.vue'))
},
{
path: 'models',
name: 'ModelCatalog',
@@ -179,6 +196,11 @@ const routes: RouteRecordRaw[] = [
name: 'BillingPlansManagement',
component: () => importWithRetry(() => import('@/views/admin/BillingPlansManagement.vue'))
},
{
path: 'referrals',
name: 'ReferralManagement',
component: () => importWithRetry(() => import('@/views/admin/ReferralManagement.vue'))
},
{
path: 'management-tokens',
name: 'AdminManagementTokens',

View File

@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest'
import {
getProviderMaskedSecretLabel,
shouldShowOAuthRefreshControl,
} from '@/utils/providerKeyAuth'
describe('providerKeyAuth', () => {
it('renders Grok OAuth-managed cookies as sessions without OAuth refresh controls', () => {
const key = {
auth_type: 'oauth',
oauth_managed: true,
can_refresh_oauth: false,
}
expect(getProviderMaskedSecretLabel(key, 'grok')).toBe('[Session Cookie]')
expect(shouldShowOAuthRefreshControl(key, 'grok')).toBe(false)
})
it('keeps standard OAuth providers on OAuth token semantics', () => {
const key = {
auth_type: 'oauth',
oauth_managed: true,
can_refresh_oauth: false,
}
expect(getProviderMaskedSecretLabel(key, 'codex')).toBe('[OAuth Token]')
expect(shouldShowOAuthRefreshControl(key, 'codex')).toBe(true)
})
})

View File

@@ -39,4 +39,69 @@ describe('providerKeyQuota', () => {
},
}, 'codex')).toBe('周剩余 90.0% | 5H剩余 80.0% | Spark5H剩余 60.0% | Spark周剩余 95.0%')
})
it('formats Grok account quota from structured quota windows', () => {
expect(getQuotaDisplayText({
status_snapshot: {
oauth: {
code: 'valid',
},
account: {
code: 'ok',
blocked: false,
},
quota: {
provider_type: 'grok',
code: 'ok',
exhausted: false,
windows: [
{
scope: 'account',
used_value: 2,
limit_value: 10,
remaining_ratio: 0.8,
},
],
},
},
}, 'grok')).toBe('剩余 80.0% (8/10)')
})
it('formats Grok mode quota from model-scoped windows', () => {
expect(getQuotaDisplayText({
status_snapshot: {
oauth: {
code: 'valid',
},
account: {
code: 'ok',
blocked: false,
},
quota: {
provider_type: 'grok',
code: 'ok',
exhausted: false,
plan_type: 'heavy',
windows: [
{
code: 'model:quota_auto',
label: 'auto',
scope: 'model',
remaining_ratio: 0.4,
used_value: 90,
limit_value: 150,
},
{
code: 'model:quota_heavy',
label: 'heavy',
scope: 'model',
remaining_ratio: 0,
used_value: 20,
limit_value: 20,
},
],
},
},
}, 'grok')).toBe('Auto剩余 40.0% (60/150) | Heavy剩余 0.0% (0/20)')
})
})

View File

@@ -4,11 +4,15 @@ export const OAUTH_ICONS: Record<string, string> = {
github: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>`,
google: `<svg viewBox="0 0 24 24"><path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/><path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/><path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/><path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/></svg>`,
gemini_cli: `<svg viewBox="0 0 24 24"><path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/><path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/><path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/><path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/></svg>`,
grok: `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><circle cx="12" cy="12" r="10" fill="#111827"/><path d="M7 7L17 17M17 7L7 17" stroke="#FFFFFF" stroke-width="2" stroke-linecap="round"/></svg>`,
}
// Default icon when provider type is not found
const DEFAULT_ICON = OAUTH_ICONS.github
export function getOAuthIcon(providerType: string): string {
return OAUTH_ICONS[providerType.toLowerCase()] || DEFAULT_ICON
export function getOAuthIcon(providerType: string, iconUrl?: string | null): string {
const builtin = OAUTH_ICONS[providerType.toLowerCase()]
if (builtin) return builtin
if (iconUrl) return `<img src="${iconUrl}" alt="" style="width:100%;height:100%;object-fit:contain;" />`
return DEFAULT_ICON
}

View File

@@ -8,6 +8,9 @@ const PLAN_TYPE_LABELS: Record<string, string> = {
'pro+': 'Pro+',
power: 'Power',
ultra: 'Ultra',
basic: 'Basic',
super: 'Super',
heavy: 'Heavy',
}
const PLAN_TYPE_CLASS_NAMES: Record<string, string> = {
@@ -20,6 +23,9 @@ const PLAN_TYPE_CLASS_NAMES: Record<string, string> = {
ultra: 'border-amber-500/50 text-amber-600 dark:text-amber-400',
'pro+': 'border-purple-500/50 text-purple-600 dark:text-purple-400',
power: 'border-amber-500/50 text-amber-600 dark:text-amber-400',
basic: 'border-primary/50 text-primary',
super: 'border-green-500/50 text-green-600 dark:text-green-400',
heavy: 'border-amber-500/50 text-amber-600 dark:text-amber-400',
}
export function normalizeOAuthPlanType(planType?: string | null): string | null {

View File

@@ -1,4 +1,5 @@
export interface ProviderKeyAuthCarrier {
provider_type?: string | null
auth_type?: string | null
credential_kind?: string | null
runtime_auth_kind?: string | null
@@ -15,6 +16,14 @@ function normalizeText(value: unknown): string | null {
return text || null
}
function resolveProviderType(input: ProviderKeyAuthCarrier, providerType?: string | null): string | null {
return normalizeText(providerType) ?? normalizeText(input.provider_type)
}
function isGrokSessionCredential(input: ProviderKeyAuthCarrier, providerType?: string | null): boolean {
return resolveProviderType(input, providerType) === 'grok' && isOAuthManagedCredential(input)
}
export function getProviderCredentialKind(
input: ProviderKeyAuthCarrier,
): 'raw_secret' | 'oauth_session' | 'service_account' {
@@ -78,7 +87,11 @@ export function canRefreshOAuthCredential(input: ProviderKeyAuthCarrier): boolea
return isOAuthManagedCredential(input)
}
export function shouldShowOAuthRefreshControl(input: ProviderKeyAuthCarrier): boolean {
export function shouldShowOAuthRefreshControl(
input: ProviderKeyAuthCarrier,
providerType?: string | null,
): boolean {
if (isGrokSessionCredential(input, providerType)) return false
return isOAuthManagedCredential(input)
}
@@ -103,7 +116,11 @@ export function getProviderAuthLabel(input: ProviderKeyAuthCarrier): string {
return getProviderRuntimeAuthKind(input) === 'bearer' ? 'Bearer' : 'API Key'
}
export function getProviderMaskedSecretLabel(input: ProviderKeyAuthCarrier): string {
export function getProviderMaskedSecretLabel(
input: ProviderKeyAuthCarrier,
providerType?: string | null,
): string {
if (isGrokSessionCredential(input, providerType)) return '[Session Cookie]'
if (isOAuthManagedCredential(input)) return '[OAuth Token]'
if (isServiceAccountCredential(input)) return '[Service Account]'
if (getProviderRuntimeAuthKind(input) === 'mixed') return '[Key]'

View File

@@ -94,6 +94,37 @@ function formatQuotaValue(value: number | null | undefined): string {
return normalized.toFixed(1)
}
function getQuotaWindowValueText(window: QuotaWindowSnapshot | null | undefined): string | null {
if (!window || typeof window.limit_value !== 'number' || window.limit_value <= 0) return null
if (typeof window.remaining_value === 'number') {
return `${formatQuotaValue(window.remaining_value)}/${formatQuotaValue(window.limit_value)}`
}
if (typeof window.used_value === 'number') {
return `${formatQuotaValue(Math.max(window.limit_value - window.used_value, 0))}/${formatQuotaValue(window.limit_value)}`
}
return null
}
const GROK_QUOTA_MODE_LABELS: Record<string, string> = {
quota_auto: 'Auto',
auto: 'Auto',
quota_fast: 'Fast',
fast: 'Fast',
quota_expert: 'Expert',
expert: 'Expert',
quota_heavy: 'Heavy',
heavy: 'Heavy',
quota_grok_4_3: 'Grok 4.3',
'grok-420-computer-use-sa': 'Grok 4.3',
}
function getGrokQuotaWindowLabel(window: QuotaWindowSnapshot): string {
const rawCode = normalizeText(window.code)?.replace(/^model:/i, '') || ''
const rawLabel = normalizeText(window.label) || normalizeText(window.model) || rawCode
const normalized = (rawLabel || rawCode).trim().toLowerCase()
return GROK_QUOTA_MODE_LABELS[normalized] || GROK_QUOTA_MODE_LABELS[rawCode.toLowerCase()] || rawLabel || rawCode || '模式'
}
function getCodexQuotaText(quota: QuotaStatusSnapshot): string | null {
const parts: string[] = []
for (const [label, code] of [
@@ -142,6 +173,47 @@ function getKiroQuotaText(quota: QuotaStatusSnapshot): string | null {
return normalizeText(quota.label)
}
function getGrokQuotaText(quota: QuotaStatusSnapshot): string | null {
const code = normalizeText(quota.code)?.toLowerCase()
if (code === 'banned') {
return normalizeText(quota.label) || '账号已封禁'
}
if (code === 'forbidden') {
return normalizeText(quota.label) || '访问受限'
}
const modelWindows = getQuotaWindowsByScope(quota, 'model')
const modelParts = modelWindows
.map((window) => {
const remainingPercent = getQuotaWindowRemainingPercent(window)
if (remainingPercent == null) return null
const valueText = getQuotaWindowValueText(window)
return `${getGrokQuotaWindowLabel(window)}剩余 ${formatPercent(remainingPercent)}${valueText ? ` (${valueText})` : ''}`
})
.filter((value): value is string => value != null)
if (modelParts.length > 0) return modelParts.join(' | ')
const window = getQuotaWindow(quota, 'usage') ?? getQuotaWindowsByScope(quota, 'account')[0] ?? null
const remainingPercent = getQuotaWindowRemainingPercent(window)
if (typeof window?.remaining_value === 'number' && typeof window.limit_value === 'number' && window.limit_value > 0 && window.remaining_value <= 0) {
return `剩余 ${formatQuotaValue(window.remaining_value)}/${formatQuotaValue(window.limit_value)}`
}
if (remainingPercent != null) {
const valueText = getQuotaWindowValueText(window)
if (valueText) {
return `剩余 ${formatPercent(remainingPercent)} (${valueText})`
}
return `剩余 ${formatPercent(remainingPercent)}`
}
if (typeof window?.remaining_value === 'number' && typeof window.limit_value === 'number' && window.limit_value > 0) {
return `剩余 ${formatQuotaValue(window.remaining_value)}/${formatQuotaValue(window.limit_value)}`
}
return normalizeText(quota.label)
}
function getAntigravityQuotaText(quota: QuotaStatusSnapshot): string | null {
const code = normalizeText(quota.code)?.toLowerCase()
if (code === 'forbidden') {
@@ -238,6 +310,8 @@ export function getQuotaSnapshotFallbackText(
return getCodexQuotaText(quota)
case 'kiro':
return getKiroQuotaText(quota)
case 'grok':
return getGrokQuotaText(quota)
case 'antigravity':
return getAntigravityQuotaText(quota)
case 'gemini_cli':

View File

@@ -272,6 +272,8 @@ function formatClientFamily(family?: string | null): string {
return 'OpenCode'
case 'claude_code':
return 'Claude Code'
case 'openai_js_sdk':
return 'OpenAI JS SDK'
case 'generic':
return '通用'
case undefined:

View File

@@ -64,6 +64,35 @@
/>
</div>
<LeaderboardTable
title="API Key 用量排行"
:items="apiKeyLeaderboard"
:metric="apiKeyLeaderboardMetric"
:loading="apiKeyLeaderboardLoading"
:show-metric-select="false"
@update:metric="apiKeyLeaderboardMetric = $event"
>
<template #actions>
<LeaderboardControls
:metric="apiKeyLeaderboardMetric"
:time-range="apiKeyLeaderboardTimeRange"
@update:metric="apiKeyLeaderboardMetric = $event"
@update:time-range="apiKeyLeaderboardTimeRange = $event"
/>
</template>
<template #pagination>
<Pagination
v-if="apiKeyLeaderboardTotal > 0"
:current="apiKeyLeaderboardPage"
:total="apiKeyLeaderboardTotal"
:page-size="apiKeyLeaderboardPageSize"
:page-size-options="apiKeyLeaderboardPageSizeOptions"
@update:current="apiKeyLeaderboardPage = $event"
@update:page-size="apiKeyLeaderboardPageSize = $event"
/>
</template>
</LeaderboardTable>
<UsageProviderTable
:data="providerStats"
:is-admin="true"
@@ -74,10 +103,11 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import Card from '@/components/ui/card.vue'
import { Pagination } from '@/components/ui'
import { TimeRangePicker } from '@/components/common'
import { CostForecastChart, QuotaProgressCard } from '@/components/stats'
import { CostForecastChart, LeaderboardControls, LeaderboardTable, QuotaProgressCard } from '@/components/stats'
import { UsageProviderTable } from '@/features/usage/components'
import { adminApi, type CostForecastResponse, type CostSavingsResponse, type QuotaUsageProvider } from '@/api/admin'
import { adminApi, type CostForecastResponse, type CostSavingsResponse, type LeaderboardItem, type QuotaUsageProvider } from '@/api/admin'
import { usageApi } from '@/api/usage'
import { formatCurrency, formatTokens } from '@/utils/format'
import { getDateRangeFromPeriod } from '@/features/usage/composables'
@@ -90,16 +120,26 @@ const forecast = ref<CostForecastResponse | null>(null)
const costSavings = ref<CostSavingsResponse | null>(null)
const quotaProviders = ref<QuotaUsageProvider[]>([])
const providerStats = ref<ProviderStatsItem[]>([])
const apiKeyLeaderboard = ref<LeaderboardItem[]>([])
const apiKeyLeaderboardMetric = ref<'requests' | 'tokens' | 'cost'>('cost')
const apiKeyLeaderboardTimeRange = ref<DateRangeParams>(getDateRangeFromPeriod('last30days'))
const apiKeyLeaderboardPage = ref(1)
const apiKeyLeaderboardPageSize = ref(10)
const apiKeyLeaderboardTotal = ref(0)
const apiKeyLeaderboardPageSizeOptions = [10, 20, 50, 100]
const forecastLoading = ref(false)
const quotaLoading = ref(false)
const apiKeyLeaderboardLoading = ref(false)
let forecastRequestId = 0
let savingsRequestId = 0
let quotaRequestId = 0
let providerStatsRequestId = 0
let apiKeyLeaderboardRequestId = 0
let loadAllPromise: Promise<void> | null = null
let hasPendingLoadAll = false
let loadAllDebounceTimer: ReturnType<typeof setTimeout> | null = null
let apiKeyLeaderboardDebounceTimer: ReturnType<typeof setTimeout> | null = null
const forecastHistory = computed(() => forecast.value?.history || [])
const forecastFuture = computed(() => forecast.value?.forecast || [])
@@ -159,12 +199,55 @@ async function loadProviderStats() {
providerStats.value = stats
}
async function loadApiKeyLeaderboard() {
const requestId = ++apiKeyLeaderboardRequestId
apiKeyLeaderboardLoading.value = true
try {
const response = await adminApi.getLeaderboardApiKeys({
...buildApiKeyLeaderboardTimeRangeParams(),
metric: apiKeyLeaderboardMetric.value,
order: 'desc',
limit: apiKeyLeaderboardPageSize.value,
offset: (apiKeyLeaderboardPage.value - 1) * apiKeyLeaderboardPageSize.value,
include_inactive: false,
exclude_admin: false
})
if (requestId !== apiKeyLeaderboardRequestId) return
apiKeyLeaderboard.value = response.items
apiKeyLeaderboardTotal.value = response.total
if (response.items.length === 0 && response.total > 0 && apiKeyLeaderboardPage.value > 1) {
apiKeyLeaderboardPage.value = 1
scheduleApiKeyLeaderboardLoad()
}
} finally {
if (requestId === apiKeyLeaderboardRequestId) {
apiKeyLeaderboardLoading.value = false
}
}
}
function buildApiKeyLeaderboardTimeRangeParams() {
return {
start_date: apiKeyLeaderboardTimeRange.value.start_date,
end_date: apiKeyLeaderboardTimeRange.value.end_date,
preset: apiKeyLeaderboardTimeRange.value.preset,
timezone: apiKeyLeaderboardTimeRange.value.timezone,
tz_offset_minutes: apiKeyLeaderboardTimeRange.value.tz_offset_minutes
}
}
async function loadAll() {
if (loadAllPromise) {
hasPendingLoadAll = true
return loadAllPromise
}
loadAllPromise = Promise.all([loadForecast(), loadSavings(), loadQuotaUsage(), loadProviderStats()])
loadAllPromise = Promise.all([
loadForecast(),
loadSavings(),
loadQuotaUsage(),
loadProviderStats(),
loadApiKeyLeaderboard()
])
.then(() => undefined)
.finally(() => {
loadAllPromise = null
@@ -186,7 +269,36 @@ function scheduleLoadAll() {
}, 120)
}
watch(timeRange, scheduleLoadAll, { deep: true })
function scheduleApiKeyLeaderboardLoad() {
if (apiKeyLeaderboardDebounceTimer) {
clearTimeout(apiKeyLeaderboardDebounceTimer)
}
apiKeyLeaderboardDebounceTimer = setTimeout(() => {
apiKeyLeaderboardDebounceTimer = null
void loadApiKeyLeaderboard()
}, 120)
}
function resetApiKeyLeaderboardPage() {
if (apiKeyLeaderboardPage.value === 1) {
return
}
apiKeyLeaderboardPage.value = 1
}
watch(timeRange, () => {
resetApiKeyLeaderboardPage()
scheduleLoadAll()
}, { deep: true })
watch(apiKeyLeaderboardMetric, () => {
resetApiKeyLeaderboardPage()
scheduleApiKeyLeaderboardLoad()
})
watch(apiKeyLeaderboardTimeRange, () => {
resetApiKeyLeaderboardPage()
scheduleApiKeyLeaderboardLoad()
}, { deep: true })
watch([apiKeyLeaderboardPage, apiKeyLeaderboardPageSize], scheduleApiKeyLeaderboardLoad)
onMounted(() => {
void loadAll()
@@ -197,11 +309,16 @@ onUnmounted(() => {
clearTimeout(loadAllDebounceTimer)
loadAllDebounceTimer = null
}
if (apiKeyLeaderboardDebounceTimer) {
clearTimeout(apiKeyLeaderboardDebounceTimer)
apiKeyLeaderboardDebounceTimer = null
}
hasPendingLoadAll = false
loadAllPromise = null
forecastRequestId += 1
savingsRequestId += 1
quotaRequestId += 1
providerStatsRequestId += 1
apiKeyLeaderboardRequestId += 1
})
</script>

View File

@@ -10,7 +10,7 @@
黑名单 IP 数量
</p>
<h3 class="text-2xl font-bold mt-2">
{{ blacklistStats.total || 0 }}
{{ blacklistData.total || blacklistStats.total || 0 }}
</h3>
</div>
<div class="h-12 w-12 rounded-full bg-destructive/10 flex items-center justify-center">
@@ -66,7 +66,7 @@
</Button>
<RefreshButton
:loading="loadingBlacklist"
@click="loadBlacklistStats"
@click="loadBlacklist"
/>
</div>
</div>
@@ -85,16 +85,33 @@
>
<div
v-if="!blacklistStats.available"
class="mb-4 rounded-lg border border-destructive/20 bg-destructive/5 px-4 py-3 text-sm text-muted-foreground"
>
<div class="flex items-start gap-3">
<AlertCircle class="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
<div>
<p class="font-medium text-foreground">
黑名单状态不可用列表可能不是最新
</p>
<p class="mt-1 text-xs">
{{ blacklistStats.error }}
</p>
</div>
</div>
</div>
<div
v-if="blacklistListError"
class="text-center py-8 text-muted-foreground"
>
<AlertCircle class="w-12 h-12 mx-auto mb-2 opacity-50" />
<p>Redis 不可用无法管理黑名单</p>
<p>无法获取黑名单列表</p>
<p class="text-xs mt-1">
{{ blacklistStats.error }}
{{ blacklistListError }}
</p>
</div>
<div
v-else-if="blacklistStats.total === 0"
v-else-if="blacklistData.items.length === 0"
class="text-center py-8 text-muted-foreground"
>
<ShieldX class="w-12 h-12 mx-auto mb-2 opacity-50" />
@@ -102,9 +119,79 @@
</div>
<div
v-else
class="text-sm text-muted-foreground"
class="space-y-4"
>
当前共有 <span class="font-semibold text-foreground">{{ blacklistStats.total }}</span> IP 在黑名单中
<div class="text-sm text-muted-foreground">
当前共有 <span class="font-semibold text-foreground">{{ blacklistData.total || blacklistStats.total || 0 }}</span> IP 在黑名单中
</div>
<Table class="hidden sm:table">
<TableHeader>
<TableRow>
<TableHead>IP 地址</TableHead>
<TableHead>原因</TableHead>
<TableHead>剩余时长</TableHead>
<TableHead class="text-right">
操作
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="entry in blacklistData.items"
:key="entry.ip_address"
>
<TableCell class="font-mono text-sm">
{{ entry.ip_address }}
</TableCell>
<TableCell class="max-w-[28rem] truncate">
{{ entry.reason }}
</TableCell>
<TableCell class="whitespace-nowrap">
{{ formatBlacklistTTL(entry.ttl_seconds) }}
</TableCell>
<TableCell class="text-right">
<Button
variant="ghost"
size="sm"
class="h-8 px-3"
@click="handleRemoveFromBlacklist(entry.ip_address)"
>
<Trash2 class="w-4 h-4 mr-1.5" />
移除
</Button>
</TableCell>
</TableRow>
</TableBody>
</Table>
<div class="sm:hidden divide-y divide-border/40">
<div
v-for="entry in blacklistData.items"
:key="entry.ip_address"
class="p-4 flex items-start justify-between gap-3"
>
<div class="min-w-0 space-y-1">
<div class="font-mono text-sm break-all">
{{ entry.ip_address }}
</div>
<div class="text-xs text-muted-foreground leading-5 break-words">
{{ entry.reason }}
</div>
<div class="text-xs text-muted-foreground">
{{ formatBlacklistTTL(entry.ttl_seconds) }}
</div>
</div>
<Button
variant="ghost"
size="sm"
class="h-8 px-3 shrink-0"
@click="handleRemoveFromBlacklist(entry.ip_address)"
>
<Trash2 class="w-4 h-4" />
</Button>
</div>
</div>
</div>
</div>
</Card>
@@ -213,15 +300,17 @@
<!-- 添加黑名单对话框 -->
<Dialog v-model:open="showAddBlacklistDialog">
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>添加 IP 到黑名单</DialogTitle>
<DialogDescription>
<DialogContent class="sm:max-w-md !p-0 overflow-hidden">
<DialogHeader class="!px-4 !py-3">
<DialogTitle class="!text-base">
添加 IP 到黑名单
</DialogTitle>
<DialogDescription class="!mt-1">
被加入黑名单的 IP 将无法访问任何接口
</DialogDescription>
</DialogHeader>
<div class="space-y-4">
<div class="space-y-2">
<div class="space-y-3 px-4 py-4">
<div class="space-y-1.5">
<label class="text-sm font-medium">IP 地址</label>
<Input
v-model="blacklistForm.ip_address"
@@ -229,7 +318,7 @@
class="font-mono"
/>
</div>
<div class="space-y-2">
<div class="space-y-1.5">
<label class="text-sm font-medium">原因</label>
<Input
v-model="blacklistForm.reason"
@@ -237,7 +326,7 @@
maxlength="200"
/>
</div>
<div class="space-y-2">
<div class="space-y-1.5">
<label class="text-sm font-medium">过期时间可选</label>
<Input
v-model.number="blacklistForm.ttl"
@@ -250,7 +339,7 @@
</p>
</div>
</div>
<DialogFooter>
<DialogFooter class="!px-4 !py-3">
<Button
variant="ghost"
@click="showAddBlacklistDialog = false"
@@ -270,27 +359,29 @@
<!-- 添加白名单对话框 -->
<Dialog v-model:open="showAddWhitelistDialog">
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>添加 IP 到白名单</DialogTitle>
<DialogDescription>
<DialogContent class="sm:max-w-md !p-0 overflow-hidden">
<DialogHeader class="!px-4 !py-3">
<DialogTitle class="!text-base">
添加 IP 到白名单
</DialogTitle>
<DialogDescription class="!mt-1">
白名单中的 IP 不受速率限制
</DialogDescription>
</DialogHeader>
<div class="space-y-4">
<div class="space-y-2">
<div class="space-y-3 px-4 py-4">
<div class="space-y-1.5">
<label class="text-sm font-medium">IP 地址或 CIDR</label>
<Input
v-model="whitelistForm.ip_address"
placeholder="例如: 192.168.1.0/24 或 192.168.1.100"
class="font-mono"
/>
<p class="text-xs text-muted-foreground">
<p class="text-xs text-muted-foreground leading-5">
支持单个 IP CIDR 网段格式
</p>
</div>
</div>
<DialogFooter>
<DialogFooter class="!px-4 !py-3">
<Button
variant="ghost"
@click="showAddWhitelistDialog = false"
@@ -330,7 +421,7 @@ import {
TableRow,
RefreshButton
} from '@/components/ui'
import { blacklistApi, whitelistApi, type BlacklistStats, type WhitelistResponse } from '@/api/security'
import { blacklistApi, whitelistApi, type BlacklistStats, type BlacklistResponse, type WhitelistResponse } from '@/api/security'
import { useToast } from '@/composables/useToast'
import { useConfirm } from '@/composables/useConfirm'
import { parseApiError } from '@/utils/errorParser'
@@ -344,6 +435,11 @@ const blacklistStats = ref<BlacklistStats>({
available: false,
total: 0
})
const blacklistData = ref<BlacklistResponse>({
items: [],
total: 0
})
const blacklistListError = ref<string | null>(null)
const showAddBlacklistDialog = ref(false)
const blacklistForm = ref({
ip_address: '',
@@ -363,14 +459,38 @@ const whitelistForm = ref({
})
/**
* 加载黑名单统计
* 加载黑名单统计和列表
*/
async function loadBlacklistStats() {
async function loadBlacklist() {
loadingBlacklist.value = true
blacklistListError.value = null
try {
blacklistStats.value = await blacklistApi.getStats()
const [statsResult, listResult] = await Promise.allSettled([
blacklistApi.getStats(),
blacklistApi.getList()
])
if (statsResult.status === 'fulfilled') {
blacklistStats.value = statsResult.value
} else {
blacklistStats.value = {
available: false,
total: 0,
error: parseApiError(statsResult.reason, '无法获取黑名单统计')
}
}
if (listResult.status === 'fulfilled') {
blacklistData.value = listResult.value
} else {
blacklistData.value = {
items: [],
total: 0
}
blacklistListError.value = parseApiError(listResult.reason, '无法获取黑名单列表')
}
} catch (err: unknown) {
error(parseApiError(err, '无法获取黑名单统计'))
error(parseApiError(err, '无法获取黑名单数据'))
} finally {
loadingBlacklist.value = false
}
@@ -405,7 +525,7 @@ async function handleAddToBlacklist() {
showAddBlacklistDialog.value = false
blacklistForm.value = { ip_address: '', reason: '', ttl: undefined }
await loadBlacklistStats()
await loadBlacklist()
} catch (err: unknown) {
error(parseApiError(err, '无法添加 IP 到黑名单'))
}
@@ -452,8 +572,46 @@ async function handleRemoveFromWhitelist(ip: string) {
}
}
/**
* 从黑名单移除 IP
*/
async function handleRemoveFromBlacklist(ip: string) {
const confirmed = await confirmDanger(
`确定要从黑名单移除 ${ip} 吗?\n\n此操作无法撤销。`,
'移除黑名单'
)
if (!confirmed) return
try {
await blacklistApi.remove(ip)
success(`IP ${ip} 已从黑名单移除`)
await loadBlacklist()
} catch (err: unknown) {
error(parseApiError(err, '无法从黑名单移除 IP'))
}
}
function formatBlacklistTTL(ttlSeconds?: number | null) {
if (ttlSeconds == null) return '永久'
if (ttlSeconds <= 0) return '即将过期'
const days = Math.floor(ttlSeconds / 86400)
if (days > 0) return `${days}`
const hours = Math.floor(ttlSeconds / 3600)
if (hours > 0) return `${hours} 小时`
const minutes = Math.floor(ttlSeconds / 60)
if (minutes > 0) return `${minutes} 分钟`
return `${ttlSeconds}`
}
onMounted(() => {
loadBlacklistStats()
loadBlacklist()
loadWhitelist()
})
</script>

View File

@@ -131,197 +131,209 @@
</div>
</template>
<div class="space-y-6">
<!-- 新建时的 Display Name -->
<div
v-if="selectedType === '__new__'"
class="grid grid-cols-1 md:grid-cols-2 gap-4"
>
<div>
<Label class="block text-sm font-medium">显示名称</Label>
<Input
v-model="form.new_display_name"
class="mt-1"
placeholder="例如My OIDC Provider"
autocomplete="off"
/>
</div>
<div>
<Label class="block text-sm font-medium">配置标识</Label>
<Input
v-model="form.new_provider_type"
class="mt-1"
placeholder="custom_oidc_work"
autocomplete="off"
@blur="normalizeNewProviderType"
/>
</div>
<div class="space-y-6">
<!-- 新建时的 Display Name -->
<div
v-if="selectedType === '__new__'"
class="grid grid-cols-1 md:grid-cols-2 gap-4"
>
<div>
<Label class="block text-sm font-medium">显示名称</Label>
<Input
v-model="form.new_display_name"
class="mt-1"
placeholder="例如My OIDC Provider"
autocomplete="off"
/>
</div>
<!-- 凭证配置 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label class="block text-sm font-medium">Client ID</Label>
<Input
v-model="form.client_id"
class="mt-1"
placeholder="client_id"
autocomplete="off"
/>
</div>
<div>
<Label class="block text-sm font-medium">Client Secret</Label>
<Input
v-model="form.client_secret"
masked
class="mt-1"
:placeholder="hasSecret ? '已设置(留空保持不变)' : '请输入 secret'"
/>
</div>
<div>
<Label class="block text-sm font-medium">配置标识</Label>
<Input
v-model="form.new_provider_type"
class="mt-1"
placeholder="custom_oidc_work"
autocomplete="off"
@blur="normalizeNewProviderType"
/>
</div>
<!-- 回调地址 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label class="block text-sm font-medium">Redirect URI后端回调</Label>
<Input
v-model="form.redirect_uri"
class="mt-1"
placeholder="http://localhost:8084/api/oauth/xxx/callback"
autocomplete="off"
/>
</div>
<div>
<Label class="block text-sm font-medium">前端回调页</Label>
<Input
v-model="form.frontend_callback_url"
class="mt-1"
placeholder="http://localhost:5173/auth/callback"
autocomplete="off"
/>
</div>
</div>
<!-- custom_oidc 必填端点 -->
<div
v-if="isSelectedCustomProvider"
class="grid grid-cols-1 md:grid-cols-3 gap-4"
>
<div>
<Label class="block text-sm font-medium">Authorization URL</Label>
<Input
v-model="form.authorization_url_override"
class="mt-1"
placeholder="https://example.com/oauth/authorize"
autocomplete="off"
/>
</div>
<div>
<Label class="block text-sm font-medium">Token URL</Label>
<Input
v-model="form.token_url_override"
class="mt-1"
placeholder="https://example.com/oauth/token"
autocomplete="off"
/>
</div>
<div>
<Label class="block text-sm font-medium">Userinfo URL</Label>
<Input
v-model="form.userinfo_url_override"
class="mt-1"
placeholder="https://example.com/api/user"
autocomplete="off"
/>
</div>
</div>
<!-- 高级选项折叠 -->
<details class="group">
<summary class="cursor-pointer text-sm font-medium text-muted-foreground hover:text-foreground transition-colors">
高级选项
</summary>
<div class="mt-4 space-y-4 pl-4 border-l-2 border-border">
<div>
<Label class="block text-sm font-medium">Scopes</Label>
<Input
v-model="form.scopes_input"
class="mt-1"
:placeholder="selectedTypeMeta?.default_scopes?.join(' ') || '留空使用默认值'"
autocomplete="off"
/>
<p class="mt-1 text-xs text-muted-foreground">
空格/逗号分隔留空使用默认值
</p>
</div>
<!-- linuxdo 的可选端点覆盖 -->
<div
v-if="!isSelectedCustomProvider"
class="grid grid-cols-1 md:grid-cols-3 gap-4"
>
<div>
<Label class="block text-sm font-medium">Authorization URL</Label>
<Input
v-model="form.authorization_url_override"
class="mt-1"
:placeholder="selectedTypeMeta?.default_authorization_url || '默认'"
autocomplete="off"
/>
</div>
<div>
<Label class="block text-sm font-medium">Token URL</Label>
<Input
v-model="form.token_url_override"
class="mt-1"
:placeholder="selectedTypeMeta?.default_token_url || '默认'"
autocomplete="off"
/>
</div>
<div>
<Label class="block text-sm font-medium">Userinfo URL</Label>
<Input
v-model="form.userinfo_url_override"
class="mt-1"
:placeholder="selectedTypeMeta?.default_userinfo_url || '默认'"
autocomplete="off"
/>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label class="block text-sm font-medium">Attribute Mapping</Label>
<Textarea
v-model="form.attribute_mapping_json"
class="mt-1 font-mono text-xs"
rows="3"
placeholder="{&quot;id&quot;: &quot;user_id&quot;, &quot;username&quot;: &quot;login&quot;}"
/>
</div>
<div>
<Label class="block text-sm font-medium">
{{ isSelectedCustomProvider ? 'Allowed Domains / Extra Config' : 'Extra Config' }}
</Label>
<Textarea
v-model="form.extra_config_json"
class="mt-1 font-mono text-xs"
rows="3"
:placeholder="extraConfigPlaceholder"
/>
<p
v-if="isSelectedCustomProvider"
class="mt-1 text-xs text-muted-foreground"
>
自定义 OIDC 必填填写 Authorization / Token / Userinfo URL 所属域名
</p>
</div>
</div>
</div>
</details>
</div>
<!-- 测试结果 -->
<!-- 凭证配置 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label class="block text-sm font-medium">Client ID</Label>
<Input
v-model="form.client_id"
class="mt-1"
placeholder="client_id"
autocomplete="off"
/>
</div>
<div>
<Label class="block text-sm font-medium">Client Secret</Label>
<Input
v-model="form.client_secret"
masked
class="mt-1"
:placeholder="hasSecret ? '已设置(留空保持不变)' : '请输入 secret'"
/>
</div>
</div>
<!-- 回调地址 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label class="block text-sm font-medium">Redirect URI后端回调</Label>
<Input
v-model="form.redirect_uri"
class="mt-1"
placeholder="http://localhost:8084/api/oauth/xxx/callback"
autocomplete="off"
/>
</div>
<div>
<Label class="block text-sm font-medium">前端回调页</Label>
<Input
v-model="form.frontend_callback_url"
class="mt-1"
placeholder="http://localhost:5173/auth/callback"
autocomplete="off"
/>
</div>
</div>
<!-- custom_oidc 必填端点 -->
<div
v-if="isSelectedCustomProvider"
class="grid grid-cols-1 md:grid-cols-3 gap-4"
>
<div>
<Label class="block text-sm font-medium">Authorization URL</Label>
<Input
v-model="form.authorization_url_override"
class="mt-1"
placeholder="https://example.com/oauth/authorize"
autocomplete="off"
/>
</div>
<div>
<Label class="block text-sm font-medium">Token URL</Label>
<Input
v-model="form.token_url_override"
class="mt-1"
placeholder="https://example.com/oauth/token"
autocomplete="off"
/>
</div>
<div>
<Label class="block text-sm font-medium">Userinfo URL</Label>
<Input
v-model="form.userinfo_url_override"
class="mt-1"
placeholder="https://example.com/api/user"
autocomplete="off"
/>
</div>
</div>
<!-- 图标 URL -->
<div>
<Label class="block text-sm font-medium">图标 URL</Label>
<Input
v-model="form.icon_url"
class="mt-1"
placeholder="https://example.com/icon.svg"
autocomplete="off"
/>
<p class="mt-1 text-xs text-muted-foreground">
登录页显示的 Provider 图标留空使用默认图标
</p>
</div>
<!-- 高级选项折叠 -->
<details class="group">
<summary class="cursor-pointer text-sm font-medium text-muted-foreground hover:text-foreground transition-colors">
高级选项
</summary>
<div class="mt-4 space-y-4 pl-4 border-l-2 border-border">
<div>
<Label class="block text-sm font-medium">Scopes</Label>
<Input
v-model="form.scopes_input"
class="mt-1"
:placeholder="selectedTypeMeta?.default_scopes?.join(' ') || '留空使用默认值'"
autocomplete="off"
/>
<p class="mt-1 text-xs text-muted-foreground">
空格/逗号分隔留空使用默认值
</p>
</div>
<!-- linuxdo 的可选端点覆盖 -->
<div
v-if="!isSelectedCustomProvider"
class="grid grid-cols-1 md:grid-cols-3 gap-4"
>
<div>
<Label class="block text-sm font-medium">Authorization URL</Label>
<Input
v-model="form.authorization_url_override"
class="mt-1"
:placeholder="selectedTypeMeta?.default_authorization_url || '默认'"
autocomplete="off"
/>
</div>
<div>
<Label class="block text-sm font-medium">Token URL</Label>
<Input
v-model="form.token_url_override"
class="mt-1"
:placeholder="selectedTypeMeta?.default_token_url || '默认'"
autocomplete="off"
/>
</div>
<div>
<Label class="block text-sm font-medium">Userinfo URL</Label>
<Input
v-model="form.userinfo_url_override"
class="mt-1"
:placeholder="selectedTypeMeta?.default_userinfo_url || '默认'"
autocomplete="off"
/>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label class="block text-sm font-medium">Attribute Mapping</Label>
<Textarea
v-model="form.attribute_mapping_json"
class="mt-1 font-mono text-xs"
rows="3"
placeholder="{&quot;id&quot;: &quot;user_id&quot;, &quot;username&quot;: &quot;login&quot;}"
/>
</div>
<div>
<Label class="block text-sm font-medium">
{{ isSelectedCustomProvider ? 'Allowed Domains / Extra Config' : 'Extra Config' }}
</Label>
<Textarea
v-model="form.extra_config_json"
class="mt-1 font-mono text-xs"
rows="3"
:placeholder="extraConfigPlaceholder"
/>
<p
v-if="isSelectedCustomProvider"
class="mt-1 text-xs text-muted-foreground"
>
自定义 OIDC 必填填写 Authorization / Token / Userinfo URL 所属域名
</p>
</div>
</div>
</div>
</details>
</div>
<div
v-if="lastTestResult"
class="mt-6 rounded-lg border border-border p-4 text-sm"
@@ -402,6 +414,7 @@ interface OAuthConfigForm {
frontend_callback_url: string
attribute_mapping_json: string
extra_config_json: string
icon_url: string
new_provider_type: string
new_display_name: string
}
@@ -423,6 +436,7 @@ const form = ref<OAuthConfigForm>({
frontend_callback_url: '',
attribute_mapping_json: '',
extra_config_json: '',
icon_url: '',
new_provider_type: '',
new_display_name: '',
})
@@ -589,6 +603,7 @@ function handleClickAdd() {
frontend_callback_url: defaultFrontendCallbackUrl(),
attribute_mapping_json: '',
extra_config_json: '',
icon_url: '',
new_provider_type: providerType,
new_display_name: '',
}
@@ -633,6 +648,7 @@ function syncFormFromSelected() {
frontend_callback_url: cfg?.frontend_callback_url || defaultFrontendCallbackUrl(),
attribute_mapping_json: cfg?.attribute_mapping ? JSON.stringify(cfg.attribute_mapping, null, 2) : '',
extra_config_json: cfg?.extra_config ? JSON.stringify(cfg.extra_config, null, 2) : '',
icon_url: cfg?.icon_url || '',
new_provider_type: '',
new_display_name: '',
}
@@ -658,6 +674,7 @@ async function toggleProviderEnabled(providerType: string, enabled: boolean, for
frontend_callback_url: cfg.frontend_callback_url,
attribute_mapping: cfg.attribute_mapping || null,
extra_config: cfg.extra_config || null,
icon_url: cfg.icon_url || null,
is_enabled: enabled,
force,
}
@@ -732,6 +749,7 @@ async function handleSave() {
frontend_callback_url: form.value.frontend_callback_url.trim(),
attribute_mapping: parseJsonOrNull(form.value.attribute_mapping_json),
extra_config: parseJsonOrNull(form.value.extra_config_json),
icon_url: form.value.icon_url.trim() || null,
is_enabled: existingConfig?.is_enabled || false,
}

View File

@@ -573,7 +573,7 @@
<Copy class="w-2.5 h-2.5" />
</Button>
<span class="font-mono">
{{ getProviderMaskedSecretLabel(key) }}
{{ getProviderMaskedSecretLabel(key, selectedProviderType) }}
</span>
<template v-if="keyUiStateMap[key.key_id]?.showOAuthRefreshControl">
<Button
@@ -603,7 +603,7 @@
</span>
</template>
<Badge
v-if="key.oauth_plan_type"
v-if="keyUiStateMap[key.key_id]?.planLabel"
variant="outline"
class="text-[9px] px-1 py-0 h-4 shrink-0"
:class="keyUiStateMap[key.key_id]?.planClass || ''"
@@ -637,10 +637,11 @@
<div class="flex items-center justify-between text-[10px] leading-none">
<span class="text-muted-foreground font-medium shrink-0">{{ getQuotaProgressLabel(item.label) }}</span>
<span
v-if="getQuotaProgressDisplayText(item)"
v-if="getQuotaProgressResetDisplayText(item)"
data-testid="pool-quota-reset-text"
class="text-muted-foreground/80 tabular-nums truncate"
:title="item.detail"
>{{ getQuotaProgressDisplayText(item) }}</span>
:title="getQuotaProgressResetDisplayText(item)"
>{{ getQuotaProgressResetDisplayText(item) }}</span>
</div>
<div class="flex items-center gap-1.5">
<div class="relative flex-1 h-1.5 rounded-full bg-border overflow-hidden">
@@ -651,9 +652,10 @@
/>
</div>
<span
data-testid="pool-quota-meter-text"
class="shrink-0 text-[10px] font-medium tabular-nums leading-none"
:class="getQuotaRemainingClassByRemaining(item.remainingPercent)"
>{{ item.remainingPercent.toFixed(1) }}%</span>
>{{ getQuotaProgressMeterDisplayText(item) }}</span>
</div>
</div>
</div>
@@ -1153,11 +1155,12 @@
>
<div class="flex items-center justify-between text-[10px] leading-none">
<span class="text-muted-foreground font-medium shrink-0">{{ getQuotaProgressLabel(item.label) }}</span>
<span
v-if="getQuotaProgressDisplayText(item)"
class="text-muted-foreground/80 tabular-nums truncate"
:title="item.detail"
>{{ getQuotaProgressDisplayText(item) }}</span>
<span
v-if="getQuotaProgressResetDisplayText(item)"
data-testid="pool-quota-reset-text"
class="text-muted-foreground/80 tabular-nums truncate"
:title="getQuotaProgressResetDisplayText(item)"
>{{ getQuotaProgressResetDisplayText(item) }}</span>
</div>
<div class="flex items-center gap-1.5">
<div class="relative flex-1 h-1.5 rounded-full bg-border overflow-hidden">
@@ -1168,9 +1171,10 @@
/>
</div>
<span
data-testid="pool-quota-meter-text"
class="shrink-0 text-[10px] font-medium tabular-nums leading-none"
:class="getQuotaRemainingClassByRemaining(item.remainingPercent)"
>{{ item.remainingPercent.toFixed(1) }}%</span>
>{{ getQuotaProgressMeterDisplayText(item) }}</span>
</div>
</div>
</div>
@@ -2073,6 +2077,7 @@ const showAccountQuotaColumn = computed(() => {
|| selectedProviderType.value === 'gemini_cli'
|| selectedProviderType.value === 'kiro'
|| selectedProviderType.value === 'antigravity'
|| selectedProviderType.value === 'grok'
|| selectedProviderType.value === 'chatgpt_web'
})
@@ -2377,8 +2382,9 @@ const keyUiStateMap = computed<Record<string, PoolKeyUiState>>(() => {
const visibleOAuthState = getVisibleOAuthState(key)
const oauthOrgBadge = getOAuthOrgBadge(key)
const quotaFallbackText = getQuotaFallbackText(key)
const planType = resolvePoolKeyPlanType(key)
const canRefreshToken = canRefreshOAuthCredential(key)
const showOAuthRefreshControl = shouldShowOAuthRefreshControl(key)
const showOAuthRefreshControl = shouldShowOAuthRefreshControl(key, selectedProviderType.value)
map[key.key_id] = {
rowClass: getRowClass(key),
@@ -2391,8 +2397,8 @@ const keyUiStateMap = computed<Record<string, PoolKeyUiState>>(() => {
oauthRefreshButtonTitle: showOAuthRefreshControl ? getOAuthRefreshButtonTitle(key) : '',
showOAuthRefreshControl,
canRefreshToken,
planLabel: key.oauth_plan_type ? formatOAuthPlanType(key.oauth_plan_type) : '',
planClass: key.oauth_plan_type ? getOAuthPlanTypeClass(key.oauth_plan_type) : '',
planLabel: planType ? formatOAuthPlanType(planType) : '',
planClass: planType ? getOAuthPlanTypeClass(planType) : '',
quotaFallbackText,
quotaTextClass: quotaFallbackText ? getQuotaTextClass(quotaFallbackText) : '',
importedAtRelative: formatPoolKeyImportedAt(key),
@@ -2470,6 +2476,7 @@ const quotaRefreshSupported = computed(() => {
return selectedProviderType.value === 'codex'
|| selectedProviderType.value === 'kiro'
|| selectedProviderType.value === 'antigravity'
|| selectedProviderType.value === 'grok'
|| selectedProviderType.value === 'chatgpt_web'
})
@@ -2589,7 +2596,12 @@ async function refreshCurrentPageQuotaInBackground(
if (!options.silent) {
const skippedText = skippedCount > 0 ? `,冷却跳过 ${skippedCount}` : ''
success(`当前页额度刷新完成:成功 ${successCount},失败 ${failedCount}${skippedText}`)
const firstFailureMessage = result.results.find(item => item.status !== 'success')?.message?.trim()
if (successCount === 0 && failedCount > 0 && firstFailureMessage) {
showError(`当前页额度刷新失败:${firstFailureMessage}${skippedText}`)
} else {
success(`当前页额度刷新完成:成功 ${successCount},失败 ${failedCount}${skippedText}`)
}
}
return true
} catch (err) {
@@ -2714,7 +2726,7 @@ function toEndpointApiKey(key: PoolKeyDetail): EndpointAPIKey {
id: key.key_id,
provider_id: selectedProviderId.value || '',
api_formats: key.api_formats || [],
api_key_masked: getProviderMaskedSecretLabel(key),
api_key_masked: getProviderMaskedSecretLabel(key, selectedProviderType.value),
auth_type: normalizeAuthTypeForEdit(key),
auth_type_by_format: key.auth_type_by_format ?? null,
credential_kind: key.credential_kind ?? null,
@@ -3524,6 +3536,7 @@ function getMobileTagItems(key: PoolKeyDetail): PoolMobileTagItem[] {
const accountAlert = getAccountAlertLabel(key)
const oauthState = getVisibleOAuthState(key)
const orgBadge = getOAuthOrgBadge(key)
const planType = resolvePoolKeyPlanType(key)
return buildPoolMobileTagItems({
accountStatusLabel: compactPoolStatusLabel(accountAlert),
@@ -3532,7 +3545,7 @@ function getMobileTagItems(key: PoolKeyDetail): PoolMobileTagItem[] {
oauthStatusTone: getMobileOAuthTone(key),
priorityLabel: `P${key.internal_priority ?? 50}`,
authLabel: getAuthTypeChipLabel(key),
planLabel: key.oauth_plan_type ? formatOAuthPlanType(key.oauth_plan_type) : null,
planLabel: planType ? formatOAuthPlanType(planType) : null,
orgLabel: orgBadge?.label ?? null,
proxyLabel: key.proxy?.node_id ? '独立代理' : null,
})
@@ -3565,6 +3578,9 @@ function formatOAuthPlanType(planType: string): string {
ultra: 'Ultra',
'pro+': 'Pro+',
power: 'Power',
basic: 'Basic',
super: 'Super',
heavy: 'Heavy',
}
return labelMap[planType.toLowerCase()] || planType
}
@@ -3580,6 +3596,9 @@ function getOAuthPlanTypeClass(planType: string): string {
ultra: 'border-amber-500/50 text-amber-600 dark:text-amber-400',
'pro+': 'border-purple-500/50 text-purple-600 dark:text-purple-400',
power: 'border-amber-500/50 text-amber-600 dark:text-amber-400',
basic: 'border-primary/50 text-primary',
super: 'border-green-500/50 text-green-600 dark:text-green-400',
heavy: 'border-amber-500/50 text-amber-600 dark:text-amber-400',
}
return classes[planType.toLowerCase()] || ''
}
@@ -3674,7 +3693,7 @@ function getQuotaProgressLabel(label: string): string {
}
function getQuotaProgressCountdown(item: QuotaProgressItem) {
if (!['5H', '周', 'Spark5H', 'Spark周'].includes(item.label)) return null
if (!['5H', '周', 'Spark5H', 'Spark周', 'Auto', 'Fast', 'Expert', 'Heavy', 'Grok 4.3'].includes(item.label)) return null
if (item.resetAtSeconds == null && item.resetSeconds == null) return null
return getCodexResetCountdown(
item.resetAtSeconds,
@@ -3704,11 +3723,16 @@ function shouldHideQuotaProgressDetailText(text: string | null | undefined): boo
return (text ?? '').trim().includes('已重置')
}
function getQuotaProgressDisplayText(item: QuotaProgressItem): string {
function getQuotaProgressResetDisplayText(item: QuotaProgressItem): string {
const countdownText = getQuotaProgressCountdownText(item)
if (countdownText) return formatCompactQuotaCountdownText(countdownText)
return ''
}
function getQuotaProgressMeterDisplayText(item: QuotaProgressItem): string {
const detail = item.detail?.trim() || ''
return shouldHideQuotaProgressDetailText(detail) ? '' : detail
if (!shouldHideQuotaProgressDetailText(detail) && detail) return detail
return `${item.remainingPercent.toFixed(1)}%`
}
function getQuotaFallbackText(key: PoolKeyDetail): string | null {
@@ -3718,6 +3742,11 @@ function getQuotaFallbackText(key: PoolKeyDetail): string | null {
function getQuotaLabelOrder(label: string): number {
if (label === 'Auto') return 0
if (label === 'Fast') return 1
if (label === 'Expert') return 2
if (label === 'Heavy') return 3
if (label === 'Grok 4.3') return 4
if (label === '5H') return 0
if (label === '周') return 1
if (label === 'Spark5H') return 2
@@ -3770,6 +3799,14 @@ function getQuotaSnapshotUpdatedAtSeconds(quota: QuotaStatusSnapshot | null | un
return normalizeUnixSeconds(quota?.updated_at ?? quota?.observed_at ?? null)
}
function getQuotaSnapshotResetAtSeconds(quota: QuotaStatusSnapshot | null | undefined): number | null {
return normalizeUnixSeconds(quota?.reset_at ?? null)
}
function getQuotaSnapshotResetSeconds(quota: QuotaStatusSnapshot | null | undefined): number | null {
return normalizeRemainingSeconds(quota?.reset_seconds ?? null)
}
function getQuotaSnapshotWindow(
quota: QuotaStatusSnapshot | null | undefined,
code: string,
@@ -3830,6 +3867,47 @@ function formatQuotaValue(value: number | null | undefined): string {
return normalized.toFixed(1)
}
function getQuotaWindowValueText(window: QuotaWindowSnapshot | null | undefined): string | undefined {
if (!window || typeof window.limit_value !== 'number' || window.limit_value <= 0) return undefined
if (typeof window.remaining_value === 'number') {
return `${formatQuotaValue(window.remaining_value)}/${formatQuotaValue(window.limit_value)}`
}
if (typeof window.used_value === 'number') {
return `${formatQuotaValue(Math.max(window.limit_value - window.used_value, 0))}/${formatQuotaValue(window.limit_value)}`
}
return undefined
}
function resolvePoolKeyPlanType(key: PoolKeyDetail): string | null {
const direct = key.oauth_plan_type?.trim()
if (direct) return direct
const quota = getQuotaSnapshot(key)
const quotaPlan = quota?.plan_type?.trim()
if (quotaPlan) return quotaPlan
const quotaPoolTier = quota?.pool_tier?.trim()
return quotaPoolTier || null
}
const GROK_QUOTA_MODE_LABELS: Record<string, string> = {
quota_auto: 'Auto',
auto: 'Auto',
quota_fast: 'Fast',
fast: 'Fast',
quota_expert: 'Expert',
expert: 'Expert',
quota_heavy: 'Heavy',
heavy: 'Heavy',
quota_grok_4_3: 'Grok 4.3',
'grok-420-computer-use-sa': 'Grok 4.3',
}
function getGrokQuotaWindowLabel(window: QuotaWindowSnapshot): string {
const code = String(window.code || '').trim().replace(/^model:/i, '')
const label = String(window.label || window.model || code).trim()
const normalized = (label || code).toLowerCase()
return GROK_QUOTA_MODE_LABELS[normalized] || GROK_QUOTA_MODE_LABELS[code.toLowerCase()] || label || code || '模式'
}
function buildQuotaProgressItemsFromSnapshot(key: PoolKeyDetail): QuotaProgressItem[] {
const quota = getQuotaSnapshot(key)
if (!quota) return []
@@ -3838,6 +3916,8 @@ function buildQuotaProgressItemsFromSnapshot(key: PoolKeyDetail): QuotaProgressI
if (providerType === 'codex') {
const items: QuotaProgressItem[] = []
const quotaResetAtSeconds = getQuotaSnapshotResetAtSeconds(quota)
const quotaResetSeconds = getQuotaSnapshotResetSeconds(quota)
for (const [label, code] of [
['5H', '5h'],
['周', 'weekly'],
@@ -3850,8 +3930,8 @@ function buildQuotaProgressItemsFromSnapshot(key: PoolKeyDetail): QuotaProgressI
items.push({
label,
remainingPercent,
resetAtSeconds: normalizeUnixSeconds(window?.reset_at ?? null),
resetSeconds: normalizeRemainingSeconds(window?.reset_seconds ?? null),
resetAtSeconds: normalizeUnixSeconds(window?.reset_at ?? quotaResetAtSeconds ?? null),
resetSeconds: normalizeRemainingSeconds(window?.reset_seconds ?? quotaResetSeconds ?? null),
updatedAtSeconds: getQuotaSnapshotUpdatedAtSeconds(quota),
})
}
@@ -3859,6 +3939,8 @@ function buildQuotaProgressItemsFromSnapshot(key: PoolKeyDetail): QuotaProgressI
}
if (providerType === 'kiro') {
const quotaResetAtSeconds = getQuotaSnapshotResetAtSeconds(quota)
const quotaResetSeconds = getQuotaSnapshotResetSeconds(quota)
const window = getQuotaSnapshotWindow(quota, 'usage')
?? getQuotaSnapshotWindowsByScope(quota, 'account')[0]
?? null
@@ -3873,8 +3955,45 @@ function buildQuotaProgressItemsFromSnapshot(key: PoolKeyDetail): QuotaProgressI
label: '剩余',
remainingPercent,
detail,
resetAtSeconds: normalizeUnixSeconds(window?.reset_at ?? null),
resetSeconds: normalizeRemainingSeconds(window?.reset_seconds ?? null),
resetAtSeconds: normalizeUnixSeconds(window?.reset_at ?? quotaResetAtSeconds ?? null),
resetSeconds: normalizeRemainingSeconds(window?.reset_seconds ?? quotaResetSeconds ?? null),
updatedAtSeconds: getQuotaSnapshotUpdatedAtSeconds(quota),
}]
}
if (providerType === 'grok') {
const quotaResetAtSeconds = getQuotaSnapshotResetAtSeconds(quota)
const quotaResetSeconds = getQuotaSnapshotResetSeconds(quota)
const modelWindows = getQuotaSnapshotWindowsByScope(quota, 'model')
if (modelWindows.length > 0) {
return modelWindows
.map((window): QuotaProgressItem | null => {
const remainingPercent = getQuotaWindowRemainingPercent(window)
if (remainingPercent == null) return null
return {
label: getGrokQuotaWindowLabel(window),
remainingPercent,
detail: getQuotaWindowValueText(window),
resetAtSeconds: normalizeUnixSeconds(window?.reset_at ?? quotaResetAtSeconds ?? null),
resetSeconds: normalizeRemainingSeconds(window?.reset_seconds ?? quotaResetSeconds ?? null),
updatedAtSeconds: getQuotaSnapshotUpdatedAtSeconds(quota),
}
})
.filter((item): item is QuotaProgressItem => item != null)
}
const window = getQuotaSnapshotWindow(quota, 'usage')
?? getQuotaSnapshotWindowsByScope(quota, 'account')[0]
?? null
const remainingPercent = getQuotaWindowRemainingPercent(window)
if (remainingPercent == null) return []
return [{
label: '剩余',
remainingPercent,
detail: getQuotaWindowValueText(window),
resetAtSeconds: normalizeUnixSeconds(window?.reset_at ?? quotaResetAtSeconds ?? null),
resetSeconds: normalizeRemainingSeconds(window?.reset_seconds ?? quotaResetSeconds ?? null),
updatedAtSeconds: getQuotaSnapshotUpdatedAtSeconds(quota),
}]
}
@@ -4075,29 +4194,6 @@ function getQuotaTextClass(quotaText: string): string {
return 'text-[11px] text-foreground/90 leading-4'
}
function formatStatInteger(value: number | null | undefined): string {
const n = Number(value ?? 0)
if (!Number.isFinite(n) || n <= 0) return '0'
return Math.round(n).toLocaleString('en-US')
}
function formatTokenCount(value: number | null | undefined): string {
const n = Number(value ?? 0)
if (!Number.isFinite(n) || n <= 0) return '0'
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`
return String(Math.round(n))
}
function formatStatUsd(value: number | string | null | undefined): string {
const n = Number(value ?? 0)
if (!Number.isFinite(n) || n <= 0) return '$0.00'
if (n < 0.01) return `$${n.toFixed(4)}`
if (n < 1) return `$${n.toFixed(3)}`
if (n < 1000) return `$${n.toFixed(2)}`
return `$${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
}
function formatPoolScore(value: number | null | undefined): string {
const n = Number(value)
if (!Number.isFinite(n)) return '-'

View File

@@ -524,7 +524,7 @@
<Dialog
:model-value="showAddDialog"
:title="editingNode ? '编辑代理节点' : '添加代理节点'"
:description="editingNode ? '修改手动代理节点的配置' : '推荐使用一键脚本部署 aether-proxy,也可手动添加已有 HTTP/SOCKS 代理'"
:description="editingNode ? '修改手动代理节点的配置' : '推荐使用一键脚本部署 aether-tunnel,也可手动添加已有 HTTP/SOCKS 代理'"
:icon="editingNode ? SquarePen : Plus"
size="lg"
@update:model-value="handleDialogClose"
@@ -733,11 +733,11 @@
</template>
</Dialog>
<!-- 远程配置对话框 (aether-proxy 节点) -->
<!-- 远程配置对话框 (aether-tunnel 节点) -->
<Dialog
:model-value="showConfigDialog"
title="远程配置"
description="修改后将在下次心跳时自动下发到 aether-proxy 节点"
description="修改后将在下次心跳时自动下发到 aether-tunnel 节点"
:icon="Settings"
size="md"
@update:model-value="handleConfigDialogClose"
@@ -1053,7 +1053,7 @@ const proxyInstallHint = computed(() => {
return `这条命令将在 ${Math.floor(proxyInstallSession.value.expires_in_seconds / 60)} 分钟内有效,成功使用后立即失效。`
})
// 远程配置对话框 (aether-proxy 节点)
// 远程配置对话框 (aether-tunnel 节点)
const showConfigDialog = ref(false)
const savingConfig = ref(false)
const configNode = ref<ProxyNode | null>(null)

View File

@@ -0,0 +1,439 @@
<template>
<div class="space-y-6 pb-8">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 class="text-2xl font-semibold text-foreground">
邀请返利
</h1>
<p class="mt-1 text-sm text-muted-foreground">
查看邀请关系返利记录和失败返利处理状态
</p>
</div>
<Button
variant="outline"
:disabled="loading"
@click="loadAll"
>
<RefreshCw
class="mr-2 h-4 w-4"
:class="{ 'animate-spin': loading }"
/>
刷新
</Button>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-5">
<Card
v-for="item in statCards"
:key="item.label"
class="p-4"
>
<p class="text-xs text-muted-foreground">
{{ item.label }}
</p>
<p class="mt-2 text-xl font-semibold">
{{ item.value }}
</p>
</Card>
</div>
<Card class="overflow-hidden">
<div class="border-b border-border px-5 py-4">
<h2 class="text-base font-semibold">
邀请关系
</h2>
</div>
<div class="grid grid-cols-1 gap-3 border-b border-border/70 p-4 md:grid-cols-5">
<Input
v-model="relationshipFilters.inviter"
placeholder="邀请人"
/>
<Input
v-model="relationshipFilters.invitee"
placeholder="被邀请人"
/>
<Input
v-model="relationshipFilters.invite_code"
placeholder="邀请码"
/>
<Select v-model="firstPaidFilter">
<SelectTrigger>
<SelectValue placeholder="首付状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
全部
</SelectItem>
<SelectItem value="true">
已首付
</SelectItem>
<SelectItem value="false">
未首付
</SelectItem>
</SelectContent>
</Select>
<Button
type="button"
@click="loadRelationships"
>
查询
</Button>
</div>
<div class="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>邀请人</TableHead>
<TableHead>被邀请人</TableHead>
<TableHead>邀请码</TableHead>
<TableHead>绑定时间</TableHead>
<TableHead>首付状态</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="item in relationships"
:key="item.id"
>
<TableCell>{{ item.inviter_username || item.inviter_user_id }}</TableCell>
<TableCell>{{ item.invitee_username || item.invitee_user_id }}</TableCell>
<TableCell class="font-mono text-xs">
{{ item.invite_code_snapshot }}
</TableCell>
<TableCell>{{ formatUnix(item.created_at_unix_secs) }}</TableCell>
<TableCell>
<Badge :variant="item.first_paid_order_id ? 'success' : 'secondary'">
{{ item.first_paid_order_id ? '已首付' : '未首付' }}
</Badge>
</TableCell>
</TableRow>
<TableRow v-if="relationships.length === 0">
<TableCell
colspan="5"
class="py-8 text-center text-sm text-muted-foreground"
>
暂无邀请关系
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</Card>
<Card class="overflow-hidden">
<div class="border-b border-border px-5 py-4">
<h2 class="text-base font-semibold">
返利记录
</h2>
</div>
<div class="grid grid-cols-1 gap-3 border-b border-border/70 p-4 md:grid-cols-5">
<Input
v-model="rewardFilters.order_id"
placeholder="订单号"
/>
<Select v-model="rewardFilters.reward_type">
<SelectTrigger>
<SelectValue placeholder="返利类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
全部类型
</SelectItem>
<SelectItem value="percent">
比例返利
</SelectItem>
<SelectItem value="headcount">
人头返利
</SelectItem>
</SelectContent>
</Select>
<Select v-model="rewardFilters.status">
<SelectTrigger>
<SelectValue placeholder="状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
全部状态
</SelectItem>
<SelectItem value="pending">
待发
</SelectItem>
<SelectItem value="failed">
失败
</SelectItem>
<SelectItem value="applied">
已发
</SelectItem>
<SelectItem value="voided">
已作废
</SelectItem>
<SelectItem value="reversed">
已冲回
</SelectItem>
</SelectContent>
</Select>
<Button
type="button"
class="md:col-start-5"
@click="loadRewards"
>
查询
</Button>
</div>
<div class="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>类型</TableHead>
<TableHead>来源订单</TableHead>
<TableHead>金额</TableHead>
<TableHead>状态</TableHead>
<TableHead>冲回</TableHead>
<TableHead>创建时间</TableHead>
<TableHead class="text-right">
操作
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="item in rewards"
:key="item.id"
>
<TableCell>{{ getRewardTypeLabel(item.reward_type) }}</TableCell>
<TableCell class="font-mono text-xs">
{{ item.source_order_id || '-' }}
</TableCell>
<TableCell>{{ formatUsd(item.amount_usd) }}</TableCell>
<TableCell>
<Badge :variant="getRewardStatusVariant(item.status)">
{{ getRewardStatusLabel(item.status) }}
</Badge>
</TableCell>
<TableCell>
{{ formatUsd(item.reversed_amount_usd) }}
<span
v-if="item.pending_reversal_amount_usd > 0"
class="text-xs text-amber-600 dark:text-amber-400"
>
/ 待冲回 {{ formatUsd(item.pending_reversal_amount_usd) }}
</span>
</TableCell>
<TableCell>{{ formatUnix(item.created_at_unix_secs) }}</TableCell>
<TableCell class="text-right">
<div class="flex justify-end gap-2">
<Button
v-if="item.status === 'failed'"
variant="outline"
size="sm"
:disabled="mutatingRewardId === item.id"
@click="retryReward(item)"
>
补发
</Button>
<Button
v-if="item.status === 'failed' || item.status === 'pending'"
variant="ghost"
size="sm"
:disabled="mutatingRewardId === item.id"
@click="voidReward(item)"
>
作废
</Button>
</div>
</TableCell>
</TableRow>
<TableRow v-if="rewards.length === 0">
<TableCell
colspan="7"
class="py-8 text-center text-sm text-muted-foreground"
>
暂无返利记录
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</Card>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { RefreshCw } from 'lucide-vue-next'
import {
referralApi,
type ReferralRelationshipRecord,
type ReferralRewardRecord,
type ReferralSummary
} from '@/api/referrals'
import {
Badge,
Button,
Card,
Input,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '@/components/ui'
import { useToast } from '@/composables/useToast'
const relationships = ref<ReferralRelationshipRecord[]>([])
const rewards = ref<ReferralRewardRecord[]>([])
const stats = ref<ReferralSummary>({
total_invites: 0,
effective_invites: 0,
paid_reward_usd: 0,
pending_reward_usd: 0,
reversed_reward_usd: 0
})
const loading = ref(false)
const mutatingRewardId = ref<string | null>(null)
const relationshipFilters = ref({
inviter: '',
invitee: '',
invite_code: ''
})
const firstPaidFilter = ref('all')
const rewardFilters = ref({
order_id: '',
reward_type: 'all',
status: 'all'
})
const { success, error: showError } = useToast()
const statCards = computed(() => [
{ label: '总邀请', value: stats.value.total_invites },
{ label: '有效邀请', value: stats.value.effective_invites },
{ label: '已发返利', value: formatUsd(stats.value.paid_reward_usd) },
{ label: '待发返利', value: formatUsd(stats.value.pending_reward_usd) },
{ label: '已冲回返利', value: formatUsd(stats.value.reversed_reward_usd) },
])
function formatUsd(value: number): string {
return `$${Number(value || 0).toFixed(2)}`
}
function formatUnix(value?: number | null): string {
if (!value) return '-'
return new Date(value * 1000).toLocaleString('zh-CN')
}
function getRewardTypeLabel(value: string): string {
if (value === 'percent') return '比例返利'
if (value === 'headcount') return '人头返利'
return value
}
function getRewardStatusLabel(value: string): string {
switch (value) {
case 'applied':
return '已发'
case 'pending':
return '待发'
case 'failed':
return '失败'
case 'voided':
return '已作废'
case 'reversed':
return '已冲回'
default:
return value
}
}
function getRewardStatusVariant(value: string): 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning' | 'dark' {
switch (value) {
case 'applied':
return 'success'
case 'failed':
return 'destructive'
case 'pending':
return 'warning'
case 'voided':
return 'secondary'
default:
return 'outline'
}
}
async function loadRelationships() {
const firstPaid =
firstPaidFilter.value === 'true' ? true : firstPaidFilter.value === 'false' ? false : null
const response = await referralApi.getAdminReferrals({
...relationshipFilters.value,
first_paid: firstPaid,
limit: 100,
offset: 0
})
relationships.value = response.items
stats.value = response.stats
}
async function loadRewards() {
const response = await referralApi.getAdminReferralRewards({
order_id: rewardFilters.value.order_id,
reward_type: rewardFilters.value.reward_type === 'all' ? undefined : rewardFilters.value.reward_type,
status: rewardFilters.value.status === 'all' ? undefined : rewardFilters.value.status,
limit: 100,
offset: 0
})
rewards.value = response.items
stats.value = response.stats
}
async function loadAll() {
loading.value = true
try {
await Promise.all([loadRelationships(), loadRewards()])
} catch {
showError('加载邀请返利数据失败')
} finally {
loading.value = false
}
}
async function retryReward(item: ReferralRewardRecord) {
mutatingRewardId.value = item.id
try {
const response = await referralApi.retryReferralReward(item.id, '管理员后台补发')
replaceReward(response.reward)
success('返利已补发')
} catch {
showError('补发失败')
} finally {
mutatingRewardId.value = null
}
}
async function voidReward(item: ReferralRewardRecord) {
mutatingRewardId.value = item.id
try {
const response = await referralApi.voidReferralReward(item.id, '管理员后台作废')
replaceReward(response.reward)
success('返利已作废')
} catch {
showError('作废失败')
} finally {
mutatingRewardId.value = null
}
}
function replaceReward(updated: ReferralRewardRecord) {
rewards.value = rewards.value.map(item => item.id === updated.id ? updated : item)
}
onMounted(() => {
void loadAll()
})
</script>

View File

@@ -58,6 +58,15 @@
:turnstile-secret-key="systemConfig.turnstile_secret_key"
:turnstile-secret-configured="systemConfig.turnstile_secret_key_is_set"
:turnstile-allowed-hostnames-str="turnstileAllowedHostnamesStr"
:referral-enabled="systemConfig.referral_enabled"
:referral-reward-mode="systemConfig.referral_reward_mode"
:referral-recharge-percent="systemConfig.referral_recharge_percent"
:referral-headcount-amount-usd="systemConfig.referral_headcount_amount_usd"
:referral-headcount-trigger="systemConfig.referral_headcount_trigger"
:registration-privacy-policy-enabled="systemConfig.registration_privacy_policy_enabled"
:registration-privacy-policy-format="systemConfig.registration_privacy_policy_format"
:registration-privacy-policy-content="systemConfig.registration_privacy_policy_content"
:registration-privacy-policy-version="systemConfig.registration_privacy_policy_version"
:auto-delete-expired-keys="systemConfig.auto_delete_expired_keys"
:enable-format-conversion="systemConfig.enable_format_conversion"
:enable-openai-image-sync-heartbeat="systemConfig.enable_openai_image_sync_heartbeat"
@@ -73,6 +82,15 @@
@update:turnstile-secret-key="systemConfig.turnstile_secret_key = $event"
@update:turnstile-allowed-hostnames-str="turnstileAllowedHostnamesStr = $event"
@clear-turnstile-secret="clearTurnstileSecret"
@update:referral-enabled="systemConfig.referral_enabled = $event"
@update:referral-reward-mode="systemConfig.referral_reward_mode = $event"
@update:referral-recharge-percent="systemConfig.referral_recharge_percent = $event"
@update:referral-headcount-amount-usd="systemConfig.referral_headcount_amount_usd = $event"
@update:referral-headcount-trigger="systemConfig.referral_headcount_trigger = $event"
@update:registration-privacy-policy-enabled="systemConfig.registration_privacy_policy_enabled = $event"
@update:registration-privacy-policy-format="systemConfig.registration_privacy_policy_format = $event"
@update:registration-privacy-policy-content="systemConfig.registration_privacy_policy_content = $event"
@update:registration-privacy-policy-version="systemConfig.registration_privacy_policy_version = $event"
@update:auto-delete-expired-keys="systemConfig.auto_delete_expired_keys = $event"
@update:enable-format-conversion="systemConfig.enable_format_conversion = $event"
@update:enable-openai-image-sync-heartbeat="systemConfig.enable_openai_image_sync_heartbeat = $event"

View File

@@ -684,7 +684,7 @@ function tunnelErrorAction(category: string) {
case 'ws_write_error': return '检查 gateway 是否重启、负载均衡/NAT/防火墙是否重置长连接,并确认 proxy 是否已自动重连。'
case 'ws_ping_error': return '检查中间代理是否清理空闲 WebSocket或对端是否提前关闭连接。'
case 'ws_read_error': return '对照同一时间的 gateway 日志和网络监控,确认是否存在链路中断。'
case 'tunnel_connect_error': return '检查 Aether 地址、DNS、TLS、管理 token以及 AETHER_PROXY_AETHER_PROXY_URL 配置。'
case 'tunnel_connect_error': return '检查 Aether 地址、DNS、TLS、管理 token以及 AETHER_TUNNEL_AETHER_OUTBOUND_PROXY_URL 配置。'
case 'frame_decode_error': return '检查 proxy/gateway 版本兼容性,确认中间层没有改写 WebSocket 二进制帧。'
case 'stream_dispatch_timeout': return '检查 proxy CPU/内存、并发上限和上游 provider 慢请求。'
case 'heartbeat_ack_empty':

View File

@@ -262,6 +262,203 @@
</p>
</div>
</div>
<div class="md:col-span-2 grid grid-cols-1 md:grid-cols-2 gap-4 border-t pt-5">
<div class="flex items-center h-full">
<div class="flex items-center space-x-2">
<Checkbox
id="referral-enabled"
:checked="referralEnabled"
@update:checked="$emit('update:referralEnabled', $event)"
/>
<div>
<Label
for="referral-enabled"
class="cursor-pointer"
>
邀请返利
</Label>
<p class="text-xs text-muted-foreground">
开启后可按充值比例人头或两者同时发放赠款返利
</p>
</div>
</div>
</div>
<div>
<Label
for="referral-reward-mode"
class="block text-sm font-medium mb-2"
>
返利方式
</Label>
<Select
:model-value="referralRewardMode"
@update:model-value="$emit('update:referralRewardMode', $event)"
>
<SelectTrigger id="referral-reward-mode">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="percent">
按充值比例
</SelectItem>
<SelectItem value="headcount">
按邀请人头
</SelectItem>
<SelectItem value="both">
两者同时启用
</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label
for="referral-recharge-percent"
class="block text-sm font-medium"
>
充值返利比例 (%)
</Label>
<Input
id="referral-recharge-percent"
:model-value="referralRechargePercent"
type="number"
min="0"
step="0.01"
class="mt-1"
@update:model-value="$emit('update:referralRechargePercent', Number($event))"
/>
</div>
<div>
<Label
for="referral-headcount-amount"
class="block text-sm font-medium"
>
人头返利金额 (美元)
</Label>
<Input
id="referral-headcount-amount"
:model-value="referralHeadcountAmountUsd"
type="number"
min="0"
step="0.01"
class="mt-1"
@update:model-value="$emit('update:referralHeadcountAmountUsd', Number($event))"
/>
</div>
<div>
<Label
for="referral-headcount-trigger"
class="block text-sm font-medium mb-2"
>
人头返利触发时机
</Label>
<Select
:model-value="referralHeadcountTrigger"
@update:model-value="$emit('update:referralHeadcountTrigger', $event)"
>
<SelectTrigger id="referral-headcount-trigger">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="registration">
注册成功
</SelectItem>
<SelectItem value="email_verified">
邮箱验证完成
</SelectItem>
<SelectItem value="first_paid_order">
首笔真实支付完成
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div class="md:col-span-2 grid grid-cols-1 md:grid-cols-2 gap-4 border-t pt-5">
<div class="flex items-center h-full">
<div class="flex items-center space-x-2">
<Checkbox
id="privacy-policy-enabled"
:checked="registrationPrivacyPolicyEnabled"
@update:checked="$emit('update:registrationPrivacyPolicyEnabled', $event)"
/>
<div>
<Label
for="privacy-policy-enabled"
class="cursor-pointer"
>
注册隐私政策确认
</Label>
<p class="text-xs text-muted-foreground">
开启后注册时必须确认当前版本
</p>
</div>
</div>
</div>
<div>
<Label
for="privacy-policy-version"
class="block text-sm font-medium"
>
隐私政策版本
</Label>
<Input
id="privacy-policy-version"
:model-value="registrationPrivacyPolicyVersion"
type="text"
placeholder="2026-05-16"
class="mt-1"
@update:model-value="$emit('update:registrationPrivacyPolicyVersion', String($event || '').trim())"
/>
</div>
<div>
<Label
for="privacy-policy-format"
class="block text-sm font-medium mb-2"
>
隐私政策格式
</Label>
<Select
:model-value="registrationPrivacyPolicyFormat"
@update:model-value="$emit('update:registrationPrivacyPolicyFormat', $event)"
>
<SelectTrigger id="privacy-policy-format">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="markdown">
Markdown
</SelectItem>
<SelectItem value="html">
HTML
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="md:col-span-2">
<Label
for="privacy-policy-content"
class="block text-sm font-medium"
>
隐私政策内容
</Label>
<Textarea
id="privacy-policy-content"
:model-value="registrationPrivacyPolicyContent"
rows="8"
class="mt-1"
placeholder="填写 Markdown 或 HTML 内容"
@update:model-value="$emit('update:registrationPrivacyPolicyContent', $event)"
/>
</div>
</div>
</div>
</CardSection>
</template>
@@ -270,6 +467,7 @@
import Button from '@/components/ui/button.vue'
import Input from '@/components/ui/input.vue'
import Label from '@/components/ui/label.vue'
import Textarea from '@/components/ui/textarea.vue'
import Checkbox from '@/components/ui/checkbox.vue'
import Select from '@/components/ui/select.vue'
import SelectTrigger from '@/components/ui/select-trigger.vue'
@@ -288,6 +486,15 @@ defineProps<{
turnstileSecretKey: string
turnstileSecretConfigured: boolean
turnstileAllowedHostnamesStr: string
referralEnabled: boolean
referralRewardMode: string
referralRechargePercent: number
referralHeadcountAmountUsd: number
referralHeadcountTrigger: string
registrationPrivacyPolicyEnabled: boolean
registrationPrivacyPolicyFormat: string
registrationPrivacyPolicyContent: string
registrationPrivacyPolicyVersion: string
autoDeleteExpiredKeys: boolean
enableFormatConversion: boolean
enableOpenaiImageSyncHeartbeat: boolean
@@ -306,6 +513,15 @@ defineEmits<{
'update:turnstileSecretKey': [value: string]
'update:turnstileAllowedHostnamesStr': [value: string]
clearTurnstileSecret: []
'update:referralEnabled': [value: boolean]
'update:referralRewardMode': [value: string]
'update:referralRechargePercent': [value: number]
'update:referralHeadcountAmountUsd': [value: number]
'update:referralHeadcountTrigger': [value: string]
'update:registrationPrivacyPolicyEnabled': [value: boolean]
'update:registrationPrivacyPolicyFormat': [value: string]
'update:registrationPrivacyPolicyContent': [value: string]
'update:registrationPrivacyPolicyVersion': [value: string]
'update:autoDeleteExpiredKeys': [value: boolean]
'update:enableFormatConversion': [value: boolean]
'update:enableOpenaiImageSyncHeartbeat': [value: boolean]

View File

@@ -20,6 +20,15 @@ export interface SystemConfig {
turnstile_secret_key: string
turnstile_secret_key_is_set: boolean
turnstile_allowed_hostnames: string[]
referral_enabled: boolean
referral_reward_mode: string
referral_recharge_percent: number
referral_headcount_amount_usd: number
referral_headcount_trigger: string
registration_privacy_policy_enabled: boolean
registration_privacy_policy_format: string
registration_privacy_policy_content: string
registration_privacy_policy_version: string
// 独立余额 Key 过期管理
auto_delete_expired_keys: boolean
// 格式转换
@@ -65,6 +74,15 @@ const CONFIG_KEYS = [
'turnstile_site_key',
'turnstile_secret_key',
'turnstile_allowed_hostnames',
'referral_enabled',
'referral_reward_mode',
'referral_recharge_percent',
'referral_headcount_amount_usd',
'referral_headcount_trigger',
'registration_privacy_policy_enabled',
'registration_privacy_policy_format',
'registration_privacy_policy_content',
'registration_privacy_policy_version',
// 独立余额 Key 过期管理
'auto_delete_expired_keys',
// 格式转换
@@ -112,6 +130,15 @@ function createDefaultConfig(): SystemConfig {
turnstile_secret_key: '',
turnstile_secret_key_is_set: false,
turnstile_allowed_hostnames: [],
referral_enabled: false,
referral_reward_mode: 'percent',
referral_recharge_percent: 5,
referral_headcount_amount_usd: 0,
referral_headcount_trigger: 'registration',
registration_privacy_policy_enabled: false,
registration_privacy_policy_format: 'markdown',
registration_privacy_policy_content: '',
registration_privacy_policy_version: '1',
// 独立余额 Key 过期管理
auto_delete_expired_keys: false,
// 格式转换
@@ -184,6 +211,19 @@ export function useSystemConfig() {
systemConfig.value.turnstile_secret_key.trim() !== '' ||
JSON.stringify(systemConfig.value.turnstile_allowed_hostnames) !==
JSON.stringify(originalConfig.value.turnstile_allowed_hostnames) ||
systemConfig.value.referral_enabled !== originalConfig.value.referral_enabled ||
systemConfig.value.referral_reward_mode !== originalConfig.value.referral_reward_mode ||
systemConfig.value.referral_recharge_percent !== originalConfig.value.referral_recharge_percent ||
systemConfig.value.referral_headcount_amount_usd !== originalConfig.value.referral_headcount_amount_usd ||
systemConfig.value.referral_headcount_trigger !== originalConfig.value.referral_headcount_trigger ||
systemConfig.value.registration_privacy_policy_enabled !==
originalConfig.value.registration_privacy_policy_enabled ||
systemConfig.value.registration_privacy_policy_format !==
originalConfig.value.registration_privacy_policy_format ||
systemConfig.value.registration_privacy_policy_content !==
originalConfig.value.registration_privacy_policy_content ||
systemConfig.value.registration_privacy_policy_version !==
originalConfig.value.registration_privacy_policy_version ||
systemConfig.value.auto_delete_expired_keys !== originalConfig.value.auto_delete_expired_keys ||
systemConfig.value.enable_format_conversion !== originalConfig.value.enable_format_conversion ||
systemConfig.value.enable_openai_image_sync_heartbeat !== originalConfig.value.enable_openai_image_sync_heartbeat
@@ -386,6 +426,51 @@ export function useSystemConfig() {
value: systemConfig.value.turnstile_allowed_hostnames,
description: 'Cloudflare Turnstile 允许的 hostname 列表',
},
{
key: 'referral_enabled',
value: systemConfig.value.referral_enabled,
description: '邀请返利开关',
},
{
key: 'referral_reward_mode',
value: systemConfig.value.referral_reward_mode,
description: '邀请返利方式',
},
{
key: 'referral_recharge_percent',
value: systemConfig.value.referral_recharge_percent,
description: '邀请充值比例返利百分比',
},
{
key: 'referral_headcount_amount_usd',
value: systemConfig.value.referral_headcount_amount_usd,
description: '邀请人头返利金额(美元)',
},
{
key: 'referral_headcount_trigger',
value: systemConfig.value.referral_headcount_trigger,
description: '邀请人头返利触发时机',
},
{
key: 'registration_privacy_policy_enabled',
value: systemConfig.value.registration_privacy_policy_enabled,
description: '注册隐私政策确认开关',
},
{
key: 'registration_privacy_policy_format',
value: systemConfig.value.registration_privacy_policy_format,
description: '注册隐私政策内容格式',
},
{
key: 'registration_privacy_policy_content',
value: systemConfig.value.registration_privacy_policy_content,
description: '注册隐私政策内容',
},
{
key: 'registration_privacy_policy_version',
value: systemConfig.value.registration_privacy_policy_version,
description: '注册隐私政策版本',
},
{
key: 'auto_delete_expired_keys',
value: systemConfig.value.auto_delete_expired_keys,
@@ -426,6 +511,21 @@ export function useSystemConfig() {
originalConfig.value.turnstile_allowed_hostnames = [
...systemConfig.value.turnstile_allowed_hostnames,
]
originalConfig.value.referral_enabled = systemConfig.value.referral_enabled
originalConfig.value.referral_reward_mode = systemConfig.value.referral_reward_mode
originalConfig.value.referral_recharge_percent = systemConfig.value.referral_recharge_percent
originalConfig.value.referral_headcount_amount_usd =
systemConfig.value.referral_headcount_amount_usd
originalConfig.value.referral_headcount_trigger =
systemConfig.value.referral_headcount_trigger
originalConfig.value.registration_privacy_policy_enabled =
systemConfig.value.registration_privacy_policy_enabled
originalConfig.value.registration_privacy_policy_format =
systemConfig.value.registration_privacy_policy_format
originalConfig.value.registration_privacy_policy_content =
systemConfig.value.registration_privacy_policy_content
originalConfig.value.registration_privacy_policy_version =
systemConfig.value.registration_privacy_policy_version
if (turnstileSecret) {
systemConfig.value.turnstile_secret_key = ''
systemConfig.value.turnstile_secret_key_is_set = true

View File

@@ -0,0 +1,102 @@
<template>
<main class="min-h-screen bg-[#faf9f5] text-[#3d3929] dark:bg-[#191714] dark:text-[#e3e0d3]">
<header class="border-b border-[#3d3929]/10 dark:border-white/10">
<div class="mx-auto flex max-w-4xl items-center justify-between px-5 py-4">
<RouterLink
to="/"
class="flex items-center gap-3"
>
<HeaderLogo
size="h-9 w-9"
class-name="text-[#191919] dark:text-white"
/>
<div>
<div class="text-sm font-semibold">
{{ siteName }}
</div>
<div class="text-xs text-muted-foreground">
隐私政策
</div>
</div>
</RouterLink>
<RouterLink
to="/"
class="rounded-lg border border-border px-3 py-1.5 text-sm text-muted-foreground transition hover:text-foreground"
>
返回首页
</RouterLink>
</div>
</header>
<section class="mx-auto max-w-4xl px-5 py-8">
<div class="mb-6">
<h1 class="text-2xl font-semibold">
隐私政策
</h1>
<p class="mt-2 text-sm text-muted-foreground">
当前版本{{ policy.version || '1' }}
</p>
</div>
<div
v-if="loading"
class="rounded-lg border border-border bg-background/70 p-6 text-sm text-muted-foreground"
>
正在加载...
</div>
<div
v-else-if="loadError"
class="rounded-lg border border-destructive/20 bg-destructive/5 p-6 text-sm text-destructive"
>
{{ loadError }}
</div>
<!-- eslint-disable vue/no-v-html -->
<article
v-else
class="prose prose-sm dark:prose-invert max-w-none rounded-lg border border-border bg-background/70 p-6"
v-html="renderedPolicy"
/>
<!-- eslint-enable vue/no-v-html -->
</section>
</main>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { marked } from 'marked'
import { authApi, type RegistrationPrivacyPolicySettings } from '@/api/auth'
import HeaderLogo from '@/components/HeaderLogo.vue'
import { useSiteInfo } from '@/composables/useSiteInfo'
import { sanitizeHtml, sanitizeMarkdown } from '@/utils/sanitize'
const { siteName } = useSiteInfo()
const loading = ref(true)
const loadError = ref('')
const policy = ref<RegistrationPrivacyPolicySettings>({
enabled: false,
format: 'markdown',
content: '',
version: '1'
})
const renderedPolicy = computed(() => {
if (!policy.value.content) return '<p>暂无隐私政策内容。</p>'
if (policy.value.format === 'html') {
return sanitizeHtml(policy.value.content)
}
return sanitizeMarkdown(marked(policy.value.content) as string)
})
onMounted(async () => {
loading.value = true
loadError.value = ''
try {
const settings = await authApi.getRegistrationSettings()
policy.value = settings.privacy_policy ?? policy.value
} catch {
loadError.value = '隐私政策加载失败,请稍后重试。'
} finally {
loading.value = false
}
})
</script>

View File

@@ -80,52 +80,39 @@ import { Settings } from 'lucide-vue-next'
</section>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 mt-12 pt-8 border-t border-[#e5e4df] dark:border-[rgba(227,224,211,0.12)]">
<!-- 6. 能力标签 -->
<section
id="capabilities"
class="scroll-mt-24 lg:scroll-mt-20"
>
<h3 class="mt-0 text-xl text-[#262624] dark:text-[#f1ead8]">
6. 能力标签
</h3>
<p class="text-sm text-[#666663] dark:text-[#a3a094] mt-2">
为特定的 Key 或模型添加自定义标签 Vision, Function Calling, Long Context通过标签约束路由只选择具备该能力的可用通道
</p>
</section>
<!-- 7. 余额监控 -->
<!-- 6. 余额监控 -->
<section
id="balance-monitor"
class="scroll-mt-24 lg:scroll-mt-20"
>
<h3 class="mt-0 text-xl text-[#262624] dark:text-[#f1ead8]">
7. 余额监控
6. 余额监控
</h3>
<p class="text-sm text-[#666663] dark:text-[#a3a094] mt-2">
针对各大提供商的官方接口或常见聚合平台自动抓取并记录剩余额度在余额低于阈值时触发报警或禁用策略
</p>
</section>
<!-- 8. 配置导入/ -->
<!-- 7. 配置导入/ -->
<section
id="config-export"
class="scroll-mt-24 lg:scroll-mt-20"
>
<h3 class="mt-0 text-xl text-[#262624] dark:text-[#f1ead8]">
8. 配置导入/
7. 配置导入/
</h3>
<p class="text-sm text-[#666663] dark:text-[#a3a094] mt-2">
支持将统一模型配置提供商端点及网关路由策略一键导出为 JSON并在其他部署实例中迁移导入
</p>
</section>
<!-- 9. 锁定用户密钥 -->
<!-- 8. 锁定用户密钥 -->
<section
id="lock-key"
class="scroll-mt-24 lg:scroll-mt-20"
>
<h3 class="mt-0 text-xl text-[#262624] dark:text-[#f1ead8]">
9. 锁定用户密钥
8. 锁定用户密钥
</h3>
<p class="text-sm text-[#666663] dark:text-[#a3a094] mt-2">
若监控发现恶意使用异常调用或高频报错管理员可以临时或永久锁定特定密钥以阻断攻击源头

View File

@@ -158,7 +158,6 @@ import { BookOpen } from 'lucide-vue-next'
<strong class="text-[#262624] dark:text-[#f1ead8] font-medium">熔断探测</strong><br>
当同一个提供商Key字连续若干次请求失败后会进入熔断状态之后每间N分钟进行探测请求若请求成功解除熔断后续正常请求否则按以指数级增长探测时间以待下次探测最大探测间隔不会增长超过32分钟
</li>
<li><strong class="text-[#262624] dark:text-[#f1ead8] font-medium">能力标签</strong>定义该Key可以使用的能力</li>
<li>
<strong class="text-[#262624] dark:text-[#f1ead8] font-medium">自动获取上游模型</strong><br>
在上游获取模型端点支持的情况下从接口自动获取可以用模型列表且按一定时间自动刷新不开启则默认任意模型可用或在后续模型权限中手动添加

View File

@@ -397,7 +397,7 @@ function copyStep(stepId: string, code: string) {
<h3>1. Aether-Proxy</h3>
<p>Rust实现, 超小资源占有, 适合性能低的VPS直接使用。</p>
<a
href="https://github.com/fawney19/Aether/tree/main/aether-proxy"
href="https://github.com/fawney19/Aether/tree/main/aether-tunnel"
target="_blank"
rel="noopener noreferrer"
class="text-[#cc785c] dark:text-[#d4a27f] hover:underline mt-2 inline-block"

View File

@@ -101,7 +101,7 @@ make dev
1. **Aether-Proxy**
Rust实现, 超小资源占有, 适合性能低的vps直接使用。
[https://github.com/fawney19/Aether/tree/main/aether-proxy](https://github.com/fawney19/Aether/tree/main/aether-proxy)
[https://github.com/fawney19/Aether/tree/main/aether-tunnel](https://github.com/fawney19/Aether/tree/main/aether-tunnel)
2. **代理节点**
在模块管理中, 开启代理模块后可以添加和使用代理功能, 包括手动添加和Aether-Proxy自动连接。

View File

@@ -85,7 +85,6 @@ export const guideNavItems: GuideNavItem[] = [
{ name: '请求头/体编辑', hash: '#header-body-edit' },
{ name: '模型映射', hash: '#model-mapping' },
{ name: '正则映射', hash: '#regex-mapping' },
{ name: '能力标签', hash: '#capabilities' },
{ name: '余额监控', hash: '#balance-monitor' },
{ name: '配置导入/出', hash: '#config-export' },
{ name: '锁定用户密钥', hash: '#lock-key' }

View File

@@ -90,9 +90,11 @@
:filter-provider="filterProvider"
:filter-api-format="filterApiFormat"
:filter-status="filterStatus"
:filter-client-family="filterClientFamily"
:available-users="availableUsers"
:available-models="availableModels"
:available-providers="availableProviders"
:available-client-families="availableClientFamilies"
:current-page="currentPage"
:page-size="pageSize"
:total-records="effectiveTotalRecords"
@@ -105,6 +107,7 @@
@update:filter-provider="handleFilterProviderChange"
@update:filter-api-format="handleFilterApiFormatChange"
@update:filter-status="handleFilterStatusChange"
@update:filter-client-family="handleFilterClientFamilyChange"
@update:current-page="handlePageChange"
@update:page-size="handlePageSizeChange"
@update:auto-refresh="handleAutoRefreshChange"
@@ -226,6 +229,7 @@ const filterModel = ref('__all__')
const filterProvider = ref('__all__')
const filterApiFormat = ref('__all__')
const filterStatus = ref<FilterStatusValue>('__all__')
const filterClientFamily = ref('__all__')
// 用户列表(仅管理员页面使用)
const availableUsers = ref<UserOption[]>([])
@@ -372,9 +376,15 @@ const filteredRecords = computed(() => {
records = records.filter(record => record.status === 'cancelled')
} else if (filterStatus.value === 'has_fallback') {
records = records.filter(record => hasUsageFallback(record))
} else if (filterStatus.value === 'has_retry') {
records = records.filter(record => record.has_retry === true)
}
}
if (filterClientFamily.value !== '__all__') {
records = records.filter(record => record.client_family === filterClientFamily.value)
}
return records
}
return currentRecords.value
@@ -704,6 +714,15 @@ const effectiveTotalRecords = computed(() => {
// 显示的记录
const displayRecords = computed(() => paginatedRecords.value)
const availableClientFamilies = computed(() => {
const families = new Set<string>()
currentRecords.value.forEach((record) => {
const family = record.client_family?.trim()
if (family) families.add(family)
})
return Array.from(families).sort()
})
// 详情弹窗状态
const detailModalOpen = ref(false)
@@ -787,7 +806,8 @@ function getCurrentFilters() {
model: filterModel.value !== '__all__' ? filterModel.value : undefined,
provider: filterProvider.value !== '__all__' ? filterProvider.value : undefined,
api_format: filterApiFormat.value !== '__all__' ? filterApiFormat.value : undefined,
status: filterStatus.value !== '__all__' ? filterStatus.value : undefined
status: filterStatus.value !== '__all__' ? filterStatus.value : undefined,
client_family: filterClientFamily.value !== '__all__' ? filterClientFamily.value : undefined
}
}
@@ -848,6 +868,15 @@ async function handleFilterStatusChange(value: string) {
}
}
async function handleFilterClientFamilyChange(value: string) {
filterClientFamily.value = value
currentPage.value = 1
if (isAdminPage.value) {
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters(), timeRange.value)
}
}
// 刷新数据
async function refreshData() {
if (!isPageVisible.value) return

View File

@@ -132,6 +132,13 @@
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<span class="text-sm font-medium text-foreground">{{ announcement.title }}</span>
<Badge
v-if="announcement.requires_ack"
variant="outline"
class="text-[10px] px-1.5 py-0"
>
必读
</Badge>
<Pin
v-if="announcement.is_pinned"
class="w-3.5 h-3.5 text-muted-foreground flex-shrink-0"
@@ -240,6 +247,13 @@
:class="getIconColor(announcement.type)"
/>
<span class="font-medium text-sm">{{ announcement.title }}</span>
<Badge
v-if="announcement.requires_ack"
variant="outline"
class="text-[10px] shrink-0"
>
必读
</Badge>
<Pin
v-if="announcement.is_pinned"
class="w-3.5 h-3.5 text-muted-foreground shrink-0"
@@ -433,6 +447,18 @@
class="cursor-pointer text-sm"
>置顶公告</Label>
</div>
<div class="flex items-center gap-2">
<input
id="requires-ack"
v-model="formData.requires_ack"
type="checkbox"
class="h-4 w-4 rounded border-gray-300 cursor-pointer"
>
<Label
for="requires-ack"
class="cursor-pointer text-sm"
>必读确认</Label>
</div>
<div
v-if="editingAnnouncement"
class="flex items-center gap-2"
@@ -611,7 +637,8 @@ const formData = ref({
type: 'info' as 'info' | 'warning' | 'maintenance' | 'important',
priority: 0,
is_pinned: false,
is_active: true
is_active: true,
requires_ack: false
})
onMounted(() => {
@@ -663,7 +690,8 @@ function openCreateDialog() {
type: 'info',
priority: 0,
is_pinned: false,
is_active: true
is_active: true,
requires_ack: false
}
dialogOpen.value = true
}
@@ -676,7 +704,8 @@ function openEditDialog(announcement: Announcement) {
type: announcement.type,
priority: announcement.priority,
is_pinned: announcement.is_pinned,
is_active: announcement.is_active
is_active: announcement.is_active,
requires_ack: !!announcement.requires_ack
}
dialogOpen.value = true
}

View File

@@ -0,0 +1,155 @@
<template>
<div class="space-y-6 pb-8">
<div>
<h1 class="text-2xl font-semibold text-foreground">
我的邀请
</h1>
<p class="mt-1 text-sm text-muted-foreground">
分享邀请码后符合规则的返利会进入赠款余额
</p>
</div>
<div
v-if="loading"
class="rounded-lg border border-border bg-card p-6 text-sm text-muted-foreground"
>
正在加载...
</div>
<template v-else-if="dashboard">
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
<Card class="p-5">
<p class="text-xs text-muted-foreground">
总邀请
</p>
<p class="mt-2 text-2xl font-semibold">
{{ dashboard.summary.total_invites }}
</p>
</Card>
<Card class="p-5">
<p class="text-xs text-muted-foreground">
有效邀请
</p>
<p class="mt-2 text-2xl font-semibold">
{{ dashboard.summary.effective_invites }}
</p>
</Card>
<Card class="p-5">
<p class="text-xs text-muted-foreground">
已发返利
</p>
<p class="mt-2 text-2xl font-semibold">
{{ formatUsd(dashboard.summary.paid_reward_usd) }}
</p>
</Card>
</div>
<Card class="p-5">
<div class="grid grid-cols-1 gap-4 lg:grid-cols-[240px_1fr]">
<div>
<Label class="text-xs text-muted-foreground">
邀请码
</Label>
<div class="mt-2 flex items-center gap-2">
<code class="rounded-lg border border-border bg-muted px-3 py-2 font-mono text-sm">
{{ dashboard.invite_code }}
</code>
<Button
type="button"
variant="outline"
size="sm"
@click="copyToClipboard(dashboard.invite_code)"
>
<Copy class="mr-2 h-4 w-4" />
复制
</Button>
</div>
</div>
<div>
<Label class="text-xs text-muted-foreground">
邀请链接
</Label>
<div class="mt-2 flex min-w-0 items-center gap-2">
<Input
:model-value="dashboard.invitation_link"
readonly
class="min-w-0"
/>
<Button
type="button"
variant="outline"
size="sm"
class="shrink-0"
@click="copyToClipboard(dashboard.invitation_link)"
>
<Copy class="mr-2 h-4 w-4" />
复制
</Button>
</div>
</div>
</div>
</Card>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<Card class="p-5">
<p class="text-xs text-muted-foreground">
待发返利
</p>
<p class="mt-2 text-xl font-semibold">
{{ formatUsd(dashboard.summary.pending_reward_usd) }}
</p>
</Card>
<Card class="p-5">
<p class="text-xs text-muted-foreground">
已冲回返利
</p>
<p class="mt-2 text-xl font-semibold">
{{ formatUsd(dashboard.summary.reversed_reward_usd) }}
</p>
</Card>
</div>
</template>
<div
v-else
class="rounded-lg border border-border bg-card p-6 text-sm text-muted-foreground"
>
邀请数据暂不可用
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { Copy } from 'lucide-vue-next'
import { referralApi, type ReferralDashboardResponse } from '@/api/referrals'
import { Button, Card, Input, Label } from '@/components/ui'
import { useClipboard } from '@/composables/useClipboard'
import { useToast } from '@/composables/useToast'
const dashboard = ref<ReferralDashboardResponse | null>(null)
const loading = ref(false)
const { copyToClipboard } = useClipboard()
const { error: showError } = useToast()
function formatUsd(value: number): string {
return `$${Number(value || 0).toFixed(2)}`
}
async function loadReferralDashboard() {
loading.value = true
try {
dashboard.value = await referralApi.getMyReferral()
} catch {
dashboard.value = null
showError('加载邀请数据失败')
} finally {
loading.value = false
}
}
onMounted(() => {
void loadReferralDashboard()
})
</script>

View File

@@ -387,7 +387,7 @@
<!-- eslint-disable vue/no-v-html -->
<div
class="oauth-icon shrink-0"
v-html="getOAuthIcon(p.provider_type)"
v-html="getOAuthIcon(p.provider_type, p.icon_url)"
/>
<!-- eslint-enable vue/no-v-html -->
<div class="min-w-0">