mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
Merge upstream main into feat/500-api-key-ip-whitelist
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -10,7 +10,7 @@ export interface ProxyConfig {
|
||||
url?: string
|
||||
username?: string
|
||||
password?: string
|
||||
node_id?: string // 代理节点 ID(aether-proxy 注册的节点,与 url 互斥)
|
||||
node_id?: string // 代理节点 ID(aether-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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
114
frontend/src/api/referrals.ts
Normal file
114
frontend/src/api/referrals.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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') {
|
||||
|
||||
70
frontend/src/components/stats/LeaderboardControls.vue
Normal file
70
frontend/src/components/stats/LeaderboardControls.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
<input
|
||||
type="checkbox"
|
||||
:class="checkboxClass"
|
||||
:checked="isChecked"
|
||||
v-bind="$attrs"
|
||||
:checked="isChecked"
|
||||
@change="handleChange"
|
||||
>
|
||||
</template>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
|
||||
// 处理背景点击
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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('未找到模型')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
// 获取上游流式按钮的样式类
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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('模型已添加')
|
||||
|
||||
@@ -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',
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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 对象',
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ const oauthAccountProviderTypes = new Set([
|
||||
'gemini_cli',
|
||||
'antigravity',
|
||||
'kiro',
|
||||
'grok',
|
||||
])
|
||||
|
||||
export const isOAuthAccountProviderType = (providerType?: string | null): boolean =>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
// 判断是否应该显示格式转换信息
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 },
|
||||
]
|
||||
|
||||
@@ -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',
|
||||
|
||||
30
frontend/src/utils/__tests__/providerKeyAuth.spec.ts
Normal file
30
frontend/src/utils/__tests__/providerKeyAuth.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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)')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]'
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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="{"id": "user_id", "username": "login"}"
|
||||
/>
|
||||
</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="{"id": "user_id", "username": "login"}"
|
||||
/>
|
||||
</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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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 '-'
|
||||
|
||||
@@ -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)
|
||||
|
||||
439
frontend/src/views/admin/ReferralManagement.vue
Normal file
439
frontend/src/views/admin/ReferralManagement.vue
Normal 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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
102
frontend/src/views/public/PrivacyPolicy.vue
Normal file
102
frontend/src/views/public/PrivacyPolicy.vue
Normal 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>
|
||||
@@ -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">
|
||||
若监控发现恶意使用、异常调用或高频报错,管理员可以临时或永久锁定特定密钥,以阻断攻击源头。
|
||||
|
||||
@@ -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>
|
||||
在上游获取模型端点支持的情况下,从接口自动获取可以用模型列表。且按一定时间自动刷新,不开启则默认任意模型可用,或在后续模型权限中手动添加。
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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自动连接。
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
155
frontend/src/views/user/ReferralCenter.vue
Normal file
155
frontend/src/views/user/ReferralCenter.vue
Normal 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>
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user