mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
Merge origin/main into codex/gemini-embedding-batch
# Conflicts: # apps/aether-gateway/src/ai_serving/api.rs # apps/aether-gateway/src/ai_serving/planner/passthrough/provider/family/request.rs # apps/aether-gateway/src/ai_serving/planner/standard/family/request.rs # apps/aether-gateway/src/ai_serving/transport.rs # apps/aether-gateway/src/handlers/admin/provider/query/models/model_test/summary.rs # apps/aether-gateway/src/handlers/admin/provider/query/models/model_test/tests.rs # crates/aether-data/src/repository/candidate_selection/postgres.rs # crates/aether-model-fetch/src/strategy.rs
This commit is contained in:
117
frontend/src/api/__tests__/usage-contract.spec.ts
Normal file
117
frontend/src/api/__tests__/usage-contract.spec.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { getMock, cachedRequestMock, dedupedRequestMock, buildCacheKeyMock } = vi.hoisted(() => ({
|
||||
getMock: vi.fn(),
|
||||
cachedRequestMock: vi.fn(async (_key: string, fn: () => Promise<unknown>) => fn()),
|
||||
dedupedRequestMock: vi.fn(async (_key: string, fn: () => Promise<unknown>) => fn()),
|
||||
buildCacheKeyMock: vi.fn(() => 'cache-key'),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
default: {
|
||||
get: getMock,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/cache', () => ({
|
||||
cachedRequest: cachedRequestMock,
|
||||
dedupedRequest: dedupedRequestMock,
|
||||
buildCacheKey: buildCacheKeyMock,
|
||||
}))
|
||||
|
||||
import { usageApi } from '@/api/usage'
|
||||
|
||||
describe('usageApi contract alignment', () => {
|
||||
beforeEach(() => {
|
||||
getMock.mockReset()
|
||||
cachedRequestMock.mockClear()
|
||||
dedupedRequestMock.mockClear()
|
||||
buildCacheKeyMock.mockClear()
|
||||
})
|
||||
|
||||
it('loads current-user usage records from the Rust usage endpoint and normalizes pagination', async () => {
|
||||
getMock.mockResolvedValueOnce({
|
||||
data: {
|
||||
records: [{ id: 'record-1' }],
|
||||
pagination: {
|
||||
total: 42,
|
||||
limit: 10,
|
||||
offset: 10,
|
||||
has_more: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const result = await usageApi.getUsageRecords({
|
||||
page: 2,
|
||||
page_size: 10,
|
||||
start_date: '2026-05-01',
|
||||
end_date: '2026-05-16',
|
||||
})
|
||||
|
||||
expect(getMock).toHaveBeenCalledWith('/api/users/me/usage', {
|
||||
params: {
|
||||
limit: 10,
|
||||
offset: 10,
|
||||
start_date: '2026-05-01',
|
||||
end_date: '2026-05-16',
|
||||
},
|
||||
})
|
||||
expect(result).toEqual({
|
||||
records: [{ id: 'record-1' }],
|
||||
total: 42,
|
||||
page: 2,
|
||||
page_size: 10,
|
||||
})
|
||||
})
|
||||
|
||||
it('loads admin usage for a specific user from admin usage endpoints', async () => {
|
||||
getMock
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
total_requests: 7,
|
||||
total_tokens: 99,
|
||||
total_cost: 12.34,
|
||||
avg_response_time: 456,
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
records: [{ id: 'record-2' }],
|
||||
total: 1,
|
||||
limit: 25,
|
||||
offset: 0,
|
||||
},
|
||||
})
|
||||
|
||||
const result = await usageApi.getUserUsage('user-123', {
|
||||
page: 1,
|
||||
page_size: 25,
|
||||
model: 'gpt-5.5',
|
||||
})
|
||||
|
||||
expect(getMock).toHaveBeenNthCalledWith(1, '/api/admin/usage/stats', {
|
||||
params: {
|
||||
user_id: 'user-123',
|
||||
model: 'gpt-5.5',
|
||||
},
|
||||
})
|
||||
expect(getMock).toHaveBeenNthCalledWith(2, '/api/admin/usage/records', {
|
||||
params: {
|
||||
user_id: 'user-123',
|
||||
limit: 25,
|
||||
offset: 0,
|
||||
model: 'gpt-5.5',
|
||||
},
|
||||
})
|
||||
expect(result).toEqual({
|
||||
records: [{ id: 'record-2' }],
|
||||
stats: {
|
||||
total_requests: 7,
|
||||
total_tokens: 99,
|
||||
total_cost: 12.34,
|
||||
avg_response_time: 456,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 {
|
||||
|
||||
@@ -142,6 +142,17 @@ export interface RequestErrorFlow {
|
||||
summary_source?: string | null
|
||||
}
|
||||
|
||||
export interface RequestSchedulingFailure {
|
||||
source?: string | null
|
||||
reason?: string | null
|
||||
reason_label?: string | null
|
||||
title?: string | null
|
||||
message?: string | null
|
||||
reason_summary?: string | null
|
||||
status_code?: number | null
|
||||
no_upstream_attempt?: boolean | null
|
||||
}
|
||||
|
||||
export interface RequestDetail {
|
||||
id: string // UUID
|
||||
request_id: string
|
||||
@@ -206,6 +217,7 @@ export interface RequestDetail {
|
||||
failure_summary?: RequestErrorDomain | null
|
||||
errors?: RequestErrorDomains | null
|
||||
error_flow?: RequestErrorFlow | null
|
||||
scheduling_failure?: RequestSchedulingFailure | null
|
||||
response_time_ms: number
|
||||
first_byte_time_ms?: number | null
|
||||
created_at: string
|
||||
@@ -366,7 +378,7 @@ export interface TimeRangeParams {
|
||||
export const dashboardApi = {
|
||||
// 获取仪表盘统计数据
|
||||
async getStats(params?: TimeRangeParams): Promise<DashboardStatsResponse> {
|
||||
const cacheKey = buildCacheKey('dashboard:stats', params)
|
||||
const cacheKey = buildCacheKey('dashboard:stats', params as Record<string, unknown> | undefined)
|
||||
return cachedRequest(
|
||||
cacheKey,
|
||||
async () => {
|
||||
@@ -427,7 +439,7 @@ export const dashboardApi = {
|
||||
|
||||
// 获取每日统计数据
|
||||
async getDailyStats(params?: TimeRangeParams & { days?: number }): Promise<DailyStatsResponse> {
|
||||
const cacheKey = buildCacheKey('dashboard:daily-stats', params)
|
||||
const cacheKey = buildCacheKey('dashboard:daily-stats', params as Record<string, unknown> | undefined)
|
||||
return cachedRequest(
|
||||
cacheKey,
|
||||
async () => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -91,7 +91,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 +126,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'
|
||||
|
||||
@@ -59,6 +59,7 @@ export interface Model {
|
||||
global_model_display_name?: string
|
||||
// 有效配置(合并 Model 和 GlobalModel 的 config)
|
||||
effective_config?: Record<string, unknown> | null
|
||||
model_test_capabilities?: ModelTestCapabilities | null
|
||||
}
|
||||
|
||||
export interface ModelCreate {
|
||||
@@ -102,6 +103,17 @@ export interface ModelCapabilities {
|
||||
[key: string]: boolean
|
||||
}
|
||||
|
||||
export interface OpenAiImageModelTestCapability {
|
||||
max_generation_count?: number | null
|
||||
supports_generation?: boolean | null
|
||||
supports_edit?: boolean | null
|
||||
}
|
||||
|
||||
export interface ModelTestCapabilities {
|
||||
'openai:image'?: OpenAiImageModelTestCapability | null
|
||||
[apiFormat: string]: OpenAiImageModelTestCapability | Record<string, unknown> | null | undefined
|
||||
}
|
||||
|
||||
export interface ProviderModelPriceInfo {
|
||||
input_price_per_1m?: number | null
|
||||
output_price_per_1m?: number | null
|
||||
@@ -248,6 +260,7 @@ export interface UpstreamModel {
|
||||
owned_by?: string
|
||||
display_name?: string
|
||||
api_formats: string[] // 该模型支持的所有 API 格式(后端保证返回数组)
|
||||
model_test_capabilities?: ModelTestCapabilities | null
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
// 按格式的健康度数据
|
||||
@@ -512,7 +526,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 表示不限制
|
||||
|
||||
@@ -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
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
186
frontend/src/api/routing-profiles.ts
Normal file
186
frontend/src/api/routing-profiles.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import client from './client'
|
||||
import type { RoutingDecisionTrace } from '@/features/routing/utils/routingTrace'
|
||||
import type {
|
||||
RoutingGroupConfig,
|
||||
RoutingRulePhase,
|
||||
} from '@/features/routing/utils/routingPolicy'
|
||||
|
||||
export type RoutingBindingSubjectType = 'user' | 'api_key' | 'user_group'
|
||||
|
||||
export interface RoutingGroupRecord {
|
||||
id: string
|
||||
name: string
|
||||
description?: string | null
|
||||
enabled: boolean
|
||||
is_system_default: boolean
|
||||
config_json: RoutingGroupConfig
|
||||
version: number
|
||||
created_at: number
|
||||
updated_at: number
|
||||
published_at?: number | null
|
||||
}
|
||||
|
||||
export interface RoutingGroupListResponse {
|
||||
items: RoutingGroupRecord[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface RoutingGroupVersionRecord {
|
||||
id: string
|
||||
group_id: string
|
||||
version: number
|
||||
config_json: RoutingGroupConfig
|
||||
created_at: number
|
||||
created_by?: string | null
|
||||
}
|
||||
|
||||
export interface RoutingGroupVersionListResponse {
|
||||
items: RoutingGroupVersionRecord[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface RoutingGroupBindingRecord {
|
||||
id: string
|
||||
group_id: string
|
||||
subject_type: RoutingBindingSubjectType
|
||||
subject_id: string
|
||||
is_default: boolean
|
||||
allow_explicit_select: boolean
|
||||
created_at: number
|
||||
updated_at: number
|
||||
}
|
||||
|
||||
export interface RoutingGroupBindingListResponse {
|
||||
items: RoutingGroupBindingRecord[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface RoutingGroupCreateRequest {
|
||||
id?: string
|
||||
name: string
|
||||
description?: string | null
|
||||
enabled?: boolean
|
||||
is_system_default?: boolean
|
||||
config_json?: RoutingGroupConfig
|
||||
}
|
||||
|
||||
export interface RoutingGroupUpdateRequest {
|
||||
name?: string
|
||||
description?: string | null
|
||||
enabled?: boolean
|
||||
is_system_default?: boolean
|
||||
config_json?: RoutingGroupConfig
|
||||
version?: number
|
||||
published_at?: number | null
|
||||
}
|
||||
|
||||
export interface RoutingGroupBindingCreateRequest {
|
||||
id?: string
|
||||
group_id: string
|
||||
subject_type: RoutingBindingSubjectType
|
||||
subject_id: string
|
||||
is_default?: boolean
|
||||
allow_explicit_select?: boolean
|
||||
}
|
||||
|
||||
export interface RoutingGroupBindingUpdateRequest {
|
||||
group_id?: string
|
||||
subject_type?: RoutingBindingSubjectType
|
||||
subject_id?: string
|
||||
is_default?: boolean
|
||||
allow_explicit_select?: boolean
|
||||
}
|
||||
|
||||
export interface RoutingDryRunRequest {
|
||||
model: string
|
||||
resolved_model?: string
|
||||
api_format?: string
|
||||
user_id?: string
|
||||
api_key_id?: string
|
||||
headers?: Record<string, string>
|
||||
body?: unknown
|
||||
phase?: RoutingRulePhase
|
||||
}
|
||||
|
||||
export interface RoutingDryRunResponse {
|
||||
group: RoutingGroupRecord
|
||||
policy: unknown
|
||||
trace_seed: RoutingDecisionTrace
|
||||
patch_summary: unknown
|
||||
mutated_body: unknown
|
||||
mutated_headers: Record<string, string>
|
||||
candidate_preview: unknown
|
||||
}
|
||||
|
||||
export async function listRoutingGroups(): Promise<RoutingGroupListResponse> {
|
||||
const response = await client.get<RoutingGroupListResponse>('/api/admin/routing/groups')
|
||||
return response.data
|
||||
}
|
||||
|
||||
export async function getRoutingGroup(groupId: string): Promise<RoutingGroupRecord> {
|
||||
const response = await client.get<RoutingGroupRecord>(`/api/admin/routing/groups/${groupId}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export async function createRoutingGroup(data: RoutingGroupCreateRequest): Promise<RoutingGroupRecord> {
|
||||
const response = await client.post<RoutingGroupRecord>('/api/admin/routing/groups', data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export async function updateRoutingGroup(
|
||||
groupId: string,
|
||||
data: RoutingGroupUpdateRequest
|
||||
): Promise<RoutingGroupRecord> {
|
||||
const response = await client.patch<RoutingGroupRecord>(`/api/admin/routing/groups/${groupId}`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export async function deleteRoutingGroup(groupId: string): Promise<void> {
|
||||
await client.delete(`/api/admin/routing/groups/${groupId}`)
|
||||
}
|
||||
|
||||
export async function publishRoutingGroup(groupId: string): Promise<RoutingGroupRecord> {
|
||||
const response = await client.post<RoutingGroupRecord>(`/api/admin/routing/groups/${groupId}/publish`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export async function listRoutingGroupVersions(groupId: string): Promise<RoutingGroupVersionListResponse> {
|
||||
const response = await client.get<RoutingGroupVersionListResponse>(`/api/admin/routing/groups/${groupId}/versions`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export async function dryRunRoutingGroup(
|
||||
groupId: string,
|
||||
data: RoutingDryRunRequest
|
||||
): Promise<RoutingDryRunResponse> {
|
||||
const response = await client.post<RoutingDryRunResponse>(`/api/admin/routing/groups/${groupId}/dry-run`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export async function listRoutingGroupBindings(params?: {
|
||||
group_id?: string
|
||||
subject_type?: RoutingBindingSubjectType
|
||||
subject_id?: string
|
||||
}): Promise<RoutingGroupBindingListResponse> {
|
||||
const response = await client.get<RoutingGroupBindingListResponse>('/api/admin/routing/bindings', { params })
|
||||
return response.data
|
||||
}
|
||||
|
||||
export async function createRoutingGroupBinding(
|
||||
data: RoutingGroupBindingCreateRequest
|
||||
): Promise<RoutingGroupBindingRecord> {
|
||||
const response = await client.post<RoutingGroupBindingRecord>('/api/admin/routing/bindings', data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export async function updateRoutingGroupBinding(
|
||||
bindingId: string,
|
||||
data: RoutingGroupBindingUpdateRequest
|
||||
): Promise<RoutingGroupBindingRecord> {
|
||||
const response = await client.patch<RoutingGroupBindingRecord>(`/api/admin/routing/bindings/${bindingId}`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export async function deleteRoutingGroupBinding(bindingId: string): Promise<void> {
|
||||
await client.delete(`/api/admin/routing/bindings/${bindingId}`)
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -100,16 +105,184 @@ export interface UsageFilters {
|
||||
user_id?: string // UUID
|
||||
provider_id?: string // UUID
|
||||
model?: string
|
||||
search?: string
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
preset?: string
|
||||
granularity?: 'hour' | 'day' | 'week' | 'month'
|
||||
timezone?: string
|
||||
tz_offset_minutes?: number
|
||||
client_family?: string
|
||||
page?: number
|
||||
page_size?: number
|
||||
}
|
||||
|
||||
type UsageListResponse = {
|
||||
records?: unknown
|
||||
pagination?: {
|
||||
total?: unknown
|
||||
limit?: unknown
|
||||
offset?: unknown
|
||||
}
|
||||
total?: unknown
|
||||
limit?: unknown
|
||||
offset?: unknown
|
||||
}
|
||||
|
||||
function assertPositiveInteger(value: number, field: string): number {
|
||||
if (!Number.isInteger(value) || value < 1) {
|
||||
throw new Error(`${field} must be a positive integer`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function assertNonNegativeInteger(value: number, field: string): number {
|
||||
if (!Number.isInteger(value) || value < 0) {
|
||||
throw new Error(`${field} must be a non-negative integer`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function assertNumber(value: unknown, field: string): number {
|
||||
if (typeof value !== 'number' || Number.isNaN(value)) {
|
||||
throw new Error(`Usage response is missing numeric ${field}`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function assertUsageRecords(value: unknown): UsageRecord[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new Error('Usage response is missing records array')
|
||||
}
|
||||
return value as UsageRecord[]
|
||||
}
|
||||
|
||||
function compactParams(params: Record<string, unknown>): Record<string, unknown> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(params).filter(([, value]) => value !== undefined && value !== null && value !== '')
|
||||
)
|
||||
}
|
||||
|
||||
function offsetPaginationFromPage(filters?: Pick<UsageFilters, 'page' | 'page_size'>): {
|
||||
page: number
|
||||
pageSize: number | undefined
|
||||
offset: number | undefined
|
||||
} {
|
||||
const page = assertPositiveInteger(filters?.page ?? 1, 'page')
|
||||
if (filters?.page_size === undefined) {
|
||||
return { page, pageSize: undefined, offset: undefined }
|
||||
}
|
||||
|
||||
const pageSize = assertPositiveInteger(filters.page_size, 'page_size')
|
||||
return {
|
||||
page,
|
||||
pageSize,
|
||||
offset: assertNonNegativeInteger((page - 1) * pageSize, 'offset'),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeUsageRecordPage(
|
||||
payload: UsageListResponse,
|
||||
requested: { page: number; pageSize?: number; offset?: number }
|
||||
): {
|
||||
records: UsageRecord[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
} {
|
||||
const records = assertUsageRecords(payload.records)
|
||||
const pagination = payload.pagination
|
||||
const total = assertNumber(pagination?.total ?? payload.total, 'pagination.total')
|
||||
const limit = assertPositiveInteger(
|
||||
assertNumber(pagination?.limit ?? payload.limit, 'pagination.limit'),
|
||||
'pagination.limit'
|
||||
)
|
||||
const offset = assertNonNegativeInteger(
|
||||
assertNumber(pagination?.offset ?? payload.offset, 'pagination.offset'),
|
||||
'pagination.offset'
|
||||
)
|
||||
const resolvedPage = requested.pageSize !== undefined
|
||||
? requested.page
|
||||
: Math.floor(offset / limit) + 1
|
||||
|
||||
return {
|
||||
records,
|
||||
total,
|
||||
page: resolvedPage,
|
||||
page_size: limit,
|
||||
}
|
||||
}
|
||||
|
||||
function buildCurrentUserUsageParams(filters?: UsageFilters): {
|
||||
params: Record<string, unknown>
|
||||
pagination: { page: number; pageSize?: number; offset?: number }
|
||||
} {
|
||||
if (filters?.user_id || filters?.provider_id || filters?.model || filters?.granularity) {
|
||||
throw new Error('getUsageRecords only supports current-user usage filters; use admin usage APIs for user/model/provider filters')
|
||||
}
|
||||
|
||||
const pagination = offsetPaginationFromPage(filters)
|
||||
return {
|
||||
pagination,
|
||||
params: compactParams({
|
||||
start_date: filters?.start_date,
|
||||
end_date: filters?.end_date,
|
||||
preset: filters?.preset,
|
||||
timezone: filters?.timezone,
|
||||
tz_offset_minutes: filters?.tz_offset_minutes,
|
||||
search: filters?.search,
|
||||
limit: pagination.pageSize,
|
||||
offset: pagination.offset,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
function buildAdminUsageRecordParams(userId: string, filters?: UsageFilters): {
|
||||
params: Record<string, unknown>
|
||||
} {
|
||||
if (!userId.trim()) {
|
||||
throw new Error('getUserUsage requires a non-empty user id')
|
||||
}
|
||||
if (filters?.provider_id || filters?.granularity) {
|
||||
throw new Error('getUserUsage does not support provider_id or granularity filters')
|
||||
}
|
||||
|
||||
const pagination = offsetPaginationFromPage(filters)
|
||||
return {
|
||||
params: compactParams({
|
||||
user_id: userId,
|
||||
start_date: filters?.start_date,
|
||||
end_date: filters?.end_date,
|
||||
preset: filters?.preset,
|
||||
timezone: filters?.timezone,
|
||||
tz_offset_minutes: filters?.tz_offset_minutes,
|
||||
search: filters?.search,
|
||||
model: filters?.model,
|
||||
limit: pagination.pageSize,
|
||||
offset: pagination.offset,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
function buildAdminUsageStatsParams(userId: string, filters?: UsageFilters): Record<string, unknown> {
|
||||
if (!userId.trim()) {
|
||||
throw new Error('getUserUsage requires a non-empty user id')
|
||||
}
|
||||
if (filters?.provider_id || filters?.granularity) {
|
||||
throw new Error('getUserUsage stats does not support provider_id or granularity filters')
|
||||
}
|
||||
|
||||
return compactParams({
|
||||
user_id: userId,
|
||||
start_date: filters?.start_date,
|
||||
end_date: filters?.end_date,
|
||||
preset: filters?.preset,
|
||||
timezone: filters?.timezone,
|
||||
tz_offset_minutes: filters?.tz_offset_minutes,
|
||||
model: filters?.model,
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeActivityHeatmapResponse(payload: unknown): ActivityHeatmap {
|
||||
const today = new Date()
|
||||
const endDate = today.toISOString().slice(0, 10)
|
||||
@@ -184,8 +357,9 @@ export const usageApi = {
|
||||
page: number
|
||||
page_size: number
|
||||
}> {
|
||||
const response = await apiClient.get('/api/usage', { params: filters })
|
||||
return response.data
|
||||
const { params, pagination } = buildCurrentUserUsageParams(filters)
|
||||
const response = await apiClient.get<UsageListResponse>('/api/users/me/usage', { params })
|
||||
return normalizeUsageRecordPage(response.data, pagination)
|
||||
},
|
||||
|
||||
async getUsageStats(filters?: UsageFilters): Promise<UsageStats> {
|
||||
@@ -244,16 +418,17 @@ export const usageApi = {
|
||||
records: UsageRecord[]
|
||||
stats: UsageStats
|
||||
}> {
|
||||
const response = await apiClient.get(`/api/users/${userId}/usage`, { params: filters })
|
||||
return response.data
|
||||
},
|
||||
const statsParams = buildAdminUsageStatsParams(userId, filters)
|
||||
const { params: recordParams } = buildAdminUsageRecordParams(userId, filters)
|
||||
const [statsResponse, recordsResponse] = await Promise.all([
|
||||
apiClient.get<UsageStats>('/api/admin/usage/stats', { params: statsParams }),
|
||||
apiClient.get<UsageListResponse>('/api/admin/usage/records', { params: recordParams }),
|
||||
])
|
||||
|
||||
async exportUsage(format: 'csv' | 'json', filters?: UsageFilters): Promise<Blob> {
|
||||
const response = await apiClient.get('/api/usage/export', {
|
||||
params: { ...filters, format },
|
||||
responseType: 'blob'
|
||||
})
|
||||
return response.data
|
||||
return {
|
||||
records: assertUsageRecords(recordsResponse.data.records),
|
||||
stats: statsResponse.data,
|
||||
}
|
||||
},
|
||||
|
||||
async getAllUsageRecords(params?: {
|
||||
|
||||
@@ -2,10 +2,6 @@
|
||||
import { PopoverTrigger } from 'radix-vue'
|
||||
import { useAttrs } from 'vue'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
withDefaults(defineProps<{
|
||||
asChild?: boolean
|
||||
as?: string
|
||||
@@ -14,6 +10,10 @@ withDefaults(defineProps<{
|
||||
as: 'button',
|
||||
})
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const attrs = useAttrs()
|
||||
</script>
|
||||
|
||||
|
||||
@@ -131,13 +131,19 @@
|
||||
|
||||
<!-- 登录表单 -->
|
||||
<form
|
||||
ref="loginFormEl"
|
||||
name="login"
|
||||
action="/api/auth/login"
|
||||
method="post"
|
||||
class="space-y-4"
|
||||
autocomplete="on"
|
||||
data-form-type="login"
|
||||
@submit.prevent="handleLogin"
|
||||
>
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex items-center justify-between">
|
||||
<Label
|
||||
for="login-email"
|
||||
for="username"
|
||||
class="text-sm"
|
||||
>
|
||||
{{ emailLabel }}
|
||||
@@ -160,29 +166,35 @@
|
||||
</button>
|
||||
</div>
|
||||
<Input
|
||||
id="login-email"
|
||||
id="username"
|
||||
v-model="form.email"
|
||||
type="text"
|
||||
name="username"
|
||||
required
|
||||
placeholder="用户名或邮箱"
|
||||
autocomplete="off"
|
||||
autocomplete="username"
|
||||
autocapitalize="none"
|
||||
spellcheck="false"
|
||||
:disable-autofill="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label
|
||||
for="login-password"
|
||||
for="password"
|
||||
class="text-sm"
|
||||
>
|
||||
密码
|
||||
</Label>
|
||||
<Input
|
||||
id="login-password"
|
||||
id="password"
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
name="password"
|
||||
required
|
||||
placeholder="输入密码"
|
||||
autocomplete="off"
|
||||
autocomplete="current-password"
|
||||
:disable-autofill="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -229,6 +241,7 @@
|
||||
:password-policy-level="passwordPolicyLevel"
|
||||
:turnstile-enabled="turnstileEnabled"
|
||||
:turnstile-site-key="turnstileSiteKey"
|
||||
:privacy-policy="privacyPolicy"
|
||||
@success="handleRegisterSuccess"
|
||||
@switch-to-login="handleSwitchToLogin"
|
||||
/>
|
||||
@@ -236,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'
|
||||
@@ -247,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'
|
||||
@@ -262,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()
|
||||
@@ -275,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'
|
||||
@@ -288,6 +308,7 @@ const ldapEnabled = ref(false)
|
||||
const ldapExclusive = ref(false)
|
||||
|
||||
const oauthProviders = ref<OAuthProviderInfo[]>([])
|
||||
const loginFormEl = ref<HTMLFormElement | null>(null)
|
||||
|
||||
// 保存用户的认证类型偏好
|
||||
watch(authType, (newType) => {
|
||||
@@ -328,30 +349,69 @@ function fillDemoAccount(type: 'admin' | 'user') {
|
||||
form.value.password = account.password
|
||||
}
|
||||
|
||||
async function handleLogin() {
|
||||
if (!form.value.email || !form.value.password) {
|
||||
async function handleLogin(event?: Event) {
|
||||
const { email, password } = readCurrentLoginCredentials(event)
|
||||
|
||||
if (!email || !password) {
|
||||
showWarning('请输入邮箱和密码')
|
||||
return
|
||||
}
|
||||
|
||||
const success = await authStore.login(form.value.email, form.value.password, authType.value)
|
||||
const success = await authStore.login(email, password, authType.value)
|
||||
if (success) {
|
||||
const targetPath = consumeStoredRedirectPath() ?? (authStore.canAccessAdmin ? '/admin/dashboard' : '/dashboard')
|
||||
|
||||
try {
|
||||
const navigationFailure = await router.push(targetPath)
|
||||
if (navigationFailure) {
|
||||
throw navigationFailure
|
||||
}
|
||||
} catch {
|
||||
showError('登录成功,但跳转失败,请刷新页面或手动进入控制台')
|
||||
return
|
||||
}
|
||||
|
||||
showSuccess('登录成功,正在跳转...')
|
||||
|
||||
// 关闭对话框
|
||||
isOpen.value = false
|
||||
|
||||
// 延迟一下让用户看到成功消息
|
||||
setTimeout(() => {
|
||||
// 根据用户角色跳转到不同的仪表盘
|
||||
const targetPath = authStore.canAccessAdmin ? '/admin/dashboard' : '/dashboard'
|
||||
router.push(targetPath)
|
||||
}, 1000)
|
||||
} else {
|
||||
showError(authStore.error || '登录失败,请检查邮箱和密码')
|
||||
}
|
||||
}
|
||||
|
||||
function readCurrentLoginCredentials(event?: Event): { email: string; password: string } {
|
||||
const formElement = event?.currentTarget instanceof HTMLFormElement
|
||||
? event.currentTarget
|
||||
: loginFormEl.value
|
||||
|
||||
const emailInput = formElement?.elements.namedItem('username')
|
||||
const passwordInput = formElement?.elements.namedItem('password')
|
||||
|
||||
const email = emailInput instanceof HTMLInputElement
|
||||
? emailInput.value.trim()
|
||||
: form.value.email.trim()
|
||||
const password = passwordInput instanceof HTMLInputElement
|
||||
? passwordInput.value
|
||||
: form.value.password
|
||||
|
||||
form.value.email = email
|
||||
form.value.password = password
|
||||
|
||||
return { email, password }
|
||||
}
|
||||
|
||||
function consumeStoredRedirectPath(): string | null {
|
||||
const redirectPath = sessionStorage.getItem('redirectPath')
|
||||
if (redirectPath) {
|
||||
sessionStorage.removeItem('redirectPath')
|
||||
}
|
||||
if (!redirectPath || redirectPath === '/' || !redirectPath.startsWith('/') || redirectPath.startsWith('//')) {
|
||||
return null
|
||||
}
|
||||
return redirectPath
|
||||
}
|
||||
|
||||
function handleOAuthLogin(providerType: string) {
|
||||
// 如果 sessionStorage 中没有 redirectPath(用户直接点击登录而非被守卫拦截),
|
||||
// 则不设置,让 AuthCallback 使用默认跳转逻辑
|
||||
@@ -394,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
|
||||
@@ -413,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
|
||||
@@ -421,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
|
||||
|
||||
@@ -212,6 +212,45 @@
|
||||
</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>
|
||||
|
||||
<!-- 登录链接 -->
|
||||
@@ -246,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,
|
||||
@@ -261,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
|
||||
@@ -272,6 +338,7 @@ interface Props {
|
||||
passwordPolicyLevel?: PasswordPolicyLevel
|
||||
turnstileEnabled?: boolean
|
||||
turnstileSiteKey?: string | null
|
||||
privacyPolicy?: RegistrationPrivacyPolicySettings
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
@@ -286,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>()
|
||||
@@ -423,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
|
||||
@@ -502,6 +601,10 @@ const canSubmit = computed(() => {
|
||||
return false
|
||||
}
|
||||
|
||||
if (privacyPolicyEnabled.value && !privacyAccepted.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
@@ -619,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()
|
||||
@@ -743,6 +848,11 @@ const handleSubmit = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
if (privacyPolicyEnabled.value && !privacyAccepted.value) {
|
||||
showError('请先阅读并同意隐私政策')
|
||||
return
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
loadingText.value = '注册中...'
|
||||
|
||||
@@ -759,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)
|
||||
|
||||
|
||||
@@ -17,6 +17,22 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue?: string
|
||||
siteKey: string
|
||||
action?: string
|
||||
disabled?: boolean
|
||||
}>(), {
|
||||
modelValue: '',
|
||||
action: undefined,
|
||||
disabled: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
error: [message: string]
|
||||
}>()
|
||||
|
||||
const TURNSTILE_SCRIPT_URL = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'
|
||||
|
||||
type TurnstileWidgetId = string
|
||||
@@ -46,22 +62,6 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue?: string
|
||||
siteKey: string
|
||||
action?: string
|
||||
disabled?: boolean
|
||||
}>(), {
|
||||
modelValue: '',
|
||||
action: undefined,
|
||||
disabled: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
error: [message: string]
|
||||
}>()
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
const widgetId = ref<TurnstileWidgetId | null>(null)
|
||||
const errorMessage = ref('')
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, defineComponent, h, nextTick, type App } from 'vue'
|
||||
|
||||
import LoginDialog from '../LoginDialog.vue'
|
||||
|
||||
const authStoreMock = vi.hoisted(() => ({
|
||||
loading: false,
|
||||
error: '',
|
||||
canAccessAdmin: false,
|
||||
login: vi.fn(),
|
||||
}))
|
||||
|
||||
const routerPushMock = vi.hoisted(() => vi.fn())
|
||||
const toastMocks = vi.hoisted(() => ({
|
||||
success: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}))
|
||||
|
||||
const authApiMocks = vi.hoisted(() => ({
|
||||
getRegistrationSettings: vi.fn(),
|
||||
getAuthSettings: vi.fn(),
|
||||
}))
|
||||
|
||||
const oauthApiMocks = vi.hoisted(() => ({
|
||||
getProviders: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({
|
||||
push: routerPushMock,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/auth', () => ({
|
||||
useAuthStore: () => authStoreMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useToast', () => ({
|
||||
useToast: () => toastMocks,
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useSiteInfo', () => ({
|
||||
useSiteInfo: () => ({
|
||||
siteName: 'Aether',
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/config/demo', () => ({
|
||||
isDemoMode: () => false,
|
||||
DEMO_ACCOUNTS: {
|
||||
admin: { email: 'admin@demo.aether.io', password: 'demo123' },
|
||||
user: { email: 'user@demo.aether.io', password: 'demo123' },
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/api/auth', () => ({
|
||||
authApi: authApiMocks,
|
||||
}))
|
||||
|
||||
vi.mock('@/api/oauth', () => ({
|
||||
oauthApi: oauthApiMocks,
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/deviceId', () => ({
|
||||
getClientDeviceId: () => 'device-123',
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/url', () => ({
|
||||
getApiUrl: (path: string) => path,
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/oauth-icons', () => ({
|
||||
getOAuthIcon: () => '',
|
||||
}))
|
||||
|
||||
vi.mock('../RegisterDialog.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'RegisterDialogStub',
|
||||
setup() {
|
||||
return () => null
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui', async () => {
|
||||
const { defineComponent, h } = await import('vue')
|
||||
return {
|
||||
Dialog: defineComponent({
|
||||
name: 'DialogStub',
|
||||
props: {
|
||||
modelValue: { type: Boolean, default: false },
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup(props, { slots }) {
|
||||
return () => props.modelValue ? h('div', { 'data-testid': 'dialog' }, slots.default?.()) : null
|
||||
},
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/components/ui/button.vue', async () => {
|
||||
const { defineComponent, h } = await import('vue')
|
||||
return {
|
||||
default: defineComponent({
|
||||
name: 'ButtonStub',
|
||||
props: {
|
||||
disabled: { type: Boolean, default: false },
|
||||
type: { type: String, default: 'button' },
|
||||
},
|
||||
setup(props, { attrs, slots }) {
|
||||
return () => h('button', {
|
||||
...attrs,
|
||||
type: props.type,
|
||||
disabled: props.disabled,
|
||||
}, slots.default?.())
|
||||
},
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/components/ui/label.vue', async () => {
|
||||
const { defineComponent, h } = await import('vue')
|
||||
return {
|
||||
default: defineComponent({
|
||||
name: 'LabelStub',
|
||||
setup(_props, { attrs, slots }) {
|
||||
return () => h('label', attrs, slots.default?.())
|
||||
},
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
|
||||
|
||||
function mountLoginDialog() {
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const app = createApp(LoginDialog, {
|
||||
modelValue: true,
|
||||
'onUpdate:modelValue': vi.fn(),
|
||||
})
|
||||
app.mount(root)
|
||||
mountedApps.push({ app, root })
|
||||
return root
|
||||
}
|
||||
|
||||
async function settle() {
|
||||
for (let index = 0; index < 4; index += 1) {
|
||||
await Promise.resolve()
|
||||
await nextTick()
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
authStoreMock.loading = false
|
||||
authStoreMock.error = ''
|
||||
authStoreMock.canAccessAdmin = false
|
||||
authStoreMock.login.mockReset()
|
||||
routerPushMock.mockReset()
|
||||
toastMocks.success.mockReset()
|
||||
toastMocks.warning.mockReset()
|
||||
toastMocks.error.mockReset()
|
||||
authApiMocks.getRegistrationSettings.mockResolvedValue({
|
||||
enable_registration: false,
|
||||
require_email_verification: false,
|
||||
email_configured: true,
|
||||
password_policy_level: 'weak',
|
||||
turnstile_enabled: false,
|
||||
turnstile_site_key: null,
|
||||
})
|
||||
authApiMocks.getAuthSettings.mockResolvedValue({
|
||||
local_enabled: true,
|
||||
ldap_enabled: false,
|
||||
ldap_exclusive: false,
|
||||
})
|
||||
oauthApiMocks.getProviders.mockResolvedValue([])
|
||||
sessionStorage.clear()
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const { app, root } of mountedApps.splice(0)) {
|
||||
app.unmount()
|
||||
root.remove()
|
||||
}
|
||||
document.body.innerHTML = ''
|
||||
sessionStorage.clear()
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
describe('LoginDialog password manager contract', () => {
|
||||
it('exposes standard login form and field autocomplete metadata', async () => {
|
||||
const root = mountLoginDialog()
|
||||
await settle()
|
||||
|
||||
const form = root.querySelector('form')
|
||||
expect(form?.getAttribute('name')).toBe('login')
|
||||
expect(form?.getAttribute('action')).toBe('/api/auth/login')
|
||||
expect(form?.getAttribute('method')).toBe('post')
|
||||
expect(form?.getAttribute('autocomplete')).toBe('on')
|
||||
expect(form?.getAttribute('data-form-type')).toBe('login')
|
||||
|
||||
const username = root.querySelector<HTMLInputElement>('input[name="username"]')
|
||||
const password = root.querySelector<HTMLInputElement>('input[name="password"]')
|
||||
|
||||
expect(username?.id).toBe('username')
|
||||
expect(username?.getAttribute('autocomplete')).toBe('username')
|
||||
expect(username?.getAttribute('autocapitalize')).toBe('none')
|
||||
expect(username?.getAttribute('spellcheck')).toBe('false')
|
||||
expect(password?.id).toBe('password')
|
||||
expect(password?.type).toBe('password')
|
||||
expect(password?.getAttribute('autocomplete')).toBe('current-password')
|
||||
})
|
||||
|
||||
it('submits DOM-filled credentials and awaits router navigation without timer delay', async () => {
|
||||
authStoreMock.login.mockResolvedValue(true)
|
||||
routerPushMock.mockResolvedValue(undefined)
|
||||
sessionStorage.setItem('redirectPath', '/admin/dashboard')
|
||||
const root = mountLoginDialog()
|
||||
await settle()
|
||||
|
||||
const form = root.querySelector('form')
|
||||
const username = root.querySelector<HTMLInputElement>('input[name="username"]')
|
||||
const password = root.querySelector<HTMLInputElement>('input[name="password"]')
|
||||
expect(form).not.toBeNull()
|
||||
expect(username).not.toBeNull()
|
||||
expect(password).not.toBeNull()
|
||||
|
||||
username!.value = ' admin@example.com '
|
||||
password!.value = 'secret-from-manager'
|
||||
form!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
|
||||
await settle()
|
||||
|
||||
expect(authStoreMock.login).toHaveBeenCalledWith('admin@example.com', 'secret-from-manager', 'local')
|
||||
expect(routerPushMock).toHaveBeenCalledWith('/admin/dashboard')
|
||||
expect(sessionStorage.getItem('redirectPath')).toBeNull()
|
||||
expect(toastMocks.success).toHaveBeenCalledWith('登录成功,正在跳转...')
|
||||
})
|
||||
})
|
||||
@@ -16,6 +16,27 @@
|
||||
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" />
|
||||
@@ -88,7 +109,7 @@
|
||||
v-if="groupedModels.length === 0"
|
||||
class="text-center py-8 text-sm text-muted-foreground"
|
||||
>
|
||||
{{ searchQuery ? '未找到模型' : '加载中...' }}
|
||||
{{ emptyModelListText }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -108,6 +129,12 @@
|
||||
<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
|
||||
@@ -343,7 +370,7 @@
|
||||
{{ isEditMode ? '保存' : '添加' }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="selectedModel && !isEditMode"
|
||||
v-if="(selectedModel || manualModelMode) && !isEditMode"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@click="clearSelection"
|
||||
@@ -382,6 +409,7 @@ import {
|
||||
EMBEDDING_API_FORMATS,
|
||||
buildGlobalModelCreatePayload,
|
||||
buildGlobalModelUpdatePayload,
|
||||
getModelDirectoryEmptyText,
|
||||
} from './global-model-form-helpers'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -404,6 +432,8 @@ 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(() => {
|
||||
@@ -466,6 +496,14 @@ 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) {
|
||||
@@ -478,6 +516,16 @@ 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)
|
||||
|
||||
@@ -710,11 +758,15 @@ 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
|
||||
}
|
||||
@@ -729,6 +781,7 @@ watch(() => props.open, (isOpen) => {
|
||||
|
||||
// 选择模型并填充表单
|
||||
function selectModel(model: ModelsDevModelItem) {
|
||||
manualModelMode.value = false
|
||||
selectedModel.value = model
|
||||
expandedProvider.value = model.providerId
|
||||
form.value.name = model.modelId
|
||||
@@ -774,6 +827,7 @@ function selectModel(model: ModelsDevModelItem) {
|
||||
|
||||
// 清除选择(手动填写)
|
||||
function clearSelection() {
|
||||
manualModelMode.value = false
|
||||
selectedModel.value = null
|
||||
form.value = defaultForm()
|
||||
tieredPricing.value = null
|
||||
@@ -793,6 +847,8 @@ function resetForm() {
|
||||
searchQuery.value = ''
|
||||
selectedModel.value = null
|
||||
expandedProvider.value = null
|
||||
manualModelMode.value = false
|
||||
modelListLoadFailed.value = false
|
||||
}
|
||||
|
||||
// 加载模型数据(编辑模式)
|
||||
@@ -828,6 +884,14 @@ 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和名称')
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
EMBEDDING_API_FORMATS,
|
||||
buildGlobalModelCreatePayload,
|
||||
buildGlobalModelUpdatePayload,
|
||||
getModelDirectoryEmptyText,
|
||||
} from '../global-model-form-helpers'
|
||||
|
||||
const embeddingPricing = {
|
||||
@@ -59,4 +60,26 @@ 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,6 +22,19 @@ 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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -1293,6 +1293,7 @@ import type {
|
||||
AntigravityModelQuota,
|
||||
CodexUpstreamMetadata,
|
||||
ChatGPTWebUpstreamMetadata,
|
||||
GrokUpstreamMetadata,
|
||||
KiroUpstreamMetadata,
|
||||
QuotaStatusSnapshot,
|
||||
QuotaWindowSnapshot,
|
||||
@@ -1964,7 +1965,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 +2169,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 +2496,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 +2624,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 +2634,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 +2649,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 +3117,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 +3467,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()] || ''
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -355,7 +361,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: '',
|
||||
// 计费配置
|
||||
|
||||
@@ -11,24 +11,13 @@
|
||||
class="space-y-4"
|
||||
@submit.prevent="handleSubmit"
|
||||
>
|
||||
<!-- 添加模式:选择或手动创建本地全局模型 -->
|
||||
<!-- 添加模式:选择本地全局模型 -->
|
||||
<div
|
||||
v-if="!isEditing"
|
||||
class="space-y-3"
|
||||
>
|
||||
<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">
|
||||
<div class="space-y-1.5">
|
||||
<Label for="global-model">选择已有模型 *</Label>
|
||||
<Select
|
||||
:model-value="form.global_model_id"
|
||||
:disabled="loadingGlobalModels"
|
||||
@@ -48,38 +37,17 @@
|
||||
</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 && !manualGlobalModelMode"
|
||||
v-if="availableGlobalModels.length === 0 && !loadingGlobalModels"
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
没有可选择的本地全局模型。可以切换到“手动添加”继续保存。
|
||||
没有可选择的本地全局模型。请先在模型管理中添加全局模型。
|
||||
</p>
|
||||
<div class="space-y-1.5">
|
||||
<Label for="provider-model-name" class="text-xs">Provider 模型名 *</Label>
|
||||
<Label
|
||||
for="provider-model-name"
|
||||
class="text-xs"
|
||||
>Provider 模型名 *</Label>
|
||||
<Input
|
||||
id="provider-model-name"
|
||||
v-model="form.provider_model_name"
|
||||
@@ -285,7 +253,7 @@ import {
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { parseNumberInput, sortResolutionEntries } from '@/utils/form'
|
||||
import { createModel, updateModel, getProviderModels } from '@/api/endpoints/models'
|
||||
import { createGlobalModel, listGlobalModels, type GlobalModelResponse } from '@/api/global-models'
|
||||
import { listGlobalModels, type GlobalModelResponse } from '@/api/global-models'
|
||||
import TieredPricingEditor from '@/features/models/components/TieredPricingEditor.vue'
|
||||
import type { Model, TieredPricingConfig } from '@/api/endpoints'
|
||||
import {
|
||||
@@ -334,7 +302,6 @@ 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)
|
||||
@@ -369,15 +336,9 @@ 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>,
|
||||
// 能力配置
|
||||
@@ -392,7 +353,6 @@ const form = ref({
|
||||
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
|
||||
})
|
||||
|
||||
@@ -407,8 +367,6 @@ watch(() => props.open, async (newOpen) => {
|
||||
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)) : {},
|
||||
@@ -470,8 +428,6 @@ function resetForm() {
|
||||
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,
|
||||
@@ -487,7 +443,6 @@ function resetForm() {
|
||||
tieredPricingModified.value = false
|
||||
originalTieredPricing.value = ''
|
||||
availableGlobalModels.value = []
|
||||
manualGlobalModelMode.value = false
|
||||
}
|
||||
|
||||
function handleGlobalModelSelect(value: string) {
|
||||
@@ -496,16 +451,6 @@ function handleGlobalModelSelect(value: string) {
|
||||
form.value.provider_model_name = selectedModel?.name || form.value.provider_model_name
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -642,28 +587,6 @@ 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
|
||||
@@ -701,7 +624,7 @@ function handleClose(value: boolean) {
|
||||
async function handleSubmit() {
|
||||
if (submitting.value) return
|
||||
if (!isEditing.value && !canSubmitCreate.value) {
|
||||
showError(manualGlobalModelMode.value ? '请填写模型ID和 Provider 模型名' : '请选择模型并填写 Provider 模型名', '错误')
|
||||
showError('请选择模型并填写 Provider 模型名', '错误')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -734,21 +657,19 @@ async function handleSubmit() {
|
||||
showSuccess('模型配置已更新')
|
||||
} else {
|
||||
// 添加模式:只有用户修改了配置才提交 tiered_pricing,否则保持继承关系
|
||||
const selectedModel = manualGlobalModelMode.value
|
||||
? await createManualGlobalModel(finalTieredPricing, cleanConfig)
|
||||
: availableGlobalModels.value.find(m => m.id === form.value.global_model_id)
|
||||
const selectedModel = availableGlobalModels.value.find(m => m.id === form.value.global_model_id)
|
||||
if (!selectedModel) {
|
||||
showError('请选择模型,或切换到手动添加后填写模型ID', '错误')
|
||||
showError('请选择模型', '错误')
|
||||
return
|
||||
}
|
||||
await createModel(props.providerId, buildProviderModelCreatePayload({
|
||||
globalModelId: selectedModel.id,
|
||||
providerModelName: form.value.provider_model_name.trim(),
|
||||
finalTieredPricing,
|
||||
tieredPricingModified: manualGlobalModelMode.value ? false : tieredPricingModified.value,
|
||||
pricePerRequest: manualGlobalModelMode.value ? undefined : form.value.price_per_request,
|
||||
tieredPricingModified: tieredPricingModified.value,
|
||||
pricePerRequest: form.value.price_per_request,
|
||||
cleanConfig,
|
||||
configTouched: manualGlobalModelMode.value ? false : configTouched.value,
|
||||
configTouched: configTouched.value,
|
||||
supportsVision: form.value.supports_vision,
|
||||
supportsFunctionCalling: form.value.supports_function_calling,
|
||||
supportsStreaming: form.value.supports_streaming,
|
||||
|
||||
@@ -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',
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -49,7 +49,7 @@ describe('provider model form embedding helpers', () => {
|
||||
expect('supports_embedding' in payload).toBe(false)
|
||||
})
|
||||
|
||||
it('uses manually supplied provider model name in create payload', () => {
|
||||
it('uses supplied provider model name in create payload', () => {
|
||||
const payload = buildProviderModelCreatePayload({
|
||||
globalModelId: 'gm-local-manual',
|
||||
providerModelName: 'intranet-chat-model-v1',
|
||||
|
||||
@@ -374,6 +374,7 @@ import {
|
||||
isModelTestableEndpoint,
|
||||
parseModelTestRequestHeadersDraft,
|
||||
parseModelTestRequestBodyDraft,
|
||||
selectPreferredModelTestEndpoint,
|
||||
syncModelTestRequestBodyDraft,
|
||||
} from './model-test-request'
|
||||
|
||||
@@ -428,6 +429,7 @@ const deletingGroup = ref<AliasGroup | null>(null)
|
||||
const testingMapping = ref<string | null>(null)
|
||||
const pendingMappingKey = ref<string | null>(null)
|
||||
const testingModelName = ref<string | null>(null)
|
||||
const testingSourceModel = ref<Model | null>(null)
|
||||
const preselectedModelId = ref<string | null>(null)
|
||||
const selectedTestEndpoint = ref<ProviderEndpoint | null>(null)
|
||||
const testRequestHeadersDraft = ref('')
|
||||
@@ -715,6 +717,7 @@ function handleTestDialogClose() {
|
||||
modelTest.resetState()
|
||||
pendingMappingKey.value = null
|
||||
testingModelName.value = null
|
||||
testingSourceModel.value = null
|
||||
testingMapping.value = null
|
||||
selectedTestEndpoint.value = null
|
||||
mappingTestEndpoints.value = null
|
||||
@@ -737,8 +740,25 @@ function handleSelectTestEndpoint(endpointId: string) {
|
||||
syncMappingTestRequestBody()
|
||||
}
|
||||
|
||||
function findMappingTestModel(modelName: string): Model | null {
|
||||
const normalized = modelName.trim()
|
||||
if (!normalized) return null
|
||||
|
||||
return models.value.find(model => (
|
||||
model.provider_model_name === normalized
|
||||
|| model.global_model_name === normalized
|
||||
|| model.global_model_display_name === normalized
|
||||
|| (model.provider_model_mappings ?? []).some(alias => alias.name === normalized)
|
||||
)) ?? null
|
||||
}
|
||||
|
||||
// 测试映射(直连测试,带故障转移和实时进度)
|
||||
function runMappingTest(testingKey: string, modelName: string, endpointsOverride?: ProviderEndpoint[]) {
|
||||
function runMappingTest(
|
||||
testingKey: string,
|
||||
modelName: string,
|
||||
endpointsOverride?: ProviderEndpoint[],
|
||||
sourceModel?: Model | null,
|
||||
) {
|
||||
const endpoints = endpointsOverride ?? activeEndpoints.value
|
||||
if (endpoints.length === 0) {
|
||||
showError('暂无可用于测试的活跃端点')
|
||||
@@ -749,8 +769,12 @@ function runMappingTest(testingKey: string, modelName: string, endpointsOverride
|
||||
modelTest.dialogOpen.value = true
|
||||
testingMapping.value = null
|
||||
testingModelName.value = modelName
|
||||
testingSourceModel.value = sourceModel ?? findMappingTestModel(modelName)
|
||||
mappingTestEndpoints.value = endpointsOverride ?? null
|
||||
selectedTestEndpoint.value = endpoints[0] ?? null
|
||||
selectedTestEndpoint.value = selectPreferredModelTestEndpoint(
|
||||
testingSourceModel.value,
|
||||
endpoints,
|
||||
)
|
||||
testRequestHeadersResetValue.value = buildDefaultModelTestRequestHeaders()
|
||||
testRequestHeadersDraft.value = testRequestHeadersResetValue.value
|
||||
resetMappingTestRequestBody()
|
||||
@@ -762,6 +786,7 @@ function resetMappingTestRequestBody() {
|
||||
testRequestBodyResetValue.value = buildDefaultModelTestRequestBody(
|
||||
testingModelName.value,
|
||||
selectedTestEndpoint.value?.api_format,
|
||||
testingSourceModel.value,
|
||||
)
|
||||
testRequestBodyDraft.value = testRequestBodyResetValue.value
|
||||
}
|
||||
@@ -772,6 +797,7 @@ function syncMappingTestRequestBody() {
|
||||
const nextResetValue = buildDefaultModelTestRequestBody(
|
||||
testingModelName.value,
|
||||
selectedTestEndpoint.value?.api_format,
|
||||
testingSourceModel.value,
|
||||
)
|
||||
const next = syncModelTestRequestBodyDraft(
|
||||
testRequestBodyDraft.value,
|
||||
@@ -839,7 +865,7 @@ function scopedMappingEndpoints(item: CombinedMapping): ProviderEndpoint[] {
|
||||
|
||||
// 测试精确映射
|
||||
function testMapping(item: CombinedMapping, mapping: MappingItem) {
|
||||
runMappingTest(`${item.key}-${mapping.name}`, mapping.name, scopedMappingEndpoints(item))
|
||||
runMappingTest(`${item.key}-${mapping.name}`, mapping.name, scopedMappingEndpoints(item), item.group?.model)
|
||||
}
|
||||
|
||||
// 测试正则映射
|
||||
|
||||
@@ -444,7 +444,27 @@
|
||||
{{ attempt.key_name || maskKey(attempt.key_id) }}
|
||||
</div>
|
||||
<div
|
||||
v-if="attemptDetail(attempt) !== '-'"
|
||||
v-if="attemptImagePreviews(attempt).length > 0"
|
||||
class="mt-2 flex flex-wrap gap-2"
|
||||
>
|
||||
<button
|
||||
v-for="(preview, imageIndex) in attemptImagePreviews(attempt).slice(0, 3)"
|
||||
:key="`${preview.src}-${imageIndex}`"
|
||||
type="button"
|
||||
class="h-16 w-16 overflow-hidden rounded-md border border-border/60 bg-muted/30 transition-colors hover:border-primary/60"
|
||||
:title="preview.label"
|
||||
@click.stop="openImagePreview(preview)"
|
||||
>
|
||||
<img
|
||||
:src="preview.src"
|
||||
:alt="preview.label"
|
||||
class="h-full w-full object-contain"
|
||||
loading="lazy"
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="attemptDetail(attempt) !== '-'"
|
||||
class="mt-1 break-all text-muted-foreground"
|
||||
>
|
||||
{{ attemptDetail(attempt) }}
|
||||
@@ -519,6 +539,33 @@
|
||||
</td>
|
||||
<td class="px-3 py-2 text-muted-foreground">
|
||||
<div
|
||||
v-if="attemptImagePreviews(attempt).length > 0"
|
||||
class="flex flex-wrap gap-2"
|
||||
>
|
||||
<button
|
||||
v-for="(preview, imageIndex) in attemptImagePreviews(attempt).slice(0, 4)"
|
||||
:key="`${preview.src}-${imageIndex}`"
|
||||
type="button"
|
||||
class="h-14 w-14 overflow-hidden rounded-md border border-border/60 bg-muted/30 transition-colors hover:border-primary/60 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/70"
|
||||
:title="preview.label"
|
||||
@click.stop="openImagePreview(preview)"
|
||||
>
|
||||
<img
|
||||
:src="preview.src"
|
||||
:alt="preview.label"
|
||||
class="h-full w-full object-contain"
|
||||
loading="lazy"
|
||||
>
|
||||
</button>
|
||||
<span
|
||||
v-if="attemptImagePreviews(attempt).length > 4"
|
||||
class="flex h-14 items-center rounded-md border border-border/60 px-2 text-xs text-muted-foreground"
|
||||
>
|
||||
+{{ attemptImagePreviews(attempt).length - 4 }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="line-clamp-2 break-all"
|
||||
:title="attemptDetail(attempt)"
|
||||
>
|
||||
@@ -641,6 +688,36 @@
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="response-body">
|
||||
<div
|
||||
v-if="selectedInspectionImagePreviews.length > 0"
|
||||
class="mb-3 rounded-md border border-border/60 bg-muted/20 p-3"
|
||||
>
|
||||
<div class="mb-3 flex items-center justify-between gap-3 text-xs text-muted-foreground">
|
||||
<span>图片预览</span>
|
||||
<span>{{ selectedInspectionImagePreviews.length }} 张</span>
|
||||
</div>
|
||||
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
<button
|
||||
v-for="(preview, index) in selectedInspectionImagePreviews"
|
||||
:key="`${preview.src}-${index}`"
|
||||
type="button"
|
||||
class="group block overflow-hidden rounded-md border border-border/60 bg-background text-left transition-colors hover:border-primary/60 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/70"
|
||||
@click="openImagePreview(preview)"
|
||||
>
|
||||
<div class="aspect-square w-full overflow-hidden bg-muted/30">
|
||||
<img
|
||||
:src="preview.src"
|
||||
:alt="preview.label"
|
||||
class="h-full w-full object-contain"
|
||||
loading="lazy"
|
||||
>
|
||||
</div>
|
||||
<div class="border-t border-border/60 px-2 py-1 text-[11px] text-muted-foreground">
|
||||
{{ preview.label }}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<JsonContent
|
||||
:data="selectedInspectionAttempt.response_body"
|
||||
view-mode="formatted"
|
||||
@@ -673,6 +750,39 @@
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
:open="Boolean(activeImagePreview)"
|
||||
size="6xl"
|
||||
:z-index="120"
|
||||
@update:open="(val: boolean) => { if (!val) activeImagePreview = null }"
|
||||
>
|
||||
<template #header>
|
||||
<div class="border-b border-border px-6 py-4">
|
||||
<div class="text-lg font-semibold text-foreground leading-tight">
|
||||
{{ activeImagePreview?.label || '图片预览' }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex max-h-[76vh] items-center justify-center overflow-auto rounded-md bg-muted/20 p-3">
|
||||
<img
|
||||
v-if="activeImagePreview"
|
||||
:src="activeImagePreview.src"
|
||||
:alt="activeImagePreview.label"
|
||||
class="max-h-[72vh] max-w-full rounded-md object-contain"
|
||||
>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button
|
||||
variant="outline"
|
||||
@click="activeImagePreview = null"
|
||||
>
|
||||
关闭
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -698,7 +808,12 @@ import type { CandidateRecord, RequestTrace } from '@/api/requestTrace'
|
||||
import JsonContent from '@/features/usage/components/RequestDetailDrawer/JsonContent.vue'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import { useDarkMode } from '@/composables/useDarkMode'
|
||||
import { extractModelTestResponsePreview, formatModelTestDiagnostic } from './model-test-request'
|
||||
import {
|
||||
extractModelTestImagePreviews,
|
||||
extractModelTestResponsePreview,
|
||||
formatModelTestDiagnostic,
|
||||
} from './model-test-request'
|
||||
import type { ModelTestImagePreview } from './model-test-request'
|
||||
|
||||
type TestEndpointOption = {
|
||||
id: string
|
||||
@@ -816,6 +931,7 @@ const inspectionTab = ref<'request-headers' | 'request-body' | 'response-headers
|
||||
const selectedInspectionKey = ref<string | null>(null)
|
||||
const inspectionExpandDepth = ref(0)
|
||||
const inspectionCopiedStates = ref<Record<string, boolean>>({})
|
||||
const activeImagePreview = ref<ModelTestImagePreview | null>(null)
|
||||
|
||||
watch(() => props.result, () => {
|
||||
showAllAttempts.value = false
|
||||
@@ -1129,6 +1245,12 @@ const selectedInspectionAttempt = computed(() => {
|
||||
return inspectableAttempts.value[0] ?? resultAttempts.value[0] ?? null
|
||||
})
|
||||
|
||||
const selectedInspectionImagePreviews = computed(() => (
|
||||
selectedInspectionAttempt.value
|
||||
? extractModelTestImagePreviews(selectedInspectionAttempt.value.response_body)
|
||||
: []
|
||||
))
|
||||
|
||||
const resultWinningTitle = computed(() => {
|
||||
const summary = resultSummary.value
|
||||
const keyName = summary.winning_key_name || summary.winning_key_id
|
||||
@@ -1238,6 +1360,7 @@ function formatAuthType(authType: string): string {
|
||||
if (lowered === 'codex') return 'Codex OAuth'
|
||||
if (lowered === 'antigravity') return 'Antigravity OAuth'
|
||||
if (lowered === 'kiro') return 'Kiro OAuth'
|
||||
if (lowered === 'grok') return 'Grok OAuth'
|
||||
return authType
|
||||
}
|
||||
|
||||
@@ -1295,6 +1418,14 @@ function attemptDetail(attempt: TestAttemptDetail): string {
|
||||
return '-'
|
||||
}
|
||||
|
||||
function attemptImagePreviews(attempt: TestAttemptDetail): ModelTestImagePreview[] {
|
||||
return extractModelTestImagePreviews(attempt.response_body)
|
||||
}
|
||||
|
||||
function openImagePreview(preview: ModelTestImagePreview) {
|
||||
activeImagePreview.value = preview
|
||||
}
|
||||
|
||||
function inspectionKey(attempt: TestAttemptDetail): string {
|
||||
return `${attempt.candidate_index}:${attempt.retry_index ?? 0}:${attempt.key_id}`
|
||||
}
|
||||
|
||||
@@ -272,6 +272,7 @@ import {
|
||||
normalizeModelTestMappedModelSelection,
|
||||
parseModelTestRequestHeadersDraft,
|
||||
parseModelTestRequestBodyDraft,
|
||||
selectPreferredModelTestEndpoint,
|
||||
syncModelTestRequestBodyDraft,
|
||||
} from './model-test-request'
|
||||
|
||||
@@ -586,7 +587,7 @@ async function testModelConnection(model: Model) {
|
||||
}
|
||||
|
||||
pendingTestModel.value = model
|
||||
selectedTestEndpoint.value = activeEndpoints.value[0] ?? null
|
||||
selectedTestEndpoint.value = selectPreferredModelTestEndpoint(model, activeEndpoints.value)
|
||||
const requestedModelName = getModelTestRequestedModelName(model)
|
||||
selectedTestMappedModelName.value = null
|
||||
testRequestHeadersResetValue.value = buildDefaultModelTestRequestHeaders()
|
||||
@@ -594,6 +595,7 @@ async function testModelConnection(model: Model) {
|
||||
testRequestBodyResetValue.value = buildDefaultModelTestRequestBody(
|
||||
requestedModelName,
|
||||
selectedTestEndpoint.value?.api_format,
|
||||
model,
|
||||
)
|
||||
testRequestBodyDraft.value = testRequestBodyResetValue.value
|
||||
modelTest.testResult.value = null
|
||||
@@ -619,7 +621,11 @@ function syncTestRequestBodyModel() {
|
||||
if (!modelName) return
|
||||
|
||||
const resetDraft = testRequestBodyResetValue.value
|
||||
|| buildDefaultModelTestRequestBody(modelName, selectedTestEndpoint.value?.api_format)
|
||||
|| buildDefaultModelTestRequestBody(
|
||||
modelName,
|
||||
selectedTestEndpoint.value?.api_format,
|
||||
pendingTestModel.value,
|
||||
)
|
||||
const next = syncModelTestRequestBodyDraft(
|
||||
testRequestBodyDraft.value,
|
||||
testRequestBodyResetValue.value,
|
||||
@@ -637,6 +643,7 @@ function resetTestRequestBodyForSelectedEndpoint() {
|
||||
const nextResetValue = buildDefaultModelTestRequestBody(
|
||||
modelName,
|
||||
selectedTestEndpoint.value?.api_format,
|
||||
pendingTestModel.value,
|
||||
)
|
||||
const next = syncModelTestRequestBodyDraft(
|
||||
testRequestBodyDraft.value,
|
||||
|
||||
@@ -3,12 +3,15 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildDefaultModelTestRequestBody,
|
||||
buildExactModelMappingTestRequest,
|
||||
extractModelTestImagePreviews,
|
||||
extractModelTestResponsePreview,
|
||||
formatModelTestDiagnostic,
|
||||
getOpenAiImageModelTestMaxGenerationCount,
|
||||
isModelTestableEndpoint,
|
||||
isModelTestableApiFormat,
|
||||
listModelTestMappedModelOptions,
|
||||
normalizeModelTestMappedModelSelection,
|
||||
selectPreferredModelTestEndpoint,
|
||||
setModelTestRequestBodyModel,
|
||||
syncModelTestRequestBodyDraft,
|
||||
} from '../model-test-request'
|
||||
@@ -55,6 +58,41 @@ describe('buildDefaultModelTestRequestBody', () => {
|
||||
expect(body.input).toBeUndefined()
|
||||
})
|
||||
|
||||
it('uses prompt payloads for openai image api formats', () => {
|
||||
const body = JSON.parse(buildDefaultModelTestRequestBody('gpt-image-2', 'openai:image'))
|
||||
|
||||
expect(body).toEqual({
|
||||
model: 'gpt-image-2',
|
||||
prompt: 'Hello! This is a test message.',
|
||||
n: 1,
|
||||
size: '1024x1024',
|
||||
stream: true,
|
||||
})
|
||||
expect(body.messages).toBeUndefined()
|
||||
})
|
||||
|
||||
it('uses image generation tools for image models on OpenAI Responses endpoints', () => {
|
||||
const body = JSON.parse(buildDefaultModelTestRequestBody(
|
||||
'gpt-image-2',
|
||||
'openai:responses',
|
||||
{
|
||||
effective_supports_image_generation: true,
|
||||
},
|
||||
))
|
||||
|
||||
expect(body.model).toBe('gpt-image-2')
|
||||
expect(body.input).toBe('Hello! This is a test message.')
|
||||
expect(body.tools).toEqual([
|
||||
{
|
||||
type: 'image_generation',
|
||||
size: '1024x1024',
|
||||
output_format: 'png',
|
||||
},
|
||||
])
|
||||
expect(body.tool_choice).toEqual({ type: 'image_generation' })
|
||||
expect(body.messages).toBeUndefined()
|
||||
})
|
||||
|
||||
it('lists endpoint-scoped provider model mappings in test selection order', () => {
|
||||
const options = listModelTestMappedModelOptions({
|
||||
provider_model_name: 'claude-opus-4-6',
|
||||
@@ -204,6 +242,7 @@ describe('isModelTestableApiFormat', () => {
|
||||
'openai:responses',
|
||||
'claude:messages',
|
||||
'gemini:generate_content',
|
||||
'openai:image',
|
||||
'openai:embedding',
|
||||
'jina:rerank',
|
||||
])('allows synchronous model-test endpoint formats: %s', (apiFormat) => {
|
||||
@@ -211,6 +250,75 @@ describe('isModelTestableApiFormat', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('selectPreferredModelTestEndpoint', () => {
|
||||
it('prefers openai image endpoints for image generation models', () => {
|
||||
const chatEndpoint = { id: 'chat', api_format: 'openai:chat', is_active: true }
|
||||
const imageEndpoint = { id: 'image', api_format: 'openai:image', is_active: true }
|
||||
|
||||
expect(selectPreferredModelTestEndpoint({
|
||||
effective_supports_image_generation: true,
|
||||
}, [chatEndpoint, imageEndpoint])).toBe(imageEndpoint)
|
||||
})
|
||||
|
||||
it('prefers openai image endpoints from model-test capability metadata', () => {
|
||||
const chatEndpoint = { id: 'chat', api_format: 'openai:chat', is_active: true }
|
||||
const imageEndpoint = { id: 'image', api_format: 'openai:image', is_active: true }
|
||||
|
||||
expect(selectPreferredModelTestEndpoint({
|
||||
effective_supports_image_generation: false,
|
||||
model_test_capabilities: {
|
||||
'openai:image': {
|
||||
supports_generation: true,
|
||||
max_generation_count: 4,
|
||||
},
|
||||
},
|
||||
}, [chatEndpoint, imageEndpoint])).toBe(imageEndpoint)
|
||||
})
|
||||
|
||||
it('does not treat edit-only image capability as generation support', () => {
|
||||
const chatEndpoint = { id: 'chat', api_format: 'openai:chat', is_active: true }
|
||||
const imageEndpoint = { id: 'image', api_format: 'openai:image', is_active: true }
|
||||
|
||||
expect(selectPreferredModelTestEndpoint({
|
||||
effective_supports_image_generation: true,
|
||||
model_test_capabilities: {
|
||||
'openai:image': {
|
||||
supports_generation: false,
|
||||
supports_edit: true,
|
||||
max_generation_count: 4,
|
||||
},
|
||||
},
|
||||
}, [chatEndpoint, imageEndpoint])).toBe(chatEndpoint)
|
||||
})
|
||||
|
||||
it('keeps the existing endpoint order for non-image models', () => {
|
||||
const chatEndpoint = { id: 'chat', api_format: 'openai:chat', is_active: true }
|
||||
const imageEndpoint = { id: 'image', api_format: 'openai:image', is_active: true }
|
||||
|
||||
expect(selectPreferredModelTestEndpoint({
|
||||
effective_supports_image_generation: false,
|
||||
}, [chatEndpoint, imageEndpoint])).toBe(chatEndpoint)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getOpenAiImageModelTestMaxGenerationCount', () => {
|
||||
it('reads image generation count from backend capability metadata', () => {
|
||||
expect(getOpenAiImageModelTestMaxGenerationCount({
|
||||
model_test_capabilities: {
|
||||
'openai:image': {
|
||||
max_generation_count: 4,
|
||||
},
|
||||
},
|
||||
})).toBe(4)
|
||||
})
|
||||
|
||||
it('returns null when backend capability metadata is absent', () => {
|
||||
expect(getOpenAiImageModelTestMaxGenerationCount({
|
||||
effective_supports_image_generation: true,
|
||||
})).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isModelTestableEndpoint', () => {
|
||||
it('requires at least one active key compatible with the endpoint format', () => {
|
||||
const keys = [
|
||||
@@ -341,6 +449,95 @@ describe('extractModelTestResponsePreview', () => {
|
||||
})).toBe('Rerank 结果:2 条')
|
||||
})
|
||||
|
||||
it('extracts image URLs from OpenAI image responses', () => {
|
||||
expect(extractModelTestResponsePreview({
|
||||
data: [
|
||||
{
|
||||
url: 'https://example.com/generated.png',
|
||||
revised_prompt: 'A generated image',
|
||||
},
|
||||
],
|
||||
})).toBe('图片:https://example.com/generated.png')
|
||||
})
|
||||
|
||||
it('summarizes base64 image responses without dumping the image payload', () => {
|
||||
expect(extractModelTestResponsePreview({
|
||||
data: [
|
||||
{
|
||||
b64_json: 'aGVsbG8=',
|
||||
},
|
||||
],
|
||||
})).toBe('图片:base64')
|
||||
})
|
||||
|
||||
it('extracts renderable base64 image previews from OpenAI image responses', () => {
|
||||
expect(extractModelTestImagePreviews({
|
||||
data: [
|
||||
{
|
||||
b64_json: 'aGVsbG8=',
|
||||
mime_type: 'image/jpeg',
|
||||
},
|
||||
],
|
||||
})).toEqual([
|
||||
{
|
||||
src: 'data:image/jpeg;base64,aGVsbG8=',
|
||||
label: '图片 1',
|
||||
source: 'base64',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('extracts image previews from nested response image urls', () => {
|
||||
expect(extractModelTestImagePreviews({
|
||||
output: [
|
||||
{
|
||||
content: [
|
||||
{
|
||||
type: 'output_image',
|
||||
image_url: {
|
||||
url: 'https://example.com/generated.png',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})).toEqual([
|
||||
{
|
||||
src: 'https://example.com/generated.png',
|
||||
label: '图片 1',
|
||||
source: 'url',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('extracts image previews from OpenAI Responses image_generation_call results', () => {
|
||||
expect(extractModelTestResponsePreview({
|
||||
output: [
|
||||
{
|
||||
type: 'image_generation_call',
|
||||
output_format: 'png',
|
||||
result: 'aGVsbG8=',
|
||||
},
|
||||
],
|
||||
})).toBe('图片:base64')
|
||||
|
||||
expect(extractModelTestImagePreviews({
|
||||
output: [
|
||||
{
|
||||
type: 'image_generation_call',
|
||||
output_format: 'png',
|
||||
result: 'aGVsbG8=',
|
||||
},
|
||||
],
|
||||
})).toEqual([
|
||||
{
|
||||
src: 'data:image/png;base64,aGVsbG8=',
|
||||
label: '图片 1',
|
||||
source: 'base64',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('falls back to response model when no text payload exists', () => {
|
||||
expect(extractModelTestResponsePreview({
|
||||
model: 'glm-4.5-air',
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { normalizeApiFormatAlias } from '@/api/endpoints/types/api-format'
|
||||
import type { ModelTestCapabilities, OpenAiImageModelTestCapability } from '@/api/endpoints/types'
|
||||
|
||||
export type ModelTestEndpointSource = {
|
||||
api_format: string
|
||||
is_active?: boolean | null
|
||||
}
|
||||
|
||||
export type ModelTestImageSource = {
|
||||
effective_supports_image_generation?: boolean | null
|
||||
supports_image_generation?: boolean | null
|
||||
model_test_capabilities?: ModelTestCapabilities | null
|
||||
}
|
||||
|
||||
export type ModelTestKeySource = {
|
||||
api_formats?: string[] | null
|
||||
is_active?: boolean | null
|
||||
}
|
||||
|
||||
const MODEL_TEST_UNSUPPORTED_API_FORMATS = new Set([
|
||||
'openai:video',
|
||||
'gemini:video',
|
||||
'gemini:files',
|
||||
])
|
||||
|
||||
const MODEL_TEST_DIAGNOSTIC_LABELS: Record<string, string> = {
|
||||
pool_account_blocked: '账号已失效,需重新授权',
|
||||
}
|
||||
|
||||
export function normalizeModelTestStringList(values: string[] | null | undefined): string[] {
|
||||
return (values ?? [])
|
||||
.map(value => value.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
export function isModelTestableApiFormat(apiFormat: string | null | undefined): boolean {
|
||||
const normalized = normalizeApiFormatAlias(apiFormat ?? '')
|
||||
return Boolean(normalized) && !MODEL_TEST_UNSUPPORTED_API_FORMATS.has(normalized)
|
||||
}
|
||||
|
||||
export function modelTestKeySupportsEndpoint(
|
||||
key: ModelTestKeySource,
|
||||
endpoint: ModelTestEndpointSource,
|
||||
): boolean {
|
||||
if (key.is_active === false) return false
|
||||
|
||||
const endpointFormat = normalizeApiFormatAlias(endpoint.api_format)
|
||||
if (!isModelTestableApiFormat(endpointFormat)) return false
|
||||
|
||||
const keyFormats = normalizeModelTestStringList(key.api_formats)
|
||||
if (keyFormats.length === 0) return true
|
||||
|
||||
return keyFormats.some(format => normalizeApiFormatAlias(format) === endpointFormat)
|
||||
}
|
||||
|
||||
export function isModelTestableEndpoint(
|
||||
endpoint: ModelTestEndpointSource,
|
||||
keys: ModelTestKeySource[],
|
||||
): boolean {
|
||||
return endpoint.is_active !== false
|
||||
&& isModelTestableApiFormat(endpoint.api_format)
|
||||
&& keys.some(key => modelTestKeySupportsEndpoint(key, endpoint))
|
||||
}
|
||||
|
||||
export function selectPreferredModelTestEndpoint<T extends ModelTestEndpointSource>(
|
||||
model: ModelTestImageSource | null | undefined,
|
||||
endpoints: T[],
|
||||
): T | null {
|
||||
if (modelSupportsImageGeneration(model)) {
|
||||
const imageEndpoint = endpoints.find(
|
||||
endpoint => normalizeApiFormatAlias(endpoint.api_format) === 'openai:image',
|
||||
)
|
||||
if (imageEndpoint) return imageEndpoint
|
||||
}
|
||||
|
||||
return endpoints[0] ?? null
|
||||
}
|
||||
|
||||
export function getOpenAiImageModelTestCapability(
|
||||
model: ModelTestImageSource | null | undefined,
|
||||
): OpenAiImageModelTestCapability | null {
|
||||
const capability = model?.model_test_capabilities?.['openai:image']
|
||||
return capability && typeof capability === 'object'
|
||||
? capability as OpenAiImageModelTestCapability
|
||||
: null
|
||||
}
|
||||
|
||||
export function getOpenAiImageModelTestMaxGenerationCount(
|
||||
model: ModelTestImageSource | null | undefined,
|
||||
): number | null {
|
||||
const maxGenerationCount = getOpenAiImageModelTestCapability(model)?.max_generation_count
|
||||
return typeof maxGenerationCount === 'number' && Number.isFinite(maxGenerationCount)
|
||||
? Math.max(1, Math.floor(maxGenerationCount))
|
||||
: null
|
||||
}
|
||||
|
||||
export function formatModelTestDiagnostic(value: string | null | undefined): string {
|
||||
const normalized = value?.trim()
|
||||
if (!normalized) return ''
|
||||
return MODEL_TEST_DIAGNOSTIC_LABELS[normalized] ?? normalized
|
||||
}
|
||||
|
||||
export function modelSupportsImageGeneration(model: ModelTestImageSource | null | undefined): boolean {
|
||||
const imageCapability = getOpenAiImageModelTestCapability(model)
|
||||
if (imageCapability) {
|
||||
return imageCapability.supports_generation !== false
|
||||
}
|
||||
return Boolean(
|
||||
model?.effective_supports_image_generation ?? model?.supports_image_generation,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
const MODEL_TEST_RESPONSE_PREVIEW_MAX_LENGTH = 160
|
||||
const MODEL_TEST_IMAGE_PREVIEW_MAX_ITEMS = 6
|
||||
|
||||
type JsonRecord = Record<string, unknown>
|
||||
|
||||
export type ModelTestImagePreview = {
|
||||
src: string
|
||||
label: string
|
||||
source: 'base64' | 'url'
|
||||
}
|
||||
|
||||
export function extractModelTestResponsePreview(responseBody: unknown): string | null {
|
||||
const text = extractResponseText(responseBody)
|
||||
if (text) return text
|
||||
|
||||
const reasoning = extractResponseReasoning(responseBody)
|
||||
if (reasoning) return `推理:${reasoning}`
|
||||
|
||||
const image = extractImagePreview(responseBody)
|
||||
if (image) return image
|
||||
|
||||
const summary = extractResponseSummary(responseBody)
|
||||
if (summary) return summary
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function extractModelTestImagePreviews(responseBody: unknown): ModelTestImagePreview[] {
|
||||
const previews: ModelTestImagePreview[] = []
|
||||
collectImagePreviews(responseBody, previews, new Set(), 0)
|
||||
return previews
|
||||
}
|
||||
|
||||
function isJsonRecord(value: unknown): value is JsonRecord {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function compactPreviewText(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null
|
||||
|
||||
const normalized = value.replace(/\s+/g, ' ').trim()
|
||||
if (!normalized) return null
|
||||
|
||||
if (normalized.length <= MODEL_TEST_RESPONSE_PREVIEW_MAX_LENGTH) {
|
||||
return normalized
|
||||
}
|
||||
return `${normalized.slice(0, MODEL_TEST_RESPONSE_PREVIEW_MAX_LENGTH - 3)}...`
|
||||
}
|
||||
|
||||
function joinPreviewParts(parts: string[]): string | null {
|
||||
return compactPreviewText(parts.filter(Boolean).join(' '))
|
||||
}
|
||||
|
||||
function extractTextFromContentParts(value: unknown, depth = 0): string | null {
|
||||
if (depth > 4) return null
|
||||
|
||||
const directText = compactPreviewText(value)
|
||||
if (directText) return directText
|
||||
|
||||
if (!Array.isArray(value)) return null
|
||||
|
||||
const parts = value.flatMap((part) => {
|
||||
if (typeof part === 'string') return [part]
|
||||
if (!isJsonRecord(part)) return []
|
||||
|
||||
const text = compactPreviewText(part.text)
|
||||
?? compactPreviewText(part.content)
|
||||
?? extractTextFromContentParts(part.parts, depth + 1)
|
||||
return text ? [text] : []
|
||||
})
|
||||
|
||||
return joinPreviewParts(parts)
|
||||
}
|
||||
|
||||
function extractResponseText(responseBody: unknown, depth = 0): string | null {
|
||||
if (depth > 4 || !isJsonRecord(responseBody)) return null
|
||||
|
||||
const wrappedText = extractResponseText(responseBody.response, depth + 1)
|
||||
?? extractResponseText(responseBody.body, depth + 1)
|
||||
if (wrappedText) return wrappedText
|
||||
|
||||
const outputText = compactPreviewText(responseBody.output_text)
|
||||
if (outputText) return outputText
|
||||
|
||||
const topLevelContentText = extractTextFromContentParts(responseBody.content, depth + 1)
|
||||
if (topLevelContentText) return topLevelContentText
|
||||
|
||||
const choicesText = extractChoicesText(responseBody.choices, depth + 1)
|
||||
if (choicesText) return choicesText
|
||||
|
||||
const outputTextParts = extractOutputText(responseBody.output, depth + 1)
|
||||
if (outputTextParts) return outputTextParts
|
||||
|
||||
const candidateText = extractGeminiCandidateText(responseBody.candidates, depth + 1)
|
||||
if (candidateText) return candidateText
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function extractImagePreview(responseBody: unknown, depth = 0): string | null {
|
||||
if (depth > 4 || !isJsonRecord(responseBody)) return null
|
||||
|
||||
const wrappedPreview = extractImagePreview(responseBody.response, depth + 1)
|
||||
?? extractImagePreview(responseBody.body, depth + 1)
|
||||
if (wrappedPreview) return wrappedPreview
|
||||
|
||||
const dataPreview = extractImagePreviewFromCollection(responseBody.data, depth + 1)
|
||||
if (dataPreview) return dataPreview
|
||||
|
||||
const outputPreview = extractImagePreviewFromCollection(responseBody.output, depth + 1)
|
||||
if (outputPreview) return outputPreview
|
||||
|
||||
const imagesPreview = extractImagePreviewFromCollection(responseBody.images, depth + 1)
|
||||
if (imagesPreview) return imagesPreview
|
||||
|
||||
const contentPreview = extractImagePreviewFromContentParts(responseBody.content, depth + 1)
|
||||
if (contentPreview) return contentPreview
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function collectImagePreviews(
|
||||
value: unknown,
|
||||
previews: ModelTestImagePreview[],
|
||||
seen: Set<string>,
|
||||
depth: number,
|
||||
) {
|
||||
if (depth > 5 || previews.length >= MODEL_TEST_IMAGE_PREVIEW_MAX_ITEMS || value == null) return
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
collectImagePreviews(item, previews, seen, depth + 1)
|
||||
if (previews.length >= MODEL_TEST_IMAGE_PREVIEW_MAX_ITEMS) return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!isJsonRecord(value)) return
|
||||
|
||||
collectImagePreviewFromRecord(value, previews, seen, depth)
|
||||
|
||||
const nestedValues = [
|
||||
value.response,
|
||||
value.body,
|
||||
value.data,
|
||||
value.output,
|
||||
value.images,
|
||||
value.content,
|
||||
]
|
||||
for (const nested of nestedValues) {
|
||||
collectImagePreviews(nested, previews, seen, depth + 1)
|
||||
if (previews.length >= MODEL_TEST_IMAGE_PREVIEW_MAX_ITEMS) return
|
||||
}
|
||||
}
|
||||
|
||||
function collectImagePreviewFromRecord(
|
||||
value: JsonRecord,
|
||||
previews: ModelTestImagePreview[],
|
||||
seen: Set<string>,
|
||||
depth: number,
|
||||
) {
|
||||
const mime = imageMimeFromRecord(value)
|
||||
|
||||
const imageUrl = value.image_url
|
||||
if (typeof imageUrl === 'string') {
|
||||
pushImagePreview(previews, seen, imageUrlToPreview(imageUrl, 'url'))
|
||||
} else if (isJsonRecord(imageUrl)) {
|
||||
pushImagePreview(previews, seen, imageUrlToPreview(imageUrl.url, 'url'))
|
||||
pushImagePreview(previews, seen, base64ImageToPreview(imageUrl.b64_json, imageMimeFromRecord(imageUrl)))
|
||||
}
|
||||
|
||||
pushImagePreview(previews, seen, imageUrlToPreview(value.url, 'url'))
|
||||
pushImagePreview(previews, seen, base64ImageToPreview(value.b64_json, mime))
|
||||
pushImagePreview(previews, seen, base64ImageToPreview(value.data, mime))
|
||||
if (value.type === 'image_generation_call') {
|
||||
pushImagePreview(previews, seen, base64ImageToPreview(value.result, mime))
|
||||
}
|
||||
|
||||
if (depth <= 4) {
|
||||
collectImagePreviews(value.source, previews, seen, depth + 1)
|
||||
}
|
||||
}
|
||||
|
||||
function pushImagePreview(
|
||||
previews: ModelTestImagePreview[],
|
||||
seen: Set<string>,
|
||||
preview: ModelTestImagePreview | null,
|
||||
) {
|
||||
if (!preview || seen.has(preview.src) || previews.length >= MODEL_TEST_IMAGE_PREVIEW_MAX_ITEMS) {
|
||||
return
|
||||
}
|
||||
seen.add(preview.src)
|
||||
previews.push({
|
||||
...preview,
|
||||
label: `图片 ${previews.length + 1}`,
|
||||
})
|
||||
}
|
||||
|
||||
function imageUrlToPreview(value: unknown, source: 'url'): ModelTestImagePreview | null {
|
||||
if (typeof value !== 'string') return null
|
||||
const url = value.trim()
|
||||
if (!url) return null
|
||||
if (url.startsWith('data:image/')) {
|
||||
return { src: url, label: 'base64', source: 'base64' }
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
return { src: url, label: 'URL', source }
|
||||
}
|
||||
|
||||
function base64ImageToPreview(value: unknown, mime: string): ModelTestImagePreview | null {
|
||||
if (typeof value !== 'string') return null
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return null
|
||||
if (trimmed.startsWith('data:image/')) {
|
||||
return { src: trimmed, label: 'base64', source: 'base64' }
|
||||
}
|
||||
|
||||
const normalized = trimmed.replace(/\s+/g, '')
|
||||
if (!normalized) return null
|
||||
return {
|
||||
src: `data:${mime};base64,${normalized}`,
|
||||
label: 'base64',
|
||||
source: 'base64',
|
||||
}
|
||||
}
|
||||
|
||||
function imageMimeFromRecord(value: JsonRecord): string {
|
||||
const outputFormat = value.output_format
|
||||
if (typeof outputFormat === 'string') {
|
||||
const normalized = outputFormat.trim().toLowerCase()
|
||||
if (/^[a-z0-9.+-]+$/.test(normalized)) return `image/${normalized}`
|
||||
}
|
||||
|
||||
const raw = [
|
||||
value.mime_type,
|
||||
value.mime,
|
||||
value.media_type,
|
||||
value.content_type,
|
||||
value.type,
|
||||
].find(candidate => typeof candidate === 'string' && candidate.trim().startsWith('image/'))
|
||||
|
||||
if (typeof raw === 'string') {
|
||||
const normalized = raw.trim().toLowerCase()
|
||||
if (/^image\/[a-z0-9.+-]+$/.test(normalized)) return normalized
|
||||
}
|
||||
return 'image/png'
|
||||
}
|
||||
|
||||
function extractResponseReasoning(responseBody: unknown, depth = 0): string | null {
|
||||
if (depth > 4 || !isJsonRecord(responseBody)) return null
|
||||
|
||||
const wrappedReasoning = extractResponseReasoning(responseBody.response, depth + 1)
|
||||
?? extractResponseReasoning(responseBody.body, depth + 1)
|
||||
if (wrappedReasoning) return wrappedReasoning
|
||||
|
||||
const directReasoning = compactPreviewText(responseBody.reasoning_content)
|
||||
?? compactPreviewText(responseBody.thinking)
|
||||
if (directReasoning) return directReasoning
|
||||
|
||||
const topLevelReasoning = extractReasoningFromContentParts(responseBody.content, depth + 1)
|
||||
if (topLevelReasoning) return topLevelReasoning
|
||||
|
||||
const choicesReasoning = extractChoicesReasoning(responseBody.choices, depth + 1)
|
||||
if (choicesReasoning) return choicesReasoning
|
||||
|
||||
const outputReasoning = extractOutputReasoning(responseBody.output, depth + 1)
|
||||
if (outputReasoning) return outputReasoning
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function extractChoicesText(value: unknown, depth: number): string | null {
|
||||
if (!Array.isArray(value)) return null
|
||||
|
||||
for (const choice of value) {
|
||||
if (!isJsonRecord(choice)) continue
|
||||
|
||||
const messageText = isJsonRecord(choice.message)
|
||||
? extractTextFromContentParts(choice.message.content, depth + 1)
|
||||
: null
|
||||
const deltaText = isJsonRecord(choice.delta)
|
||||
? extractTextFromContentParts(choice.delta.content, depth + 1)
|
||||
: null
|
||||
const text = messageText ?? deltaText ?? extractTextFromContentParts(choice.text, depth + 1)
|
||||
if (text) return text
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function extractChoicesReasoning(value: unknown, depth: number): string | null {
|
||||
if (!Array.isArray(value)) return null
|
||||
|
||||
for (const choice of value) {
|
||||
if (!isJsonRecord(choice)) continue
|
||||
|
||||
const messageReasoning = isJsonRecord(choice.message)
|
||||
? extractReasoningFromMessage(choice.message, depth + 1)
|
||||
: null
|
||||
const deltaReasoning = isJsonRecord(choice.delta)
|
||||
? extractReasoningFromMessage(choice.delta, depth + 1)
|
||||
: null
|
||||
const reasoning = messageReasoning ?? deltaReasoning
|
||||
if (reasoning) return reasoning
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function extractReasoningFromMessage(message: JsonRecord, depth: number): string | null {
|
||||
return compactPreviewText(message.reasoning_content)
|
||||
?? compactPreviewText(message.thinking)
|
||||
?? extractReasoningFromContentParts(message.content, depth + 1)
|
||||
}
|
||||
|
||||
function extractOutputText(value: unknown, depth: number): string | null {
|
||||
if (!Array.isArray(value)) return null
|
||||
|
||||
for (const outputItem of value) {
|
||||
if (!isJsonRecord(outputItem)) continue
|
||||
|
||||
const contentText = extractTextFromContentParts(outputItem.content, depth + 1)
|
||||
?? extractResponseText(outputItem.response, depth + 1)
|
||||
if (contentText) return contentText
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function extractOutputReasoning(value: unknown, depth: number): string | null {
|
||||
if (!Array.isArray(value)) return null
|
||||
|
||||
for (const outputItem of value) {
|
||||
if (!isJsonRecord(outputItem)) continue
|
||||
|
||||
const reasoning = extractReasoningFromContentParts(outputItem.content, depth + 1)
|
||||
?? compactPreviewText(outputItem.reasoning_content)
|
||||
?? compactPreviewText(outputItem.thinking)
|
||||
?? extractResponseReasoning(outputItem.response, depth + 1)
|
||||
if (reasoning) return reasoning
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function extractImagePreviewFromCollection(value: unknown, depth: number): string | null {
|
||||
if (!Array.isArray(value)) return null
|
||||
|
||||
for (const item of value) {
|
||||
if (!isJsonRecord(item)) continue
|
||||
|
||||
const preview = extractImagePreviewFromRecord(item, depth + 1)
|
||||
if (preview) return preview
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function extractImagePreviewFromContentParts(value: unknown, depth: number): string | null {
|
||||
if (!Array.isArray(value)) return null
|
||||
|
||||
for (const part of value) {
|
||||
if (!isJsonRecord(part)) continue
|
||||
|
||||
const preview = extractImagePreviewFromRecord(part, depth + 1)
|
||||
if (preview) return preview
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function extractImagePreviewFromRecord(value: JsonRecord, depth: number): string | null {
|
||||
if (depth > 4) return null
|
||||
|
||||
const imageUrl = value.image_url
|
||||
if (typeof imageUrl === 'string' && imageUrl.trim()) {
|
||||
return compactPreviewText(`图片:${imageUrl}`)
|
||||
}
|
||||
if (isJsonRecord(imageUrl)) {
|
||||
const nestedUrl = compactPreviewText(imageUrl.url)
|
||||
if (nestedUrl) return `图片:${nestedUrl}`
|
||||
if (compactPreviewText(imageUrl.b64_json)) {
|
||||
return '图片:base64'
|
||||
}
|
||||
}
|
||||
|
||||
const url = compactPreviewText(value.url)
|
||||
if (url) return `图片:${url}`
|
||||
|
||||
if (compactPreviewText(value.b64_json)) {
|
||||
return '图片:base64'
|
||||
}
|
||||
|
||||
if (value.type === 'image_generation_call' && compactPreviewText(value.result)) {
|
||||
return '图片:base64'
|
||||
}
|
||||
|
||||
return extractImagePreviewFromCollection(value.data, depth + 1)
|
||||
?? extractImagePreviewFromCollection(value.images, depth + 1)
|
||||
?? extractImagePreviewFromContentParts(value.content, depth + 1)
|
||||
}
|
||||
|
||||
function extractGeminiCandidateText(value: unknown, depth: number): string | null {
|
||||
if (!Array.isArray(value)) return null
|
||||
|
||||
for (const candidate of value) {
|
||||
if (!isJsonRecord(candidate) || !isJsonRecord(candidate.content)) continue
|
||||
|
||||
const text = extractTextFromContentParts(candidate.content.parts, depth + 1)
|
||||
if (text) return text
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function extractReasoningFromContentParts(value: unknown, depth = 0): string | null {
|
||||
if (depth > 4 || !Array.isArray(value)) return null
|
||||
|
||||
const parts = value.flatMap((part) => {
|
||||
if (!isJsonRecord(part)) return []
|
||||
|
||||
const reasoning = compactPreviewText(part.reasoning_content)
|
||||
?? compactPreviewText(part.thinking)
|
||||
?? compactPreviewText(part.reasoning)
|
||||
?? extractReasoningFromContentParts(part.content, depth + 1)
|
||||
?? extractReasoningFromContentParts(part.parts, depth + 1)
|
||||
return reasoning ? [reasoning] : []
|
||||
})
|
||||
|
||||
return joinPreviewParts(parts)
|
||||
}
|
||||
|
||||
function extractResponseSummary(responseBody: unknown): string | null {
|
||||
if (!isJsonRecord(responseBody)) return null
|
||||
|
||||
if (Array.isArray(responseBody.data)) {
|
||||
const embeddingDimensions = responseBody.data
|
||||
.map(item => isJsonRecord(item) && Array.isArray(item.embedding) ? item.embedding.length : null)
|
||||
.find((size): size is number => typeof size === 'number')
|
||||
if (embeddingDimensions != null) return `Embedding 维度:${embeddingDimensions}`
|
||||
if (responseBody.data.length > 0) return `返回数据:${responseBody.data.length} 条`
|
||||
}
|
||||
|
||||
if (Array.isArray(responseBody.results)) return `Rerank 结果:${responseBody.results.length} 条`
|
||||
|
||||
const model = compactPreviewText(responseBody.model)
|
||||
if (model) return `返回模型:${model}`
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -1,9 +1,33 @@
|
||||
import { normalizeApiFormatAlias } from '@/api/endpoints/types/api-format'
|
||||
import type { ProviderModelMapping } from '@/api/endpoints/types'
|
||||
import type { TestModelRequest } from '@/api/endpoints/providers'
|
||||
import {
|
||||
modelSupportsImageGeneration,
|
||||
normalizeModelTestStringList,
|
||||
type ModelTestImageSource,
|
||||
} from './model-test-capabilities'
|
||||
|
||||
export {
|
||||
formatModelTestDiagnostic,
|
||||
getOpenAiImageModelTestCapability,
|
||||
getOpenAiImageModelTestMaxGenerationCount,
|
||||
isModelTestableApiFormat,
|
||||
isModelTestableEndpoint,
|
||||
modelTestKeySupportsEndpoint,
|
||||
selectPreferredModelTestEndpoint,
|
||||
} from './model-test-capabilities'
|
||||
export type {
|
||||
ModelTestEndpointSource,
|
||||
ModelTestImageSource,
|
||||
ModelTestKeySource,
|
||||
} from './model-test-capabilities'
|
||||
export {
|
||||
extractModelTestImagePreviews,
|
||||
extractModelTestResponsePreview,
|
||||
} from './model-test-preview'
|
||||
export type { ModelTestImagePreview } from './model-test-preview'
|
||||
|
||||
const DEFAULT_MODEL_TEST_MESSAGE = 'Hello! This is a test message.'
|
||||
const MODEL_TEST_RESPONSE_PREVIEW_MAX_LENGTH = 160
|
||||
|
||||
type ModelTestMappingSource = {
|
||||
provider_model_name: string
|
||||
@@ -15,308 +39,20 @@ type ModelTestMappingEndpoint = {
|
||||
api_format: string
|
||||
}
|
||||
|
||||
type ModelTestEndpointSource = {
|
||||
api_format: string
|
||||
is_active?: boolean | null
|
||||
}
|
||||
|
||||
type ModelTestKeySource = {
|
||||
api_formats?: string[] | null
|
||||
is_active?: boolean | null
|
||||
}
|
||||
|
||||
export type ModelTestMappedModelOption = {
|
||||
name: string
|
||||
priority: number
|
||||
}
|
||||
|
||||
const MODEL_TEST_UNSUPPORTED_API_FORMATS = new Set([
|
||||
'openai:video',
|
||||
'gemini:video',
|
||||
'gemini:files',
|
||||
])
|
||||
|
||||
const MODEL_TEST_DIAGNOSTIC_LABELS: Record<string, string> = {
|
||||
pool_account_blocked: '账号已失效,需重新授权',
|
||||
}
|
||||
|
||||
type JsonRecord = Record<string, unknown>
|
||||
|
||||
export function isModelTestableApiFormat(apiFormat: string | null | undefined): boolean {
|
||||
const normalized = normalizeApiFormatAlias(apiFormat ?? '')
|
||||
return Boolean(normalized) && !MODEL_TEST_UNSUPPORTED_API_FORMATS.has(normalized)
|
||||
}
|
||||
|
||||
export function modelTestKeySupportsEndpoint(
|
||||
key: ModelTestKeySource,
|
||||
endpoint: ModelTestEndpointSource,
|
||||
): boolean {
|
||||
if (key.is_active === false) return false
|
||||
|
||||
const endpointFormat = normalizeApiFormatAlias(endpoint.api_format)
|
||||
if (!isModelTestableApiFormat(endpointFormat)) return false
|
||||
|
||||
const keyFormats = normalizeStringList(key.api_formats ?? undefined)
|
||||
if (keyFormats.length === 0) return true
|
||||
|
||||
return keyFormats.some(format => normalizeApiFormatAlias(format) === endpointFormat)
|
||||
}
|
||||
|
||||
export function isModelTestableEndpoint(
|
||||
endpoint: ModelTestEndpointSource,
|
||||
keys: ModelTestKeySource[],
|
||||
): boolean {
|
||||
return endpoint.is_active !== false
|
||||
&& isModelTestableApiFormat(endpoint.api_format)
|
||||
&& keys.some(key => modelTestKeySupportsEndpoint(key, endpoint))
|
||||
}
|
||||
|
||||
export function formatModelTestDiagnostic(value: string | null | undefined): string {
|
||||
const normalized = value?.trim()
|
||||
if (!normalized) return ''
|
||||
return MODEL_TEST_DIAGNOSTIC_LABELS[normalized] ?? normalized
|
||||
}
|
||||
|
||||
export function extractModelTestResponsePreview(responseBody: unknown): string | null {
|
||||
const text = extractResponseText(responseBody)
|
||||
if (text) return text
|
||||
|
||||
const reasoning = extractResponseReasoning(responseBody)
|
||||
if (reasoning) return `推理:${reasoning}`
|
||||
|
||||
const summary = extractResponseSummary(responseBody)
|
||||
if (summary) return summary
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function normalizeStringList(values: string[] | undefined): string[] {
|
||||
return (values ?? [])
|
||||
.map(value => value.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function isJsonRecord(value: unknown): value is JsonRecord {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function compactPreviewText(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null
|
||||
|
||||
const normalized = value.replace(/\s+/g, ' ').trim()
|
||||
if (!normalized) return null
|
||||
|
||||
if (normalized.length <= MODEL_TEST_RESPONSE_PREVIEW_MAX_LENGTH) {
|
||||
return normalized
|
||||
}
|
||||
return `${normalized.slice(0, MODEL_TEST_RESPONSE_PREVIEW_MAX_LENGTH - 3)}...`
|
||||
}
|
||||
|
||||
function joinPreviewParts(parts: string[]): string | null {
|
||||
return compactPreviewText(parts.filter(Boolean).join(' '))
|
||||
}
|
||||
|
||||
function extractTextFromContentParts(value: unknown, depth = 0): string | null {
|
||||
if (depth > 4) return null
|
||||
|
||||
const directText = compactPreviewText(value)
|
||||
if (directText) return directText
|
||||
|
||||
if (!Array.isArray(value)) return null
|
||||
|
||||
const parts = value.flatMap((part) => {
|
||||
if (typeof part === 'string') return [part]
|
||||
if (!isJsonRecord(part)) return []
|
||||
|
||||
const text = compactPreviewText(part.text)
|
||||
?? compactPreviewText(part.content)
|
||||
?? extractTextFromContentParts(part.parts, depth + 1)
|
||||
return text ? [text] : []
|
||||
})
|
||||
|
||||
return joinPreviewParts(parts)
|
||||
}
|
||||
|
||||
function extractResponseText(responseBody: unknown, depth = 0): string | null {
|
||||
if (depth > 4 || !isJsonRecord(responseBody)) return null
|
||||
|
||||
const wrappedText = extractResponseText(responseBody.response, depth + 1)
|
||||
?? extractResponseText(responseBody.body, depth + 1)
|
||||
if (wrappedText) return wrappedText
|
||||
|
||||
const outputText = compactPreviewText(responseBody.output_text)
|
||||
if (outputText) return outputText
|
||||
|
||||
const topLevelContentText = extractTextFromContentParts(responseBody.content, depth + 1)
|
||||
if (topLevelContentText) return topLevelContentText
|
||||
|
||||
const choicesText = extractChoicesText(responseBody.choices, depth + 1)
|
||||
if (choicesText) return choicesText
|
||||
|
||||
const outputTextParts = extractOutputText(responseBody.output, depth + 1)
|
||||
if (outputTextParts) return outputTextParts
|
||||
|
||||
const candidateText = extractGeminiCandidateText(responseBody.candidates, depth + 1)
|
||||
if (candidateText) return candidateText
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function extractResponseReasoning(responseBody: unknown, depth = 0): string | null {
|
||||
if (depth > 4 || !isJsonRecord(responseBody)) return null
|
||||
|
||||
const wrappedReasoning = extractResponseReasoning(responseBody.response, depth + 1)
|
||||
?? extractResponseReasoning(responseBody.body, depth + 1)
|
||||
if (wrappedReasoning) return wrappedReasoning
|
||||
|
||||
const directReasoning = compactPreviewText(responseBody.reasoning_content)
|
||||
?? compactPreviewText(responseBody.thinking)
|
||||
if (directReasoning) return directReasoning
|
||||
|
||||
const topLevelReasoning = extractReasoningFromContentParts(responseBody.content, depth + 1)
|
||||
if (topLevelReasoning) return topLevelReasoning
|
||||
|
||||
const choicesReasoning = extractChoicesReasoning(responseBody.choices, depth + 1)
|
||||
if (choicesReasoning) return choicesReasoning
|
||||
|
||||
const outputReasoning = extractOutputReasoning(responseBody.output, depth + 1)
|
||||
if (outputReasoning) return outputReasoning
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function extractChoicesText(value: unknown, depth: number): string | null {
|
||||
if (!Array.isArray(value)) return null
|
||||
|
||||
for (const choice of value) {
|
||||
if (!isJsonRecord(choice)) continue
|
||||
|
||||
const messageText = isJsonRecord(choice.message)
|
||||
? extractTextFromContentParts(choice.message.content, depth + 1)
|
||||
: null
|
||||
const deltaText = isJsonRecord(choice.delta)
|
||||
? extractTextFromContentParts(choice.delta.content, depth + 1)
|
||||
: null
|
||||
const text = messageText ?? deltaText ?? extractTextFromContentParts(choice.text, depth + 1)
|
||||
if (text) return text
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function extractChoicesReasoning(value: unknown, depth: number): string | null {
|
||||
if (!Array.isArray(value)) return null
|
||||
|
||||
for (const choice of value) {
|
||||
if (!isJsonRecord(choice)) continue
|
||||
|
||||
const messageReasoning = isJsonRecord(choice.message)
|
||||
? extractReasoningFromMessage(choice.message, depth + 1)
|
||||
: null
|
||||
const deltaReasoning = isJsonRecord(choice.delta)
|
||||
? extractReasoningFromMessage(choice.delta, depth + 1)
|
||||
: null
|
||||
const reasoning = messageReasoning ?? deltaReasoning
|
||||
if (reasoning) return reasoning
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function extractReasoningFromMessage(message: JsonRecord, depth: number): string | null {
|
||||
return compactPreviewText(message.reasoning_content)
|
||||
?? compactPreviewText(message.thinking)
|
||||
?? extractReasoningFromContentParts(message.content, depth + 1)
|
||||
}
|
||||
|
||||
function extractOutputText(value: unknown, depth: number): string | null {
|
||||
if (!Array.isArray(value)) return null
|
||||
|
||||
for (const outputItem of value) {
|
||||
if (!isJsonRecord(outputItem)) continue
|
||||
|
||||
const contentText = extractTextFromContentParts(outputItem.content, depth + 1)
|
||||
?? extractResponseText(outputItem.response, depth + 1)
|
||||
if (contentText) return contentText
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function extractOutputReasoning(value: unknown, depth: number): string | null {
|
||||
if (!Array.isArray(value)) return null
|
||||
|
||||
for (const outputItem of value) {
|
||||
if (!isJsonRecord(outputItem)) continue
|
||||
|
||||
const reasoning = extractReasoningFromContentParts(outputItem.content, depth + 1)
|
||||
?? compactPreviewText(outputItem.reasoning_content)
|
||||
?? compactPreviewText(outputItem.thinking)
|
||||
?? extractResponseReasoning(outputItem.response, depth + 1)
|
||||
if (reasoning) return reasoning
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function extractGeminiCandidateText(value: unknown, depth: number): string | null {
|
||||
if (!Array.isArray(value)) return null
|
||||
|
||||
for (const candidate of value) {
|
||||
if (!isJsonRecord(candidate) || !isJsonRecord(candidate.content)) continue
|
||||
|
||||
const text = extractTextFromContentParts(candidate.content.parts, depth + 1)
|
||||
if (text) return text
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function extractReasoningFromContentParts(value: unknown, depth = 0): string | null {
|
||||
if (depth > 4 || !Array.isArray(value)) return null
|
||||
|
||||
const parts = value.flatMap((part) => {
|
||||
if (!isJsonRecord(part)) return []
|
||||
|
||||
const reasoning = compactPreviewText(part.reasoning_content)
|
||||
?? compactPreviewText(part.thinking)
|
||||
?? compactPreviewText(part.reasoning)
|
||||
?? extractReasoningFromContentParts(part.content, depth + 1)
|
||||
?? extractReasoningFromContentParts(part.parts, depth + 1)
|
||||
return reasoning ? [reasoning] : []
|
||||
})
|
||||
|
||||
return joinPreviewParts(parts)
|
||||
}
|
||||
|
||||
function extractResponseSummary(responseBody: unknown): string | null {
|
||||
if (!isJsonRecord(responseBody)) return null
|
||||
|
||||
if (Array.isArray(responseBody.data)) {
|
||||
const embeddingDimensions = responseBody.data
|
||||
.map(item => isJsonRecord(item) && Array.isArray(item.embedding) ? item.embedding.length : null)
|
||||
.find((size): size is number => typeof size === 'number')
|
||||
if (embeddingDimensions != null) return `Embedding 维度:${embeddingDimensions}`
|
||||
if (responseBody.data.length > 0) return `返回数据:${responseBody.data.length} 条`
|
||||
}
|
||||
|
||||
if (Array.isArray(responseBody.results)) return `Rerank 结果:${responseBody.results.length} 条`
|
||||
|
||||
const model = compactPreviewText(responseBody.model)
|
||||
if (model) return `返回模型:${model}`
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function mappingApiFormatMatches(mapping: ProviderModelMapping, endpoint: ModelTestMappingEndpoint): boolean {
|
||||
const apiFormats = normalizeStringList(mapping.api_formats)
|
||||
const apiFormats = normalizeModelTestStringList(mapping.api_formats)
|
||||
if (apiFormats.length === 0) return true
|
||||
const endpointFormat = normalizeApiFormatAlias(endpoint.api_format)
|
||||
return apiFormats.some(format => normalizeApiFormatAlias(format) === endpointFormat)
|
||||
}
|
||||
|
||||
function mappingEndpointMatches(mapping: ProviderModelMapping, endpoint: ModelTestMappingEndpoint): boolean {
|
||||
const endpointIds = normalizeStringList(mapping.endpoint_ids)
|
||||
const endpointIds = normalizeModelTestStringList(mapping.endpoint_ids)
|
||||
if (endpointIds.length === 0) return true
|
||||
return endpointIds.includes(endpoint.id)
|
||||
}
|
||||
@@ -408,7 +144,11 @@ export function buildExactModelMappingTestRequest(
|
||||
}
|
||||
}
|
||||
|
||||
export function buildDefaultModelTestRequestBody(modelName: string, apiFormat?: string | null): string {
|
||||
export function buildDefaultModelTestRequestBody(
|
||||
modelName: string,
|
||||
apiFormat?: string | null,
|
||||
model?: ModelTestImageSource | null,
|
||||
): string {
|
||||
if (apiFormat?.trim().toLowerCase().endsWith(':embedding')) {
|
||||
return JSON.stringify({
|
||||
model: modelName,
|
||||
@@ -431,6 +171,34 @@ export function buildDefaultModelTestRequestBody(modelName: string, apiFormat?:
|
||||
}, null, 2)
|
||||
}
|
||||
|
||||
if (normalizeApiFormatAlias(apiFormat ?? '') === 'openai:image') {
|
||||
return JSON.stringify({
|
||||
model: modelName,
|
||||
prompt: DEFAULT_MODEL_TEST_MESSAGE,
|
||||
n: 1,
|
||||
size: '1024x1024',
|
||||
stream: true,
|
||||
}, null, 2)
|
||||
}
|
||||
|
||||
if (normalizeApiFormatAlias(apiFormat ?? '') === 'openai:responses' && modelSupportsImageGeneration(model)) {
|
||||
return JSON.stringify({
|
||||
model: modelName,
|
||||
input: DEFAULT_MODEL_TEST_MESSAGE,
|
||||
tools: [
|
||||
{
|
||||
type: 'image_generation',
|
||||
size: '1024x1024',
|
||||
output_format: 'png',
|
||||
},
|
||||
],
|
||||
tool_choice: {
|
||||
type: 'image_generation',
|
||||
},
|
||||
stream: true,
|
||||
}, null, 2)
|
||||
}
|
||||
|
||||
return JSON.stringify({
|
||||
model: modelName,
|
||||
messages: [
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
132
frontend/src/features/routing/__tests__/routingPolicy.spec.ts
Normal file
132
frontend/src/features/routing/__tests__/routingPolicy.spec.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
DEFAULT_ROUTING_POLICY_MODEL,
|
||||
createEmptyModelPolicy,
|
||||
createEmptyRoutingGroupConfig,
|
||||
getDefaultModelPolicy,
|
||||
getModelScheduling,
|
||||
modelSchedulingRuleId,
|
||||
normalizeRoutingGroupConfig,
|
||||
setDefaultPoolPriorityOverrides,
|
||||
setDefaultProviderPriorityOverrides,
|
||||
upsertModelSchedulingRule,
|
||||
upsertModelPolicy,
|
||||
} from '../utils/routingPolicy'
|
||||
import { sortCandidateTraces, summarizeRoutingTrace, type RoutingDecisionTrace } from '../utils/routingTrace'
|
||||
|
||||
describe('routingPolicy', () => {
|
||||
it('normalizes partial configs with stable defaults', () => {
|
||||
const config = normalizeRoutingGroupConfig({
|
||||
allowed_models: ['gpt-5'],
|
||||
})
|
||||
|
||||
expect(config.default_policy.priority_mode).toBe('provider')
|
||||
expect(config.default_policy.scheduling_mode).toBe('cache_affinity')
|
||||
expect(config.allowed_models).toEqual(['gpt-5'])
|
||||
})
|
||||
|
||||
it('upserts model policies by model name', () => {
|
||||
const config = createEmptyRoutingGroupConfig()
|
||||
const next = upsertModelPolicy(config, {
|
||||
...createEmptyModelPolicy('gpt-5'),
|
||||
allowed_providers: ['provider-a'],
|
||||
})
|
||||
|
||||
expect(next.model_policies).toHaveLength(1)
|
||||
expect(next.model_policies[0].allowed_providers).toEqual(['provider-a'])
|
||||
})
|
||||
|
||||
it('stores default priority overrides on the wildcard model policy', () => {
|
||||
const config = upsertModelPolicy(createEmptyRoutingGroupConfig(), createEmptyModelPolicy('gpt-5'))
|
||||
const next = setDefaultProviderPriorityOverrides(config, {
|
||||
'provider-a': 0,
|
||||
'provider-b': 2,
|
||||
})
|
||||
|
||||
const policy = getDefaultModelPolicy(next)
|
||||
expect(policy.model).toBe(DEFAULT_ROUTING_POLICY_MODEL)
|
||||
expect(next.model_policies.map(item => item.model)).toEqual([DEFAULT_ROUTING_POLICY_MODEL, 'gpt-5'])
|
||||
expect(policy.provider_priority_overrides).toEqual({
|
||||
'provider-a': 0,
|
||||
'provider-b': 2,
|
||||
})
|
||||
})
|
||||
|
||||
it('stores pool priority overrides separately from key overrides', () => {
|
||||
const next = setDefaultPoolPriorityOverrides(createEmptyRoutingGroupConfig(), {
|
||||
'provider-pool': 3,
|
||||
})
|
||||
|
||||
const policy = getDefaultModelPolicy(next)
|
||||
expect(policy.pool_priority_overrides).toEqual({
|
||||
'provider-pool': 3,
|
||||
})
|
||||
expect(policy.key_priority_overrides).toEqual({})
|
||||
})
|
||||
|
||||
it('stores per-model scheduling as generated routing rules', () => {
|
||||
const next = upsertModelSchedulingRule(createEmptyRoutingGroupConfig(), 'gpt-5', {
|
||||
priority_mode: 'global_key',
|
||||
scheduling_mode: 'fixed_order',
|
||||
})
|
||||
|
||||
expect(next.rules).toHaveLength(1)
|
||||
expect(next.rules[0].id).toBe(modelSchedulingRuleId('gpt-5'))
|
||||
expect(next.rules[0].conditions).toEqual({
|
||||
field: 'model',
|
||||
op: 'eq',
|
||||
value: 'gpt-5',
|
||||
})
|
||||
expect(getModelScheduling(next, 'gpt-5')).toMatchObject({
|
||||
priority_mode: 'global_key',
|
||||
scheduling_mode: 'fixed_order',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('routingTrace', () => {
|
||||
it('sorts candidate traces by selected order', () => {
|
||||
const sorted = sortCandidateTraces([
|
||||
candidate('provider-b', 2),
|
||||
candidate('provider-a', 1),
|
||||
])
|
||||
|
||||
expect(sorted.map(item => item.provider_id)).toEqual(['provider-a', 'provider-b'])
|
||||
})
|
||||
|
||||
it('summarizes trace metadata', () => {
|
||||
const trace: RoutingDecisionTrace = {
|
||||
group_id: 'group-a',
|
||||
group_version: 3,
|
||||
selection_source: 'explicit',
|
||||
selected_rules: ['rule-a'],
|
||||
original_model: 'gpt-5',
|
||||
resolved_model: 'gpt-5',
|
||||
client_api_format: 'openai:chat',
|
||||
global_candidates: [candidate('provider-a', 0)],
|
||||
pool_expansion: [],
|
||||
runtime_facts: {},
|
||||
}
|
||||
|
||||
expect(summarizeRoutingTrace(trace)).toContain('分组: group-a')
|
||||
expect(summarizeRoutingTrace(trace)).toContain('候选: 1')
|
||||
})
|
||||
})
|
||||
|
||||
function candidate(providerId: string, selectedOrder: number) {
|
||||
return {
|
||||
candidate_kind: 'provider' as const,
|
||||
provider_id: providerId,
|
||||
endpoint_id: `${providerId}-endpoint`,
|
||||
model_id: 'model-a',
|
||||
key_id: `${providerId}-key`,
|
||||
selected_order: selectedOrder,
|
||||
ranking_vector: {
|
||||
provider_priority_before: selectedOrder,
|
||||
provider_priority_after: selectedOrder,
|
||||
key_priority_before: selectedOrder,
|
||||
key_priority_after: selectedOrder,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<input
|
||||
v-model="draft.model"
|
||||
class="h-10 rounded-md border border-border bg-background px-3 text-sm"
|
||||
placeholder="模型"
|
||||
>
|
||||
<input
|
||||
v-model="draft.api_format"
|
||||
class="h-10 rounded-md border border-border bg-background px-3 text-sm"
|
||||
placeholder="API 格式"
|
||||
>
|
||||
</div>
|
||||
|
||||
<RoutingTraceViewer
|
||||
v-if="trace"
|
||||
:trace="trace"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive } from 'vue'
|
||||
|
||||
import RoutingTraceViewer from './RoutingTraceViewer.vue'
|
||||
import type { RoutingDecisionTrace } from '../utils/routingTrace'
|
||||
|
||||
const props = defineProps<{
|
||||
trace?: RoutingDecisionTrace | null
|
||||
model?: string
|
||||
apiFormat?: string
|
||||
}>()
|
||||
|
||||
const draft = reactive({
|
||||
model: props.model ?? '',
|
||||
api_format: props.apiFormat ?? 'openai:chat',
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<section class="space-y-4">
|
||||
<div class="grid gap-3">
|
||||
<label class="space-y-1 text-sm">
|
||||
<span class="text-muted-foreground">允许模型</span>
|
||||
<input
|
||||
v-model="allowedModelsText"
|
||||
class="h-10 w-full rounded-md border border-border bg-background px-3 text-sm"
|
||||
placeholder="gpt-5, claude-sonnet-*"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<RoutingModelPolicyEditor
|
||||
:model-policies="config.model_policies"
|
||||
@update:model-policies="updateModelPolicies"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import RoutingModelPolicyEditor from './RoutingModelPolicyEditor.vue'
|
||||
import { normalizeRoutingGroupConfig, type RoutingGroupConfig, type RoutingModelPolicy } from '../utils/routingPolicy'
|
||||
|
||||
const props = defineProps<{
|
||||
config: RoutingGroupConfig
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:config': [value: RoutingGroupConfig]
|
||||
}>()
|
||||
|
||||
const config = computed(() => normalizeRoutingGroupConfig(props.config))
|
||||
|
||||
const allowedModelsText = computed({
|
||||
get: () => config.value.allowed_models.join(', '),
|
||||
set: value => {
|
||||
emit('update:config', {
|
||||
...config.value,
|
||||
allowed_models: value.split(',').map(item => item.trim()).filter(Boolean),
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
function updateModelPolicies(modelPolicies: RoutingModelPolicy[]) {
|
||||
emit('update:config', {
|
||||
...config.value,
|
||||
model_policies: modelPolicies,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="group in groups"
|
||||
:key="group.id"
|
||||
class="rounded-lg border border-border/60 bg-background px-4 py-3"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-medium text-foreground">
|
||||
{{ group.name }}
|
||||
</p>
|
||||
<p class="truncate text-xs text-muted-foreground">
|
||||
{{ group.description || '未填写描述' }}
|
||||
</p>
|
||||
</div>
|
||||
<span class="shrink-0 rounded-md border px-2 py-1 text-xs text-muted-foreground">
|
||||
v{{ group.version }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
export interface RoutingGroupListItem {
|
||||
id: string
|
||||
name: string
|
||||
description?: string | null
|
||||
enabled: boolean
|
||||
version: number
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
groups: RoutingGroupListItem[]
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h3 class="text-sm font-medium">
|
||||
模型策略
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-border px-3 py-1.5 text-xs"
|
||||
@click="addPolicy"
|
||||
>
|
||||
添加
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="(policy, index) in draftPolicies"
|
||||
:key="`${policy.model}-${index}`"
|
||||
class="grid gap-3 rounded-lg border border-border/60 p-3 sm:grid-cols-[1fr_1fr_auto]"
|
||||
>
|
||||
<input
|
||||
v-model="policy.model"
|
||||
class="h-9 rounded-md border border-border bg-background px-3 text-sm"
|
||||
placeholder="模型"
|
||||
@change="commit"
|
||||
>
|
||||
<input
|
||||
:value="policy.allowed_providers.join(', ')"
|
||||
class="h-9 rounded-md border border-border bg-background px-3 text-sm"
|
||||
placeholder="允许 Provider"
|
||||
@change="event => updateProviders(index, event)"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-border px-3 text-xs text-muted-foreground"
|
||||
@click="removePolicy(index)"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import { createEmptyModelPolicy, type RoutingModelPolicy } from '../utils/routingPolicy'
|
||||
|
||||
const props = defineProps<{
|
||||
modelPolicies: RoutingModelPolicy[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:model-policies': [value: RoutingModelPolicy[]]
|
||||
}>()
|
||||
|
||||
const draftPolicies = ref<RoutingModelPolicy[]>(props.modelPolicies.map(policy => ({ ...policy })))
|
||||
|
||||
watch(() => props.modelPolicies, value => {
|
||||
draftPolicies.value = value.map(policy => ({ ...policy }))
|
||||
})
|
||||
|
||||
function addPolicy() {
|
||||
draftPolicies.value.push(createEmptyModelPolicy())
|
||||
commit()
|
||||
}
|
||||
|
||||
function removePolicy(index: number) {
|
||||
draftPolicies.value.splice(index, 1)
|
||||
commit()
|
||||
}
|
||||
|
||||
function updateProviders(index: number, event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
draftPolicies.value[index].allowed_providers = target.value.split(',').map(item => item.trim()).filter(Boolean)
|
||||
commit()
|
||||
}
|
||||
|
||||
function commit() {
|
||||
emit('update:model-policies', draftPolicies.value.map(policy => ({ ...policy })))
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,884 @@
|
||||
<template>
|
||||
<section class="space-y-4">
|
||||
<div
|
||||
v-if="showPriorityMode || showSchedulingMode"
|
||||
class="grid gap-3"
|
||||
:class="showPriorityMode ? 'lg:grid-cols-[1fr_1.4fr]' : ''"
|
||||
>
|
||||
<div
|
||||
v-if="showPriorityMode"
|
||||
class="space-y-1 text-sm"
|
||||
>
|
||||
<span class="text-muted-foreground">优先级模式</span>
|
||||
<div class="grid grid-cols-2 gap-1 rounded-lg bg-muted/40 p-1">
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-9 items-center justify-center gap-2 rounded-md px-3 text-sm font-medium transition-colors"
|
||||
:class="effectivePriorityMode === 'provider'
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:bg-background/60 hover:text-foreground'"
|
||||
@click="updatePriorityMode('provider')"
|
||||
>
|
||||
<Layers class="h-4 w-4" />
|
||||
Provider
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-9 items-center justify-center gap-2 rounded-md px-3 text-sm font-medium transition-colors"
|
||||
:class="effectivePriorityMode === 'global_key'
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:bg-background/60 hover:text-foreground'"
|
||||
@click="updatePriorityMode('global_key')"
|
||||
>
|
||||
<Key class="h-4 w-4" />
|
||||
Key
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="showSchedulingMode"
|
||||
class="space-y-1 text-sm"
|
||||
>
|
||||
<span class="text-muted-foreground">调度策略</span>
|
||||
<div class="grid grid-cols-3 gap-1 rounded-lg bg-muted/40 p-1">
|
||||
<button
|
||||
v-for="mode in schedulingModes"
|
||||
:key="mode.value"
|
||||
type="button"
|
||||
class="h-9 rounded-md px-3 text-sm font-medium transition-colors"
|
||||
:class="effectiveSchedulingMode === mode.value
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:bg-background/60 hover:text-foreground'"
|
||||
@click="updateSchedulingMode(mode.value)"
|
||||
>
|
||||
{{ mode.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-border/60">
|
||||
<div class="flex flex-col gap-3 border-b border-border/60 px-4 py-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h3 class="text-sm font-medium">
|
||||
{{ effectivePriorityMode === 'provider' ? '提供商排序' : 'Key 排序' }}
|
||||
</h3>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
v-if="effectivePriorityMode === 'global_key'"
|
||||
v-model="selectedApiFormat"
|
||||
class="h-9 min-w-[180px] rounded-md border border-border bg-background px-3 text-sm"
|
||||
>
|
||||
<option
|
||||
v-for="format in apiFormats"
|
||||
:key="format"
|
||||
:value="format"
|
||||
>
|
||||
{{ formatLabel(format) }}
|
||||
</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-9 items-center gap-2 rounded-md border border-border px-3 text-xs"
|
||||
@click="refresh"
|
||||
>
|
||||
<RefreshCw
|
||||
class="h-3.5 w-3.5"
|
||||
:class="{ 'animate-spin': loading }"
|
||||
/>
|
||||
刷新
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="h-9 rounded-md border border-border px-3 text-xs text-muted-foreground"
|
||||
@click="clearActiveOverrides"
|
||||
>
|
||||
清空排序
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-h-[180px] max-h-[420px] overflow-y-auto p-3">
|
||||
<div
|
||||
v-if="loading"
|
||||
class="py-10 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
正在加载
|
||||
</div>
|
||||
<div
|
||||
v-else-if="loadError"
|
||||
class="rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive"
|
||||
>
|
||||
{{ loadError }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="effectivePriorityMode === 'provider'"
|
||||
class="space-y-2"
|
||||
>
|
||||
<div
|
||||
v-if="providerRows.length === 0"
|
||||
class="rounded-lg border border-dashed border-border/70 px-4 py-8 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
暂无 Provider
|
||||
</div>
|
||||
<div
|
||||
v-for="(row, index) in providerRows"
|
||||
v-else
|
||||
:key="row.id"
|
||||
class="group grid items-center gap-3 rounded-lg border px-3 py-2 transition-colors sm:grid-cols-[auto_auto_76px_minmax(0,1fr)_auto]"
|
||||
:class="draggedProviderId === row.id
|
||||
? 'border-primary/50 bg-primary/5 shadow-sm'
|
||||
: dragOverProviderId === row.id
|
||||
? 'border-primary/30 bg-primary/5'
|
||||
: 'border-border/50 bg-background hover:bg-muted/30'"
|
||||
draggable="true"
|
||||
@dragstart="handleProviderDragStart(row.id, $event)"
|
||||
@dragend="handleProviderDragEnd"
|
||||
@dragover.prevent="handleProviderDragOver(row.id)"
|
||||
@dragleave="handleProviderDragLeave"
|
||||
@drop="handleProviderDrop(row.id)"
|
||||
>
|
||||
<div class="cursor-grab rounded p-1 text-muted-foreground/40 transition-colors group-hover:text-muted-foreground active:cursor-grabbing">
|
||||
<GripVertical class="h-4 w-4" />
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-30"
|
||||
:disabled="index === 0"
|
||||
@click="moveProvider(row.id, -1)"
|
||||
>
|
||||
<ArrowUp class="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-30"
|
||||
:disabled="index === providerRows.length - 1"
|
||||
@click="moveProvider(row.id, 1)"
|
||||
>
|
||||
<ArrowDown class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
:value="row.priority"
|
||||
type="number"
|
||||
min="0"
|
||||
class="h-8 w-full rounded-md border border-border bg-background px-2 text-sm"
|
||||
@change="event => setProviderPriority(row.id, event)"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="truncate text-sm font-medium">{{ row.name }}</span>
|
||||
<span
|
||||
v-if="row.kind === 'pool'"
|
||||
class="rounded bg-primary/10 px-1.5 py-0.5 text-[10px] text-primary"
|
||||
>
|
||||
Pool
|
||||
</span>
|
||||
<span
|
||||
v-if="!row.is_active"
|
||||
class="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground"
|
||||
>
|
||||
停用
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-0.5 truncate text-xs text-muted-foreground">
|
||||
{{ row.id }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="hidden max-w-[240px] flex-wrap justify-end gap-1 sm:flex">
|
||||
<span
|
||||
v-for="format in row.api_formats.slice(0, 3)"
|
||||
:key="format"
|
||||
class="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground"
|
||||
>
|
||||
{{ format }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="space-y-2"
|
||||
>
|
||||
<div
|
||||
v-if="keyRows.length === 0"
|
||||
class="rounded-lg border border-dashed border-border/70 px-4 py-8 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
暂无 Key
|
||||
</div>
|
||||
<div
|
||||
v-for="(row, index) in keyRows"
|
||||
v-else
|
||||
:key="row.id"
|
||||
class="group grid items-center gap-3 rounded-lg border px-3 py-2 transition-colors sm:grid-cols-[auto_auto_76px_minmax(0,1fr)_auto]"
|
||||
:class="draggedKeyId === row.id
|
||||
? 'border-primary/50 bg-primary/5 shadow-sm'
|
||||
: dragOverKeyId === row.id
|
||||
? 'border-primary/30 bg-primary/5'
|
||||
: 'border-border/50 bg-background hover:bg-muted/30'"
|
||||
draggable="true"
|
||||
@dragstart="handleKeyDragStart(row.id, $event)"
|
||||
@dragend="handleKeyDragEnd"
|
||||
@dragover.prevent="handleKeyDragOver(row.id)"
|
||||
@dragleave="handleKeyDragLeave"
|
||||
@drop="handleKeyDrop(row.id)"
|
||||
>
|
||||
<div class="cursor-grab rounded p-1 text-muted-foreground/40 transition-colors group-hover:text-muted-foreground active:cursor-grabbing">
|
||||
<GripVertical class="h-4 w-4" />
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-30"
|
||||
:disabled="index === 0"
|
||||
@click="moveKey(row.id, -1)"
|
||||
>
|
||||
<ArrowUp class="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-30"
|
||||
:disabled="index === keyRows.length - 1"
|
||||
@click="moveKey(row.id, 1)"
|
||||
>
|
||||
<ArrowDown class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
:value="row.priority"
|
||||
type="number"
|
||||
min="0"
|
||||
class="h-8 w-full rounded-md border border-border bg-background px-2 text-sm"
|
||||
@change="event => setKeyPriority(row.id, event)"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="truncate text-sm font-medium">{{ row.name }}</span>
|
||||
<span
|
||||
v-if="!row.is_active"
|
||||
class="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground"
|
||||
>
|
||||
停用
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-0.5 truncate font-mono text-xs text-muted-foreground">
|
||||
{{ row.masked }} · {{ row.provider_name }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="hidden max-w-[240px] flex-wrap justify-end gap-1 sm:flex">
|
||||
<span
|
||||
v-for="format in row.api_formats.slice(0, 3)"
|
||||
:key="format"
|
||||
class="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground"
|
||||
>
|
||||
{{ format }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { ArrowDown, ArrowUp, GripVertical, Key, Layers, RefreshCw } from 'lucide-vue-next'
|
||||
|
||||
import client from '@/api/client'
|
||||
import {
|
||||
getProvidersSummary,
|
||||
type ProviderWithEndpointsSummary,
|
||||
} from '@/api/endpoints'
|
||||
import { formatApiFormat, normalizeApiFormatAlias, sortApiFormats } from '@/api/endpoints/types/api-format'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import {
|
||||
DEFAULT_ROUTING_POLICY_MODEL,
|
||||
getDefaultModelPolicy,
|
||||
getModelPolicy,
|
||||
normalizeRoutingGroupConfig,
|
||||
setModelKeyPriorityOverrides,
|
||||
setModelPoolPriorityOverrides,
|
||||
setModelProviderPriorityOverrides,
|
||||
type RoutingDefaultPolicy,
|
||||
type RoutingGroupConfig,
|
||||
type RoutingPriorityMode,
|
||||
type RoutingSchedulingMode,
|
||||
} from '../utils/routingPolicy'
|
||||
|
||||
interface ProviderPriorityRow {
|
||||
id: string
|
||||
name: string
|
||||
is_active: boolean
|
||||
api_formats: string[]
|
||||
priority: number
|
||||
}
|
||||
|
||||
interface KeyPriorityRow {
|
||||
id: string
|
||||
kind: 'key' | 'pool'
|
||||
target_id: string
|
||||
name: string
|
||||
masked: string
|
||||
is_active: boolean
|
||||
api_formats: string[]
|
||||
priority: number
|
||||
provider_id: string
|
||||
provider_name: string
|
||||
pool_key_count?: number
|
||||
pool_active_key_count?: number
|
||||
}
|
||||
|
||||
interface GlobalKeySource {
|
||||
id: string
|
||||
provider_id: string
|
||||
provider_name: string
|
||||
name: string
|
||||
api_key_masked: string
|
||||
internal_priority: number
|
||||
global_priority_by_format: Record<string, number> | null
|
||||
is_active: boolean
|
||||
provider_active: boolean
|
||||
api_formats: string[]
|
||||
api_format: string
|
||||
health_score: number | null
|
||||
request_count: number
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
config: RoutingGroupConfig
|
||||
model?: string
|
||||
priorityMode?: RoutingPriorityMode
|
||||
schedulingMode?: RoutingSchedulingMode
|
||||
showPriorityMode?: boolean
|
||||
showSchedulingMode?: boolean
|
||||
subtitle?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:config': [value: RoutingGroupConfig]
|
||||
'update:priority-mode': [value: RoutingPriorityMode]
|
||||
'update:scheduling-mode': [value: RoutingSchedulingMode]
|
||||
}>()
|
||||
|
||||
const schedulingModes: Array<{ value: RoutingDefaultPolicy['scheduling_mode']; label: string }> = [
|
||||
{ value: 'cache_affinity', label: '缓存亲和' },
|
||||
{ value: 'load_balance', label: '负载均衡' },
|
||||
{ value: 'fixed_order', label: '固定顺序' },
|
||||
]
|
||||
|
||||
const providers = ref<ProviderWithEndpointsSummary[]>([])
|
||||
const keysByFormat = ref<Record<string, GlobalKeySource[]>>({})
|
||||
const selectedApiFormat = ref('')
|
||||
const loadingProviders = ref(false)
|
||||
const loadingKeys = ref(false)
|
||||
const loadError = ref<string | null>(null)
|
||||
const draggedProviderId = ref<string | null>(null)
|
||||
const dragOverProviderId = ref<string | null>(null)
|
||||
const draggedKeyId = ref<string | null>(null)
|
||||
const dragOverKeyId = ref<string | null>(null)
|
||||
|
||||
const config = computed(() => normalizeRoutingGroupConfig(props.config))
|
||||
const targetModel = computed(() => props.model?.trim() || DEFAULT_ROUTING_POLICY_MODEL)
|
||||
const targetModelPolicy = computed(() => targetModel.value === DEFAULT_ROUTING_POLICY_MODEL
|
||||
? getDefaultModelPolicy(config.value)
|
||||
: getModelPolicy(config.value, targetModel.value))
|
||||
const showPriorityMode = computed(() => props.showPriorityMode !== false)
|
||||
const showSchedulingMode = computed(() => props.showSchedulingMode !== false)
|
||||
const effectivePriorityMode = computed(() => props.priorityMode ?? config.value.default_policy.priority_mode)
|
||||
const effectiveSchedulingMode = computed(() => props.schedulingMode ?? config.value.default_policy.scheduling_mode)
|
||||
const subtitle = computed(() => props.subtitle ?? '默认作用于全部模型')
|
||||
const loading = computed(() => loadingProviders.value || loadingKeys.value)
|
||||
const apiFormats = computed(() => sortApiFormats(Object.keys(keysByFormat.value)))
|
||||
const providerById = computed(() => {
|
||||
const map = new Map<string, ProviderWithEndpointsSummary>()
|
||||
for (const provider of providers.value) {
|
||||
map.set(provider.id, provider)
|
||||
}
|
||||
return map
|
||||
})
|
||||
const providerIdByName = computed(() => {
|
||||
const map = new Map<string, string>()
|
||||
for (const provider of providers.value) {
|
||||
if (!map.has(provider.name)) {
|
||||
map.set(provider.name, provider.id)
|
||||
}
|
||||
}
|
||||
return map
|
||||
})
|
||||
const poolProviderIds = computed(() => {
|
||||
const set = new Set<string>()
|
||||
for (const provider of providers.value) {
|
||||
if (provider.pool_advanced) {
|
||||
set.add(provider.id)
|
||||
}
|
||||
}
|
||||
return set
|
||||
})
|
||||
|
||||
const providerRows = computed<ProviderPriorityRow[]>(() => {
|
||||
const overrides = targetModelPolicy.value.provider_priority_overrides
|
||||
return providers.value
|
||||
.map(provider => ({
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
is_active: provider.is_active,
|
||||
api_formats: provider.api_formats ?? [],
|
||||
priority: priorityValue(overrides[provider.id], provider.provider_priority),
|
||||
}))
|
||||
.sort(comparePriorityRows)
|
||||
})
|
||||
|
||||
const keyRows = computed<KeyPriorityRow[]>(() => {
|
||||
const format = selectedApiFormat.value
|
||||
const keyOverrides = targetModelPolicy.value.key_priority_overrides
|
||||
const poolOverrides = targetModelPolicy.value.pool_priority_overrides
|
||||
const normalRows: KeyPriorityRow[] = []
|
||||
const poolGroups = new Map<string, GlobalKeySource[]>()
|
||||
|
||||
for (const key of keysByFormat.value[format] ?? []) {
|
||||
const providerId = resolveProviderId(key)
|
||||
if (isPoolManagedProvider(providerId)) {
|
||||
if (!poolGroups.has(providerId)) {
|
||||
poolGroups.set(providerId, [])
|
||||
}
|
||||
poolGroups.get(providerId)?.push(key)
|
||||
continue
|
||||
}
|
||||
normalRows.push({
|
||||
id: key.id,
|
||||
kind: 'key',
|
||||
target_id: key.id,
|
||||
name: key.name,
|
||||
masked: key.api_key_masked,
|
||||
is_active: key.is_active && key.provider_active,
|
||||
api_formats: key.api_formats,
|
||||
priority: priorityValue(keyOverrides[key.id], fallbackKeyPriority(key, format)),
|
||||
provider_id: providerId,
|
||||
provider_name: key.provider_name,
|
||||
})
|
||||
}
|
||||
|
||||
const poolRows = Array.from(poolGroups.entries()).map(([providerId, keys]) =>
|
||||
buildPoolRow(format, providerId, keys, poolOverrides)
|
||||
)
|
||||
|
||||
return [...normalRows, ...poolRows].sort(comparePriorityRows)
|
||||
})
|
||||
|
||||
watch(effectivePriorityMode, mode => {
|
||||
if (mode === 'global_key') {
|
||||
void loadGlobalKeys()
|
||||
}
|
||||
})
|
||||
|
||||
watch(apiFormats, formats => {
|
||||
if (!formats.includes(selectedApiFormat.value)) {
|
||||
selectedApiFormat.value = formats[0] ?? ''
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
void (async () => {
|
||||
await loadProviders()
|
||||
if (effectivePriorityMode.value === 'global_key') {
|
||||
await loadGlobalKeys()
|
||||
}
|
||||
})()
|
||||
})
|
||||
|
||||
function updateConfig(value: RoutingGroupConfig): void {
|
||||
emit('update:config', normalizeRoutingGroupConfig(value))
|
||||
}
|
||||
|
||||
function updateDefaultPolicy(patch: Partial<RoutingDefaultPolicy>): void {
|
||||
updateConfig({
|
||||
...config.value,
|
||||
default_policy: {
|
||||
...config.value.default_policy,
|
||||
...patch,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function updatePriorityMode(mode: RoutingPriorityMode): void {
|
||||
if (props.priorityMode != null) {
|
||||
emit('update:priority-mode', mode)
|
||||
return
|
||||
}
|
||||
updateDefaultPolicy({ priority_mode: mode })
|
||||
}
|
||||
|
||||
function updateSchedulingMode(mode: RoutingSchedulingMode): void {
|
||||
if (props.schedulingMode != null) {
|
||||
emit('update:scheduling-mode', mode)
|
||||
return
|
||||
}
|
||||
updateDefaultPolicy({ scheduling_mode: mode })
|
||||
}
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
if (effectivePriorityMode.value === 'provider') {
|
||||
await loadProviders()
|
||||
} else {
|
||||
await loadProviders()
|
||||
await loadGlobalKeys(true)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProviders(): Promise<void> {
|
||||
loadingProviders.value = true
|
||||
loadError.value = null
|
||||
try {
|
||||
const response = await getProvidersSummary({ page: 1, page_size: 9999 })
|
||||
providers.value = response.items
|
||||
} catch (err) {
|
||||
loadError.value = parseApiError(err, '加载 Provider 失败')
|
||||
providers.value = []
|
||||
} finally {
|
||||
loadingProviders.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadGlobalKeys(force = false): Promise<void> {
|
||||
if (!force && Object.keys(keysByFormat.value).length > 0) return
|
||||
loadingKeys.value = true
|
||||
loadError.value = null
|
||||
try {
|
||||
const response = await client.get<Record<string, Record<string, unknown>[]>>(
|
||||
'/api/admin/endpoints/keys/grouped-by-format',
|
||||
)
|
||||
const next: Record<string, GlobalKeySource[]> = {}
|
||||
for (const [rawFormat, rawKeys] of Object.entries(response.data ?? {})) {
|
||||
const format = normalizeFormat(rawFormat)
|
||||
if (!format) continue
|
||||
next[format] = normalizeGlobalKeys(format, rawKeys)
|
||||
}
|
||||
keysByFormat.value = next
|
||||
if (!selectedApiFormat.value || !Object.keys(next).includes(selectedApiFormat.value)) {
|
||||
selectedApiFormat.value = sortApiFormats(Object.keys(next))[0] ?? ''
|
||||
}
|
||||
} catch (err) {
|
||||
loadError.value = parseApiError(err, '加载全局 Key 失败')
|
||||
} finally {
|
||||
loadingKeys.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function setProviderPriority(providerId: string, event: Event): void {
|
||||
const priority = readPriorityInput(event)
|
||||
if (priority == null) return
|
||||
updateProviderOverrides({
|
||||
...targetModelPolicy.value.provider_priority_overrides,
|
||||
[providerId]: priority,
|
||||
})
|
||||
}
|
||||
|
||||
function moveProvider(providerId: string, direction: -1 | 1): void {
|
||||
const rows = moveRow(providerRows.value, providerId, direction)
|
||||
updateProviderOverrides(Object.fromEntries(rows.map((row, index) => [row.id, index])))
|
||||
}
|
||||
|
||||
function updateProviderOverrides(overrides: Record<string, number>): void {
|
||||
updateConfig(setModelProviderPriorityOverrides(config.value, targetModel.value, overrides))
|
||||
}
|
||||
|
||||
function setKeyPriority(keyId: string, event: Event): void {
|
||||
const priority = readPriorityInput(event)
|
||||
if (priority == null) return
|
||||
const row = keyRows.value.find(item => item.id === keyId)
|
||||
if (!row) return
|
||||
if (row.kind === 'pool') {
|
||||
updatePoolOverrides({
|
||||
...targetModelPolicy.value.pool_priority_overrides,
|
||||
[row.target_id]: priority,
|
||||
})
|
||||
} else {
|
||||
updateKeyOverrides({
|
||||
...targetModelPolicy.value.key_priority_overrides,
|
||||
[row.target_id]: priority,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function moveKey(keyId: string, direction: -1 | 1): void {
|
||||
const rows = moveRow(keyRows.value, keyId, direction)
|
||||
updateVisibleKeyAndPoolOverrides(rows)
|
||||
}
|
||||
|
||||
function updateKeyOverrides(overrides: Record<string, number>): void {
|
||||
updateConfig(setModelKeyPriorityOverrides(config.value, targetModel.value, overrides))
|
||||
}
|
||||
|
||||
function updatePoolOverrides(overrides: Record<string, number>): void {
|
||||
updateConfig(setModelPoolPriorityOverrides(config.value, targetModel.value, overrides))
|
||||
}
|
||||
|
||||
function updateKeyAndPoolOverrides(
|
||||
keyOverrides: Record<string, number>,
|
||||
poolOverrides: Record<string, number>,
|
||||
): void {
|
||||
const next = setModelPoolPriorityOverrides(
|
||||
setModelKeyPriorityOverrides(config.value, targetModel.value, keyOverrides),
|
||||
targetModel.value,
|
||||
poolOverrides,
|
||||
)
|
||||
updateConfig(next)
|
||||
}
|
||||
|
||||
function updateVisibleKeyAndPoolOverrides(rows: KeyPriorityRow[]): void {
|
||||
const keyOverrides = { ...targetModelPolicy.value.key_priority_overrides }
|
||||
const poolOverrides = { ...targetModelPolicy.value.pool_priority_overrides }
|
||||
|
||||
for (const row of keyRows.value) {
|
||||
if (row.kind === 'pool') {
|
||||
delete poolOverrides[row.target_id]
|
||||
} else {
|
||||
delete keyOverrides[row.target_id]
|
||||
}
|
||||
}
|
||||
|
||||
rows.forEach((row, index) => {
|
||||
if (row.kind === 'pool') {
|
||||
poolOverrides[row.target_id] = index
|
||||
} else {
|
||||
keyOverrides[row.target_id] = index
|
||||
}
|
||||
})
|
||||
|
||||
updateKeyAndPoolOverrides(keyOverrides, poolOverrides)
|
||||
}
|
||||
|
||||
function handleProviderDragStart(providerId: string, event: DragEvent): void {
|
||||
draggedProviderId.value = providerId
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
event.dataTransfer.setData('text/plain', providerId)
|
||||
}
|
||||
}
|
||||
|
||||
function handleProviderDragEnd(): void {
|
||||
draggedProviderId.value = null
|
||||
dragOverProviderId.value = null
|
||||
}
|
||||
|
||||
function handleProviderDragOver(providerId: string): void {
|
||||
dragOverProviderId.value = providerId
|
||||
}
|
||||
|
||||
function handleProviderDragLeave(): void {
|
||||
dragOverProviderId.value = null
|
||||
}
|
||||
|
||||
function handleProviderDrop(providerId: string): void {
|
||||
const draggedId = draggedProviderId.value
|
||||
if (!draggedId || draggedId === providerId) {
|
||||
handleProviderDragEnd()
|
||||
return
|
||||
}
|
||||
const rows = reorderRows(providerRows.value, draggedId, providerId)
|
||||
updateProviderOverrides(Object.fromEntries(rows.map((row, index) => [row.id, index])))
|
||||
handleProviderDragEnd()
|
||||
}
|
||||
|
||||
function handleKeyDragStart(keyId: string, event: DragEvent): void {
|
||||
draggedKeyId.value = keyId
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
event.dataTransfer.setData('text/plain', keyId)
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDragEnd(): void {
|
||||
draggedKeyId.value = null
|
||||
dragOverKeyId.value = null
|
||||
}
|
||||
|
||||
function handleKeyDragOver(keyId: string): void {
|
||||
dragOverKeyId.value = keyId
|
||||
}
|
||||
|
||||
function handleKeyDragLeave(): void {
|
||||
dragOverKeyId.value = null
|
||||
}
|
||||
|
||||
function handleKeyDrop(keyId: string): void {
|
||||
const draggedId = draggedKeyId.value
|
||||
if (!draggedId || draggedId === keyId) {
|
||||
handleKeyDragEnd()
|
||||
return
|
||||
}
|
||||
const rows = reorderRows(keyRows.value, draggedId, keyId)
|
||||
updateVisibleKeyAndPoolOverrides(rows)
|
||||
handleKeyDragEnd()
|
||||
}
|
||||
|
||||
function clearActiveOverrides(): void {
|
||||
if (effectivePriorityMode.value === 'provider') {
|
||||
updateProviderOverrides({})
|
||||
} else {
|
||||
updateVisibleKeyAndPoolOverrides([])
|
||||
}
|
||||
}
|
||||
|
||||
function moveRow<T extends { id: string }>(rows: T[], id: string, direction: -1 | 1): T[] {
|
||||
const next = [...rows]
|
||||
const index = next.findIndex(row => row.id === id)
|
||||
const targetIndex = index + direction
|
||||
if (index < 0 || targetIndex < 0 || targetIndex >= next.length) {
|
||||
return next
|
||||
}
|
||||
const [item] = next.splice(index, 1)
|
||||
next.splice(targetIndex, 0, item)
|
||||
return next
|
||||
}
|
||||
|
||||
function reorderRows<T extends { id: string }>(rows: T[], draggedId: string, targetId: string): T[] {
|
||||
const next = [...rows]
|
||||
const fromIndex = next.findIndex(row => row.id === draggedId)
|
||||
const toIndex = next.findIndex(row => row.id === targetId)
|
||||
if (fromIndex < 0 || toIndex < 0) return next
|
||||
const [item] = next.splice(fromIndex, 1)
|
||||
next.splice(toIndex, 0, item)
|
||||
return next
|
||||
}
|
||||
|
||||
function readPriorityInput(event: Event): number | null {
|
||||
const value = Number((event.target as HTMLInputElement).value)
|
||||
if (!Number.isFinite(value) || value < 0) {
|
||||
return null
|
||||
}
|
||||
return Math.trunc(value)
|
||||
}
|
||||
|
||||
function priorityValue(override: number | undefined, fallback: number | null | undefined): number {
|
||||
if (typeof override === 'number' && Number.isFinite(override)) return override
|
||||
if (typeof fallback === 'number' && Number.isFinite(fallback)) return fallback
|
||||
return 0
|
||||
}
|
||||
|
||||
function fallbackKeyPriority(key: GlobalKeySource, format: string): number {
|
||||
const normalizedFormat = normalizeFormat(format)
|
||||
if (normalizedFormat && typeof key.global_priority_by_format?.[normalizedFormat] === 'number') {
|
||||
return key.global_priority_by_format[normalizedFormat]
|
||||
}
|
||||
return key.internal_priority
|
||||
}
|
||||
|
||||
function normalizeGlobalKeys(format: string, rawKeys: Record<string, unknown>[]): GlobalKeySource[] {
|
||||
const deduped = new Map<string, GlobalKeySource>()
|
||||
for (const raw of rawKeys) {
|
||||
const id = String(raw.id || '').trim()
|
||||
if (!id) continue
|
||||
const providerName = String(raw.provider_name || '')
|
||||
const providerId = String(raw.provider_id || '') || providerIdByName.value.get(providerName) || ''
|
||||
const priorityMap = normalizePriorityMap(raw.global_priority_by_format as Record<string, unknown> | null | undefined)
|
||||
const source: GlobalKeySource = {
|
||||
id,
|
||||
provider_id: providerId,
|
||||
provider_name: providerName || providerById.value.get(providerId)?.name || 'Unknown Provider',
|
||||
name: String(raw.name || 'Unnamed Key'),
|
||||
api_key_masked: String(raw.api_key_masked || '***'),
|
||||
internal_priority: toNumberOrNull(raw.internal_priority) ?? 0,
|
||||
global_priority_by_format: Object.keys(priorityMap).length > 0 ? priorityMap : null,
|
||||
is_active: raw.is_active !== false,
|
||||
provider_active: raw.provider_active !== false,
|
||||
api_formats: Array.isArray(raw.api_formats) ? raw.api_formats.map(item => normalizeFormat(String(item))).filter(Boolean) : [format],
|
||||
api_format: format,
|
||||
health_score: toNumberOrNull(raw.health_score),
|
||||
request_count: toNumberOrNull(raw.request_count) ?? 0,
|
||||
}
|
||||
const existing = deduped.get(id)
|
||||
if (!existing) {
|
||||
deduped.set(id, source)
|
||||
continue
|
||||
}
|
||||
deduped.set(id, {
|
||||
...existing,
|
||||
...source,
|
||||
global_priority_by_format: {
|
||||
...(existing.global_priority_by_format ?? {}),
|
||||
...(source.global_priority_by_format ?? {}),
|
||||
},
|
||||
api_formats: Array.from(new Set([...existing.api_formats, ...source.api_formats])),
|
||||
})
|
||||
}
|
||||
return Array.from(deduped.values())
|
||||
}
|
||||
|
||||
function buildPoolRow(
|
||||
format: string,
|
||||
providerId: string,
|
||||
keys: GlobalKeySource[],
|
||||
overrides: Record<string, number>,
|
||||
): KeyPriorityRow {
|
||||
const provider = providerById.value.get(providerId)
|
||||
const activeKeyCount = keys.filter(key => key.is_active).length
|
||||
return {
|
||||
id: `pool:${providerId}:${format}`,
|
||||
kind: 'pool',
|
||||
target_id: providerId,
|
||||
name: provider?.name || keys[0]?.provider_name || '未知 Provider',
|
||||
masked: '[Pool]',
|
||||
is_active: (provider?.is_active ?? keys.some(key => key.provider_active)) && activeKeyCount > 0,
|
||||
api_formats: [format],
|
||||
priority: priorityValue(
|
||||
overrides[providerId],
|
||||
provider?.pool_advanced?.global_priority ?? provider?.provider_priority ?? 999999,
|
||||
),
|
||||
provider_id: providerId,
|
||||
provider_name: provider?.name || keys[0]?.provider_name || 'Unknown Provider',
|
||||
pool_key_count: keys.length,
|
||||
pool_active_key_count: activeKeyCount,
|
||||
}
|
||||
}
|
||||
|
||||
function resolveProviderId(key: Pick<GlobalKeySource, 'provider_id' | 'provider_name'>): string {
|
||||
if (key.provider_id) return key.provider_id
|
||||
return providerIdByName.value.get(key.provider_name) || ''
|
||||
}
|
||||
|
||||
function isPoolManagedProvider(providerId: string): boolean {
|
||||
return providerId !== '' && poolProviderIds.value.has(providerId)
|
||||
}
|
||||
|
||||
function normalizeFormat(value: string | null | undefined): string {
|
||||
return normalizeApiFormatAlias(value).trim()
|
||||
}
|
||||
|
||||
function formatLabel(format: string): string {
|
||||
return formatApiFormat(format)
|
||||
}
|
||||
|
||||
function normalizePriorityMap(value: Record<string, unknown> | null | undefined): Record<string, number> {
|
||||
if (!value) return {}
|
||||
const normalized: Record<string, number> = {}
|
||||
for (const [rawFormat, rawPriority] of Object.entries(value)) {
|
||||
const format = normalizeFormat(rawFormat)
|
||||
const priority = toNumberOrNull(rawPriority)
|
||||
if (!format || priority == null) continue
|
||||
normalized[format] = priority
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
function toNumberOrNull(value: unknown): number | null {
|
||||
const numberValue = Number(value)
|
||||
return Number.isFinite(numberValue) ? Math.trunc(numberValue) : null
|
||||
}
|
||||
|
||||
function comparePriorityRows(left: ProviderPriorityRow | KeyPriorityRow, right: ProviderPriorityRow | KeyPriorityRow): number {
|
||||
return left.priority - right.priority
|
||||
|| Number(right.is_active) - Number(left.is_active)
|
||||
|| left.name.localeCompare(right.name)
|
||||
|| left.id.localeCompare(right.id)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="rule in rules"
|
||||
:key="rule.id"
|
||||
class="rounded-lg border border-border/60 px-3 py-2"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<p class="truncate text-sm font-medium">
|
||||
{{ rule.id }}
|
||||
</p>
|
||||
<span class="rounded-md bg-muted px-2 py-1 text-xs text-muted-foreground">
|
||||
P{{ rule.priority }} / {{ rule.phase }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
{{ summarizeRule(rule) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { summarizeRoutingCondition } from '../utils/routingConditions'
|
||||
import type { RoutingRule } from '../utils/routingPolicy'
|
||||
|
||||
defineProps<{
|
||||
rules: RoutingRule[]
|
||||
}>()
|
||||
|
||||
function summarizeRule(rule: RoutingRule): string {
|
||||
return summarizeRoutingCondition(rule.conditions as never)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<section class="space-y-4">
|
||||
<div class="rounded-lg border border-border/60 p-3">
|
||||
<p
|
||||
v-for="line in summary"
|
||||
:key="line"
|
||||
class="text-sm text-muted-foreground"
|
||||
>
|
||||
{{ line }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="candidate in candidates"
|
||||
:key="`${candidate.provider_id}-${candidate.endpoint_id}-${candidate.key_id ?? 'pool'}`"
|
||||
class="rounded-lg border border-border/60 px-3 py-2"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<p class="truncate text-sm font-medium">
|
||||
{{ candidateTraceLabel(candidate) }}
|
||||
</p>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ candidate.skip_reason || `#${candidate.selected_order ?? '-'}` }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { candidateTraceLabel, sortCandidateTraces, summarizeRoutingTrace, type RoutingDecisionTrace } from '../utils/routingTrace'
|
||||
|
||||
const props = defineProps<{
|
||||
trace: RoutingDecisionTrace
|
||||
}>()
|
||||
|
||||
const summary = computed(() => summarizeRoutingTrace(props.trace))
|
||||
const candidates = computed(() => sortCandidateTraces(props.trace.global_candidates))
|
||||
</script>
|
||||
7
frontend/src/features/routing/components/index.ts
Normal file
7
frontend/src/features/routing/components/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { default as RoutingDryRunDialog } from './RoutingDryRunDialog.vue'
|
||||
export { default as RoutingGroupEditor } from './RoutingGroupEditor.vue'
|
||||
export { default as RoutingGroupList } from './RoutingGroupList.vue'
|
||||
export { default as RoutingModelPolicyEditor } from './RoutingModelPolicyEditor.vue'
|
||||
export { default as RoutingPriorityPolicyEditor } from './RoutingPriorityPolicyEditor.vue'
|
||||
export { default as RoutingRuleEditor } from './RoutingRuleEditor.vue'
|
||||
export { default as RoutingTraceViewer } from './RoutingTraceViewer.vue'
|
||||
4
frontend/src/features/routing/index.ts
Normal file
4
frontend/src/features/routing/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './components'
|
||||
export * from './utils/routingConditions'
|
||||
export * from './utils/routingPolicy'
|
||||
export * from './utils/routingTrace'
|
||||
73
frontend/src/features/routing/utils/routingConditions.ts
Normal file
73
frontend/src/features/routing/utils/routingConditions.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
export type RoutingConditionOp = 'eq' | 'ne' | 'in' | 'contains' | 'exists' | 'matches'
|
||||
|
||||
export interface RoutingConditionLeaf {
|
||||
field: string
|
||||
op: RoutingConditionOp
|
||||
value?: unknown
|
||||
}
|
||||
|
||||
export interface RoutingConditionGroup {
|
||||
all?: RoutingCondition[]
|
||||
any?: RoutingCondition[]
|
||||
not?: RoutingCondition
|
||||
}
|
||||
|
||||
export type RoutingCondition = RoutingConditionLeaf | RoutingConditionGroup
|
||||
|
||||
export const routingConditionFieldLabels: Record<string, string> = {
|
||||
model: '模型',
|
||||
api_format: 'API 格式',
|
||||
user_id: '用户',
|
||||
api_key_id: 'API Key',
|
||||
}
|
||||
|
||||
export const routingConditionOpLabels: Record<RoutingConditionOp, string> = {
|
||||
eq: '等于',
|
||||
ne: '不等于',
|
||||
in: '包含于',
|
||||
contains: '包含',
|
||||
exists: '存在',
|
||||
matches: '匹配',
|
||||
}
|
||||
|
||||
export function isConditionLeaf(condition: RoutingCondition): condition is RoutingConditionLeaf {
|
||||
return typeof (condition as RoutingConditionLeaf).field === 'string'
|
||||
}
|
||||
|
||||
export function summarizeRoutingCondition(condition: RoutingCondition): string {
|
||||
if (isConditionLeaf(condition)) {
|
||||
const field = routingConditionFieldLabels[condition.field] ?? condition.field
|
||||
const op = routingConditionOpLabels[condition.op] ?? condition.op
|
||||
return `${field} ${op} ${formatConditionValue(condition.value)}`
|
||||
}
|
||||
|
||||
if (condition.all?.length) {
|
||||
return condition.all.map(summarizeRoutingCondition).join(' 且 ')
|
||||
}
|
||||
|
||||
if (condition.any?.length) {
|
||||
return condition.any.map(summarizeRoutingCondition).join(' 或 ')
|
||||
}
|
||||
|
||||
if (condition.not) {
|
||||
return `非 ${summarizeRoutingCondition(condition.not)}`
|
||||
}
|
||||
|
||||
return '无条件'
|
||||
}
|
||||
|
||||
function formatConditionValue(value: unknown): string {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(formatConditionValue).join(', ')
|
||||
}
|
||||
|
||||
if (value === undefined || value === null) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
|
||||
return String(value)
|
||||
}
|
||||
350
frontend/src/features/routing/utils/routingPolicy.ts
Normal file
350
frontend/src/features/routing/utils/routingPolicy.ts
Normal file
@@ -0,0 +1,350 @@
|
||||
export type RoutingPriorityMode = 'provider' | 'global_key'
|
||||
export type RoutingSchedulingMode = 'fixed_order' | 'cache_affinity' | 'load_balance'
|
||||
export type RoutingRulePhase = 'client_request' | 'provider_request'
|
||||
|
||||
export interface RoutingDefaultPolicy {
|
||||
priority_mode: RoutingPriorityMode
|
||||
scheduling_mode: RoutingSchedulingMode
|
||||
keep_priority_on_conversion: boolean
|
||||
}
|
||||
|
||||
export interface RoutingPoolSchedulingPreset {
|
||||
preset: string
|
||||
enabled: boolean
|
||||
mode?: string | null
|
||||
}
|
||||
|
||||
export interface RoutingPoolPolicyOverride {
|
||||
scheduling_presets: RoutingPoolSchedulingPreset[]
|
||||
}
|
||||
|
||||
export interface RoutingModelPolicy {
|
||||
model: string
|
||||
allowed_providers: string[]
|
||||
allowed_keys: string[]
|
||||
provider_priority_overrides: Record<string, number>
|
||||
key_priority_overrides: Record<string, number>
|
||||
pool_priority_overrides: Record<string, number>
|
||||
pool_policy_overrides: Record<string, RoutingPoolPolicyOverride>
|
||||
}
|
||||
|
||||
export interface RoutingRule {
|
||||
id: string
|
||||
priority: number
|
||||
enabled: boolean
|
||||
phase: RoutingRulePhase
|
||||
conditions: unknown
|
||||
actions: unknown[]
|
||||
stop_processing: boolean
|
||||
}
|
||||
|
||||
export interface RoutingPredicateCondition {
|
||||
field: string
|
||||
op: 'eq' | 'prefix'
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface RoutingSetSchedulingAction {
|
||||
type: 'set_scheduling'
|
||||
priority_mode: RoutingPriorityMode
|
||||
scheduling_mode: RoutingSchedulingMode
|
||||
}
|
||||
|
||||
export interface RoutingGroupConfig {
|
||||
allowed_models: string[]
|
||||
default_policy: RoutingDefaultPolicy
|
||||
model_policies: RoutingModelPolicy[]
|
||||
rules: RoutingRule[]
|
||||
}
|
||||
|
||||
export const DEFAULT_ROUTING_POLICY_MODEL = '*'
|
||||
export const MODEL_SCHEDULING_RULE_PREFIX = 'ui_model_scheduling:'
|
||||
|
||||
export function createEmptyRoutingGroupConfig(): RoutingGroupConfig {
|
||||
return {
|
||||
allowed_models: [],
|
||||
default_policy: {
|
||||
priority_mode: 'provider',
|
||||
scheduling_mode: 'cache_affinity',
|
||||
keep_priority_on_conversion: false,
|
||||
},
|
||||
model_policies: [],
|
||||
rules: [],
|
||||
}
|
||||
}
|
||||
|
||||
export function createEmptyModelPolicy(model = ''): RoutingModelPolicy {
|
||||
return {
|
||||
model,
|
||||
allowed_providers: [],
|
||||
allowed_keys: [],
|
||||
provider_priority_overrides: {},
|
||||
key_priority_overrides: {},
|
||||
pool_priority_overrides: {},
|
||||
pool_policy_overrides: {},
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeRoutingGroupConfig(value: Partial<RoutingGroupConfig> | null | undefined): RoutingGroupConfig {
|
||||
const base = createEmptyRoutingGroupConfig()
|
||||
|
||||
return {
|
||||
allowed_models: Array.isArray(value?.allowed_models) ? [...value.allowed_models] : base.allowed_models,
|
||||
default_policy: {
|
||||
...base.default_policy,
|
||||
...(value?.default_policy ?? {}),
|
||||
},
|
||||
model_policies: Array.isArray(value?.model_policies)
|
||||
? value.model_policies.map(policy => ({
|
||||
...createEmptyModelPolicy(policy.model),
|
||||
...policy,
|
||||
allowed_providers: Array.isArray(policy.allowed_providers) ? [...policy.allowed_providers] : [],
|
||||
allowed_keys: Array.isArray(policy.allowed_keys) ? [...policy.allowed_keys] : [],
|
||||
provider_priority_overrides: { ...(policy.provider_priority_overrides ?? {}) },
|
||||
key_priority_overrides: { ...(policy.key_priority_overrides ?? {}) },
|
||||
pool_priority_overrides: { ...(policy.pool_priority_overrides ?? {}) },
|
||||
pool_policy_overrides: { ...(policy.pool_policy_overrides ?? {}) },
|
||||
}))
|
||||
: base.model_policies,
|
||||
rules: Array.isArray(value?.rules) ? value.rules.map(rule => ({ ...rule })) : base.rules,
|
||||
}
|
||||
}
|
||||
|
||||
export function upsertModelPolicy(config: RoutingGroupConfig, policy: RoutingModelPolicy): RoutingGroupConfig {
|
||||
const model = policy.model.trim()
|
||||
if (!model) {
|
||||
return normalizeRoutingGroupConfig(config)
|
||||
}
|
||||
|
||||
const next = normalizeRoutingGroupConfig(config)
|
||||
const index = next.model_policies.findIndex(item => item.model === model)
|
||||
const normalizedPolicy = { ...createEmptyModelPolicy(model), ...policy, model }
|
||||
|
||||
if (index >= 0) {
|
||||
next.model_policies[index] = normalizedPolicy
|
||||
} else {
|
||||
next.model_policies.push(normalizedPolicy)
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
export function removeModelPolicy(config: RoutingGroupConfig, model: string): RoutingGroupConfig {
|
||||
const next = normalizeRoutingGroupConfig(config)
|
||||
next.model_policies = next.model_policies.filter(policy => policy.model !== model)
|
||||
return next
|
||||
}
|
||||
|
||||
export function getDefaultModelPolicy(config: RoutingGroupConfig): RoutingModelPolicy {
|
||||
const normalized = normalizeRoutingGroupConfig(config)
|
||||
return normalized.model_policies.find(policy => policy.model === DEFAULT_ROUTING_POLICY_MODEL)
|
||||
?? createEmptyModelPolicy(DEFAULT_ROUTING_POLICY_MODEL)
|
||||
}
|
||||
|
||||
export function getModelPolicy(config: RoutingGroupConfig, model: string): RoutingModelPolicy {
|
||||
const normalizedModel = model.trim() || DEFAULT_ROUTING_POLICY_MODEL
|
||||
if (normalizedModel === DEFAULT_ROUTING_POLICY_MODEL) {
|
||||
return getDefaultModelPolicy(config)
|
||||
}
|
||||
const normalized = normalizeRoutingGroupConfig(config)
|
||||
return normalized.model_policies.find(policy => policy.model === normalizedModel)
|
||||
?? createEmptyModelPolicy(normalizedModel)
|
||||
}
|
||||
|
||||
export function upsertDefaultModelPolicy(
|
||||
config: RoutingGroupConfig,
|
||||
patch: Partial<Omit<RoutingModelPolicy, 'model'>>,
|
||||
): RoutingGroupConfig {
|
||||
const current = getDefaultModelPolicy(config)
|
||||
const next = upsertModelPolicy(config, {
|
||||
...current,
|
||||
...patch,
|
||||
model: DEFAULT_ROUTING_POLICY_MODEL,
|
||||
})
|
||||
next.model_policies = [
|
||||
...next.model_policies.filter(policy => policy.model === DEFAULT_ROUTING_POLICY_MODEL),
|
||||
...next.model_policies.filter(policy => policy.model !== DEFAULT_ROUTING_POLICY_MODEL),
|
||||
]
|
||||
return next
|
||||
}
|
||||
|
||||
export function setDefaultProviderPriorityOverrides(
|
||||
config: RoutingGroupConfig,
|
||||
overrides: Record<string, number>,
|
||||
): RoutingGroupConfig {
|
||||
return upsertDefaultModelPolicy(config, {
|
||||
provider_priority_overrides: normalizePriorityOverrides(overrides),
|
||||
})
|
||||
}
|
||||
|
||||
export function setDefaultKeyPriorityOverrides(
|
||||
config: RoutingGroupConfig,
|
||||
overrides: Record<string, number>,
|
||||
): RoutingGroupConfig {
|
||||
return upsertDefaultModelPolicy(config, {
|
||||
key_priority_overrides: normalizePriorityOverrides(overrides),
|
||||
})
|
||||
}
|
||||
|
||||
export function setDefaultPoolPriorityOverrides(
|
||||
config: RoutingGroupConfig,
|
||||
overrides: Record<string, number>,
|
||||
): RoutingGroupConfig {
|
||||
return upsertDefaultModelPolicy(config, {
|
||||
pool_priority_overrides: normalizePriorityOverrides(overrides),
|
||||
})
|
||||
}
|
||||
|
||||
export function setModelProviderPriorityOverrides(
|
||||
config: RoutingGroupConfig,
|
||||
model: string,
|
||||
overrides: Record<string, number>,
|
||||
): RoutingGroupConfig {
|
||||
const normalizedModel = model.trim() || DEFAULT_ROUTING_POLICY_MODEL
|
||||
if (normalizedModel === DEFAULT_ROUTING_POLICY_MODEL) {
|
||||
return setDefaultProviderPriorityOverrides(config, overrides)
|
||||
}
|
||||
return upsertModelPolicy(config, {
|
||||
...getModelPolicy(config, normalizedModel),
|
||||
model: normalizedModel,
|
||||
provider_priority_overrides: normalizePriorityOverrides(overrides),
|
||||
})
|
||||
}
|
||||
|
||||
export function setModelKeyPriorityOverrides(
|
||||
config: RoutingGroupConfig,
|
||||
model: string,
|
||||
overrides: Record<string, number>,
|
||||
): RoutingGroupConfig {
|
||||
const normalizedModel = model.trim() || DEFAULT_ROUTING_POLICY_MODEL
|
||||
if (normalizedModel === DEFAULT_ROUTING_POLICY_MODEL) {
|
||||
return setDefaultKeyPriorityOverrides(config, overrides)
|
||||
}
|
||||
return upsertModelPolicy(config, {
|
||||
...getModelPolicy(config, normalizedModel),
|
||||
model: normalizedModel,
|
||||
key_priority_overrides: normalizePriorityOverrides(overrides),
|
||||
})
|
||||
}
|
||||
|
||||
export function setModelPoolPriorityOverrides(
|
||||
config: RoutingGroupConfig,
|
||||
model: string,
|
||||
overrides: Record<string, number>,
|
||||
): RoutingGroupConfig {
|
||||
const normalizedModel = model.trim() || DEFAULT_ROUTING_POLICY_MODEL
|
||||
if (normalizedModel === DEFAULT_ROUTING_POLICY_MODEL) {
|
||||
return setDefaultPoolPriorityOverrides(config, overrides)
|
||||
}
|
||||
return upsertModelPolicy(config, {
|
||||
...getModelPolicy(config, normalizedModel),
|
||||
model: normalizedModel,
|
||||
pool_priority_overrides: normalizePriorityOverrides(overrides),
|
||||
})
|
||||
}
|
||||
|
||||
export function modelSchedulingRuleId(model: string): string {
|
||||
return `${MODEL_SCHEDULING_RULE_PREFIX}${encodeURIComponent(model.trim())}`
|
||||
}
|
||||
|
||||
export function isGeneratedModelSchedulingRule(rule: RoutingRule): boolean {
|
||||
return rule.id.startsWith(MODEL_SCHEDULING_RULE_PREFIX)
|
||||
}
|
||||
|
||||
export function modelPatternCondition(model: string): RoutingPredicateCondition {
|
||||
const normalizedModel = model.trim()
|
||||
if (normalizedModel.endsWith('*')) {
|
||||
return {
|
||||
field: 'model',
|
||||
op: 'prefix',
|
||||
value: normalizedModel.slice(0, -1),
|
||||
}
|
||||
}
|
||||
return {
|
||||
field: 'model',
|
||||
op: 'eq',
|
||||
value: normalizedModel,
|
||||
}
|
||||
}
|
||||
|
||||
export function getModelScheduling(
|
||||
config: RoutingGroupConfig,
|
||||
model: string,
|
||||
): RoutingDefaultPolicy {
|
||||
const normalized = normalizeRoutingGroupConfig(config)
|
||||
const rule = normalized.rules.find(rule => rule.id === modelSchedulingRuleId(model))
|
||||
const action = rule?.actions.find(isSetSchedulingAction)
|
||||
return {
|
||||
priority_mode: action?.priority_mode ?? normalized.default_policy.priority_mode,
|
||||
scheduling_mode: action?.scheduling_mode ?? normalized.default_policy.scheduling_mode,
|
||||
keep_priority_on_conversion: normalized.default_policy.keep_priority_on_conversion,
|
||||
}
|
||||
}
|
||||
|
||||
export function upsertModelSchedulingRule(
|
||||
config: RoutingGroupConfig,
|
||||
model: string,
|
||||
scheduling: Pick<RoutingDefaultPolicy, 'priority_mode' | 'scheduling_mode'>,
|
||||
): RoutingGroupConfig {
|
||||
const normalizedModel = model.trim()
|
||||
if (!normalizedModel || normalizedModel === DEFAULT_ROUTING_POLICY_MODEL) {
|
||||
return normalizeRoutingGroupConfig(config)
|
||||
}
|
||||
|
||||
const next = normalizeRoutingGroupConfig(config)
|
||||
const rule: RoutingRule = {
|
||||
id: modelSchedulingRuleId(normalizedModel),
|
||||
priority: 10_000 + next.rules.filter(isGeneratedModelSchedulingRule).length,
|
||||
enabled: true,
|
||||
phase: 'client_request',
|
||||
conditions: modelPatternCondition(normalizedModel),
|
||||
actions: [{
|
||||
type: 'set_scheduling',
|
||||
priority_mode: scheduling.priority_mode,
|
||||
scheduling_mode: scheduling.scheduling_mode,
|
||||
} satisfies RoutingSetSchedulingAction],
|
||||
stop_processing: false,
|
||||
}
|
||||
|
||||
const index = next.rules.findIndex(item => item.id === rule.id)
|
||||
if (index >= 0) {
|
||||
next.rules[index] = {
|
||||
...next.rules[index],
|
||||
...rule,
|
||||
priority: next.rules[index].priority,
|
||||
}
|
||||
} else {
|
||||
next.rules.push(rule)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
export function removeModelSchedulingRule(config: RoutingGroupConfig, model: string): RoutingGroupConfig {
|
||||
const ruleId = modelSchedulingRuleId(model)
|
||||
const next = normalizeRoutingGroupConfig(config)
|
||||
next.rules = next.rules.filter(rule => rule.id !== ruleId)
|
||||
return next
|
||||
}
|
||||
|
||||
export function removeGeneratedModelSchedulingRules(config: RoutingGroupConfig): RoutingGroupConfig {
|
||||
const next = normalizeRoutingGroupConfig(config)
|
||||
next.rules = next.rules.filter(rule => !isGeneratedModelSchedulingRule(rule))
|
||||
return next
|
||||
}
|
||||
|
||||
export function normalizePriorityOverrides(overrides: Record<string, number>): Record<string, number> {
|
||||
const normalized: Record<string, number> = {}
|
||||
for (const [rawId, rawPriority] of Object.entries(overrides)) {
|
||||
const id = rawId.trim()
|
||||
const priority = Math.max(0, Math.trunc(Number(rawPriority)))
|
||||
if (!id || !Number.isFinite(priority)) continue
|
||||
normalized[id] = priority
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
function isSetSchedulingAction(action: unknown): action is RoutingSetSchedulingAction {
|
||||
if (!action || typeof action !== 'object') return false
|
||||
const candidate = action as Partial<RoutingSetSchedulingAction>
|
||||
return candidate.type === 'set_scheduling'
|
||||
}
|
||||
59
frontend/src/features/routing/utils/routingTrace.ts
Normal file
59
frontend/src/features/routing/utils/routingTrace.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
export interface RoutingCandidateRankVector {
|
||||
provider_priority_before: number
|
||||
provider_priority_after: number
|
||||
key_priority_before: number
|
||||
key_priority_after: number
|
||||
}
|
||||
|
||||
export interface RoutingCandidateTrace {
|
||||
candidate_kind: 'provider' | 'pool_group'
|
||||
provider_id: string
|
||||
endpoint_id: string
|
||||
model_id: string
|
||||
key_id?: string | null
|
||||
ranking_vector: RoutingCandidateRankVector
|
||||
skip_reason?: string | null
|
||||
selected_order?: number | null
|
||||
}
|
||||
|
||||
export interface RoutingDecisionTrace {
|
||||
group_id?: string | null
|
||||
group_version?: number | null
|
||||
selection_source: string
|
||||
selected_rules: string[]
|
||||
original_model: string
|
||||
resolved_model: string
|
||||
client_api_format: string
|
||||
global_candidates: RoutingCandidateTrace[]
|
||||
pool_expansion: unknown[]
|
||||
runtime_facts: Record<string, unknown>
|
||||
}
|
||||
|
||||
export function candidateTraceLabel(candidate: RoutingCandidateTrace): string {
|
||||
const kind = candidate.candidate_kind === 'pool_group' ? '号池' : 'Provider'
|
||||
const key = candidate.key_id ? ` / ${candidate.key_id}` : ''
|
||||
return `${kind} ${candidate.provider_id}${key}`
|
||||
}
|
||||
|
||||
export function summarizeRoutingTrace(trace: RoutingDecisionTrace): string[] {
|
||||
const lines = [
|
||||
`分组: ${trace.group_id ?? 'legacy'}`,
|
||||
`来源: ${trace.selection_source}`,
|
||||
`模型: ${trace.original_model} -> ${trace.resolved_model}`,
|
||||
]
|
||||
|
||||
if (trace.selected_rules.length > 0) {
|
||||
lines.push(`规则: ${trace.selected_rules.join(', ')}`)
|
||||
}
|
||||
|
||||
lines.push(`候选: ${trace.global_candidates.length}`)
|
||||
return lines
|
||||
}
|
||||
|
||||
export function sortCandidateTraces(candidates: readonly RoutingCandidateTrace[]): RoutingCandidateTrace[] {
|
||||
return [...candidates].sort((left, right) => {
|
||||
const leftOrder = left.selected_order ?? Number.MAX_SAFE_INTEGER
|
||||
const rightOrder = right.selected_order ?? Number.MAX_SAFE_INTEGER
|
||||
return leftOrder - rightOrder
|
||||
})
|
||||
}
|
||||
@@ -159,6 +159,47 @@
|
||||
v-else-if="detail"
|
||||
class="space-y-4"
|
||||
>
|
||||
<!-- 执行失败原因:优先展示本地调度/运行时失败摘要 -->
|
||||
<Card
|
||||
v-if="failureNotice"
|
||||
class="border-red-200 bg-red-50/80 shadow-sm dark:border-red-900/60 dark:bg-red-950/30"
|
||||
>
|
||||
<div class="p-3 sm:p-4 flex gap-3">
|
||||
<div class="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-red-100 text-red-600 dark:bg-red-900/50 dark:text-red-300">
|
||||
<AlertTriangle class="h-4 w-4" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1 space-y-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h4 class="text-sm font-semibold text-red-950 dark:text-red-100">
|
||||
{{ failureNotice.title }}
|
||||
</h4>
|
||||
<Badge
|
||||
v-if="failureNotice.isSchedulingFailure"
|
||||
variant="outline"
|
||||
class="border-red-300 bg-white/60 text-[10px] text-red-700 dark:border-red-800 dark:bg-red-950/40 dark:text-red-200"
|
||||
>
|
||||
调度阶段
|
||||
</Badge>
|
||||
</div>
|
||||
<p class="text-sm leading-6 text-red-900 dark:text-red-100">
|
||||
{{ failureNotice.message }}
|
||||
</p>
|
||||
<div
|
||||
v-if="failureNotice.meta.length > 0"
|
||||
class="flex flex-wrap gap-1.5"
|
||||
>
|
||||
<span
|
||||
v-for="item in failureNotice.meta"
|
||||
:key="item"
|
||||
class="rounded-full border border-red-200 bg-white/70 px-2 py-0.5 text-[11px] font-mono text-red-700 dark:border-red-900 dark:bg-red-950/50 dark:text-red-200"
|
||||
>
|
||||
{{ item }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- 费用与性能概览 -->
|
||||
<Card>
|
||||
<div class="p-3 sm:p-4">
|
||||
@@ -701,7 +742,7 @@ import Separator from '@/components/ui/separator.vue'
|
||||
import Skeleton from '@/components/ui/skeleton.vue'
|
||||
import Tabs from '@/components/ui/tabs.vue'
|
||||
import TabsContent from '@/components/ui/tabs-content.vue'
|
||||
import { Check, Columns2, RefreshCw, X, Monitor, Server, MessageSquareText, Code2, Terminal, Play } from 'lucide-vue-next'
|
||||
import { AlertTriangle, Check, Columns2, RefreshCw, X, Monitor, Server, MessageSquareText, Code2, Terminal, Play } from 'lucide-vue-next'
|
||||
import { dashboardApi, type RequestDetail, type RequestErrorDomain } from '@/api/dashboard'
|
||||
import type { ImageProgress, RequestTrace } from '@/api/requestTrace'
|
||||
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
||||
@@ -721,6 +762,7 @@ import {
|
||||
resolveDisplayRequestStatus,
|
||||
resolveUsageStreamLabelSegments,
|
||||
} from '../utils/status'
|
||||
import { resolveRequestFailureNotice } from '../utils/errorNotice'
|
||||
|
||||
// 子组件
|
||||
import RequestHeadersContent from './RequestDetailDrawer/RequestHeadersContent.vue'
|
||||
@@ -1023,6 +1065,8 @@ const metadataPanelData = computed<Record<string, unknown> | null>(() => {
|
||||
return Object.keys(merged).length > 0 ? merged : null
|
||||
})
|
||||
|
||||
const failureNotice = computed(() => resolveRequestFailureNotice(detail.value))
|
||||
|
||||
const settlementInfo = computed<JsonRecord | null>(() =>
|
||||
asRecord(detail.value?.settlement ?? null),
|
||||
)
|
||||
@@ -1826,6 +1870,7 @@ async function ensureBodyContentLoaded() {
|
||||
failure_summary: response.failure_summary,
|
||||
errors: response.errors,
|
||||
error_flow: response.error_flow,
|
||||
scheduling_failure: response.scheduling_failure,
|
||||
}
|
||||
bodiesLoadedForRequestId.value = cacheKey
|
||||
} catch (err) {
|
||||
@@ -1872,12 +1917,13 @@ async function loadDetail(id: string, silent = false) {
|
||||
provider_request_body: sameRequest ? previousDetail?.provider_request_body : undefined,
|
||||
response_body: sameRequest ? previousDetail?.response_body : undefined,
|
||||
client_response_body: sameRequest ? previousDetail?.client_response_body : undefined,
|
||||
request_error: sameRequest ? (previousDetail?.request_error ?? response.request_error) : response.request_error,
|
||||
upstream_error: sameRequest ? (previousDetail?.upstream_error ?? response.upstream_error) : response.upstream_error,
|
||||
client_error: sameRequest ? (previousDetail?.client_error ?? response.client_error) : response.client_error,
|
||||
failure_summary: sameRequest ? (previousDetail?.failure_summary ?? response.failure_summary) : response.failure_summary,
|
||||
errors: sameRequest ? (previousDetail?.errors ?? response.errors) : response.errors,
|
||||
error_flow: sameRequest ? (previousDetail?.error_flow ?? response.error_flow) : response.error_flow,
|
||||
request_error: response.request_error,
|
||||
upstream_error: response.upstream_error,
|
||||
client_error: response.client_error,
|
||||
failure_summary: response.failure_summary,
|
||||
errors: response.errors,
|
||||
error_flow: response.error_flow,
|
||||
scheduling_failure: response.scheduling_failure,
|
||||
}
|
||||
bodiesLoadedForRequestId.value = sameRequest ? bodiesLoadedForRequestId.value : null
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -293,35 +306,44 @@
|
||||
</div>
|
||||
|
||||
<!-- 桌面端表格视图 -->
|
||||
<Table :class="['hidden md:table table-fixed w-full', isAdmin ? 'min-w-[1120px]' : 'min-w-[960px]']">
|
||||
<Table
|
||||
class="hidden md:table table-fixed w-full"
|
||||
: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"
|
||||
@@ -340,13 +362,15 @@
|
||||
</template>
|
||||
</SortableTableHead>
|
||||
<TableHead
|
||||
v-if="!isAdmin"
|
||||
v-if="!isAdmin && isColumnVisible('key')"
|
||||
class="h-12 font-semibold w-[17%]"
|
||||
>
|
||||
密钥
|
||||
</TableHead>
|
||||
<SortableTableHead
|
||||
:class="['h-12 font-semibold', isAdmin ? 'w-[14%]' : 'w-[22%]']"
|
||||
v-if="isColumnVisible('model')"
|
||||
class="h-12 font-semibold"
|
||||
:class="[isAdmin ? 'w-[14%]' : 'w-[22%]']"
|
||||
column-key="model"
|
||||
:sortable="false"
|
||||
:filter-active="filterModel !== '__all__'"
|
||||
@@ -364,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"
|
||||
@@ -383,7 +407,9 @@
|
||||
</template>
|
||||
</SortableTableHead>
|
||||
<SortableTableHead
|
||||
:class="['h-12 font-semibold', isAdmin ? 'w-[15%]' : 'w-[14%]']"
|
||||
v-if="isColumnVisible('api_format')"
|
||||
class="h-12 font-semibold"
|
||||
:class="[isAdmin ? 'w-[15%]' : 'w-[14%]']"
|
||||
column-key="api_format"
|
||||
:sortable="false"
|
||||
:filter-active="filterApiFormat !== '__all__'"
|
||||
@@ -401,6 +427,7 @@
|
||||
</template>
|
||||
</SortableTableHead>
|
||||
<SortableTableHead
|
||||
v-if="isColumnVisible('status')"
|
||||
class="h-12 font-semibold w-[10%] text-center"
|
||||
column-key="status"
|
||||
:sortable="false"
|
||||
@@ -419,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"
|
||||
>
|
||||
暂无请求记录
|
||||
@@ -450,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) }}
|
||||
@@ -461,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}` : '已删除用户')"
|
||||
>
|
||||
@@ -480,7 +532,7 @@
|
||||
</TableCell>
|
||||
<!-- 用户页面的密钥列 -->
|
||||
<TableCell
|
||||
v-if="!isAdmin"
|
||||
v-if="!isAdmin && isColumnVisible('key')"
|
||||
class="py-4 w-[17%]"
|
||||
:title="record.api_key?.name || '-'"
|
||||
>
|
||||
@@ -495,7 +547,9 @@
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
:class="['font-medium py-4', isAdmin ? 'w-[14%]' : 'w-[22%]']"
|
||||
v-if="isColumnVisible('model')"
|
||||
class="font-medium py-4"
|
||||
:class="[isAdmin ? 'w-[14%]' : 'w-[22%]']"
|
||||
:title="getModelTooltip(record)"
|
||||
>
|
||||
<div
|
||||
@@ -525,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">
|
||||
@@ -582,7 +636,9 @@
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
:class="['py-4', isAdmin ? 'w-[15%]' : 'w-[14%]']"
|
||||
v-if="isColumnVisible('api_format')"
|
||||
class="py-4"
|
||||
:class="[isAdmin ? 'w-[15%]' : 'w-[14%]']"
|
||||
:title="getApiFormatTooltip(record)"
|
||||
>
|
||||
<!-- 有格式转换或同族格式差异:两行显示 -->
|
||||
@@ -617,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'"
|
||||
@@ -668,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)) }}
|
||||
@@ -682,8 +738,8 @@
|
||||
</div>
|
||||
<div class="mt-0.5 grid w-full min-w-0 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] gap-x-1 text-xs leading-tight tabular-nums text-muted-foreground">
|
||||
<span
|
||||
class="justify-self-end whitespace-nowrap text-right"
|
||||
:class="[
|
||||
'justify-self-end whitespace-nowrap text-right',
|
||||
hasPositiveTokens(getRecordCacheReadTokens(record)) ? 'text-foreground/70' : ''
|
||||
]"
|
||||
>
|
||||
@@ -693,8 +749,8 @@
|
||||
/
|
||||
</span>
|
||||
<span
|
||||
class="justify-self-start whitespace-nowrap text-left"
|
||||
:class="[
|
||||
'justify-self-start whitespace-nowrap text-left',
|
||||
hasPositiveTokens(getRecordCacheCreationTokens(record)) ? 'text-foreground/70' : ''
|
||||
]"
|
||||
>
|
||||
@@ -702,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
|
||||
@@ -713,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'"
|
||||
@@ -746,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>
|
||||
@@ -768,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,
|
||||
@@ -808,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'
|
||||
|
||||
@@ -824,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
|
||||
@@ -838,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
|
||||
@@ -858,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]
|
||||
@@ -881,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) => ({
|
||||
@@ -897,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) => ({
|
||||
@@ -1048,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 {
|
||||
|
||||
108
frontend/src/features/usage/utils/__tests__/errorNotice.spec.ts
Normal file
108
frontend/src/features/usage/utils/__tests__/errorNotice.spec.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { RequestDetail } from '@/api/dashboard'
|
||||
import { resolveRequestFailureNotice } from '../errorNotice'
|
||||
|
||||
function buildRequestDetail(overrides: Partial<RequestDetail> = {}): RequestDetail {
|
||||
return {
|
||||
id: 'usage-1',
|
||||
request_id: 'req-1',
|
||||
user: {
|
||||
id: 'user-1',
|
||||
username: 'alice',
|
||||
email: 'alice@example.com',
|
||||
},
|
||||
api_key: {
|
||||
id: 'key-1',
|
||||
name: 'primary',
|
||||
display: 'primary',
|
||||
},
|
||||
provider: 'OpenAI',
|
||||
model: 'gpt-5',
|
||||
tokens: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
total: 0,
|
||||
},
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
total: 0,
|
||||
},
|
||||
request_type: 'chat',
|
||||
is_stream: true,
|
||||
status_code: 503,
|
||||
status: 'failed',
|
||||
response_time_ms: 0,
|
||||
created_at: '2026-05-14T10:33:21Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('request failure notice', () => {
|
||||
it('prioritizes local scheduling failure details over generic 503 status', () => {
|
||||
const notice = resolveRequestFailureNotice(buildRequestDetail({
|
||||
error_message: 'generic 503',
|
||||
failure_summary: {
|
||||
status_code: 503,
|
||||
message: '没有可用提供商支持模型 gpt-5 的流式请求',
|
||||
},
|
||||
scheduling_failure: {
|
||||
source: 'local_execution_runtime_miss',
|
||||
reason: 'all_candidates_skipped',
|
||||
reason_label: '所有候选均被跳过',
|
||||
title: '本地调度失败:所有候选均被跳过',
|
||||
message: '没有可用提供商支持模型 gpt-5 的流式请求',
|
||||
reason_summary: 'pool_account_exhausted 2 次',
|
||||
status_code: 503,
|
||||
no_upstream_attempt: true,
|
||||
},
|
||||
}))
|
||||
|
||||
expect(notice).toEqual({
|
||||
title: '本地调度失败:所有候选均被跳过',
|
||||
message: '没有可用提供商支持模型 gpt-5 的流式请求',
|
||||
isSchedulingFailure: true,
|
||||
meta: [
|
||||
'pool_account_exhausted 2 次',
|
||||
'所有候选均被跳过',
|
||||
'all_candidates_skipped',
|
||||
'HTTP 503',
|
||||
'未进入上游执行',
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to the failure summary for upstream failures', () => {
|
||||
const notice = resolveRequestFailureNotice(buildRequestDetail({
|
||||
failure_summary: {
|
||||
source: 'upstream_response',
|
||||
status_code: 429,
|
||||
type: 'insufficient_quota',
|
||||
message: 'quota exceeded',
|
||||
},
|
||||
}))
|
||||
|
||||
expect(notice).toEqual({
|
||||
title: '执行失败原因',
|
||||
message: 'quota exceeded',
|
||||
isSchedulingFailure: false,
|
||||
meta: ['HTTP 429', 'insufficient_quota', 'upstream_response'],
|
||||
})
|
||||
})
|
||||
|
||||
it('does not show a stale notice when the refreshed detail has no error fields', () => {
|
||||
const notice = resolveRequestFailureNotice(buildRequestDetail({
|
||||
status_code: 200,
|
||||
status: 'completed',
|
||||
error_message: undefined,
|
||||
scheduling_failure: null,
|
||||
failure_summary: null,
|
||||
client_error: null,
|
||||
upstream_error: null,
|
||||
request_error: null,
|
||||
}))
|
||||
|
||||
expect(notice).toBeNull()
|
||||
})
|
||||
})
|
||||
82
frontend/src/features/usage/utils/errorNotice.ts
Normal file
82
frontend/src/features/usage/utils/errorNotice.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import type { RequestDetail, RequestErrorDomain, RequestSchedulingFailure } from '@/api/dashboard'
|
||||
|
||||
export interface RequestFailureNotice {
|
||||
title: string
|
||||
message: string
|
||||
meta: string[]
|
||||
isSchedulingFailure: boolean
|
||||
}
|
||||
|
||||
function nonEmptyString(value: string | null | undefined): string | null {
|
||||
const trimmed = value?.trim()
|
||||
return trimmed ? trimmed : null
|
||||
}
|
||||
|
||||
function normalizeErrorDomain(domain: RequestErrorDomain | null | undefined): RequestErrorDomain | null {
|
||||
if (!nonEmptyString(domain?.message)) return null
|
||||
return domain ?? null
|
||||
}
|
||||
|
||||
function formatHttpStatus(statusCode: number | null | undefined): string | null {
|
||||
return typeof statusCode === 'number' ? `HTTP ${statusCode}` : null
|
||||
}
|
||||
|
||||
function uniqueMeta(values: Array<string | null | undefined>): string[] {
|
||||
return Array.from(new Set(values.map(value => value?.trim()).filter((value): value is string => Boolean(value))))
|
||||
}
|
||||
|
||||
function schedulingFailureMessage(
|
||||
failure: RequestSchedulingFailure,
|
||||
fallbackDomain: RequestErrorDomain | null,
|
||||
fallbackErrorMessage: string | null,
|
||||
): string | null {
|
||||
return nonEmptyString(failure.message)
|
||||
?? nonEmptyString(fallbackDomain?.message)
|
||||
?? fallbackErrorMessage
|
||||
?? nonEmptyString(failure.reason_label)
|
||||
?? nonEmptyString(failure.reason)
|
||||
}
|
||||
|
||||
export function resolveRequestFailureNotice(detail: RequestDetail | null | undefined): RequestFailureNotice | null {
|
||||
if (!detail) return null
|
||||
|
||||
const fallbackDomain = normalizeErrorDomain(detail.failure_summary)
|
||||
?? normalizeErrorDomain(detail.client_error)
|
||||
?? normalizeErrorDomain(detail.upstream_error)
|
||||
?? normalizeErrorDomain(detail.request_error)
|
||||
const fallbackErrorMessage = nonEmptyString(detail.error_message ?? null)
|
||||
const schedulingFailure = detail.scheduling_failure ?? null
|
||||
|
||||
if (schedulingFailure) {
|
||||
const message = schedulingFailureMessage(schedulingFailure, fallbackDomain, fallbackErrorMessage)
|
||||
if (message) {
|
||||
return {
|
||||
title: nonEmptyString(schedulingFailure.title) ?? '本地调度失败',
|
||||
message,
|
||||
isSchedulingFailure: true,
|
||||
meta: uniqueMeta([
|
||||
nonEmptyString(schedulingFailure.reason_summary),
|
||||
nonEmptyString(schedulingFailure.reason_label),
|
||||
nonEmptyString(schedulingFailure.reason),
|
||||
formatHttpStatus(schedulingFailure.status_code ?? detail.status_code),
|
||||
schedulingFailure.no_upstream_attempt ? '未进入上游执行' : null,
|
||||
]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const domain = fallbackDomain
|
||||
const message = nonEmptyString(domain?.message) ?? fallbackErrorMessage
|
||||
if (!message) return null
|
||||
|
||||
return {
|
||||
title: '执行失败原因',
|
||||
message,
|
||||
isSchedulingFailure: false,
|
||||
meta: uniqueMeta([
|
||||
formatHttpStatus(domain?.status_code ?? detail.status_code),
|
||||
nonEmptyString(domain?.type),
|
||||
nonEmptyString(domain?.source),
|
||||
]),
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
]
|
||||
}
|
||||
@@ -696,11 +784,13 @@ const navigation = computed(() => {
|
||||
{ name: '用户管理', href: '/admin/users', icon: Users },
|
||||
{ name: '提供商', href: '/admin/providers', icon: FolderTree },
|
||||
{ name: '模型管理', href: '/admin/models', icon: Layers },
|
||||
{ name: '调度策略', href: '/admin/routing', icon: SlidersHorizontal },
|
||||
{ name: '号池管理', href: '/admin/pool', icon: Database },
|
||||
{ name: '独立密钥', href: '/admin/keys', icon: Key },
|
||||
{ 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 },
|
||||
]
|
||||
|
||||
@@ -429,6 +429,102 @@ const MOCK_ALIASES = [
|
||||
{ id: 'alias-004', source_model: 'gemini-pro', target_global_model_id: 'gm-005', target_global_model_name: 'gemini-3-pro-preview', target_global_model_display_name: 'Gemini 3 Pro Preview', provider_id: null, provider_name: null, scope: 'global', mapping_type: 'alias', is_active: true, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' }
|
||||
]
|
||||
|
||||
interface MockRoutingGroup {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
enabled: boolean
|
||||
is_system_default: boolean
|
||||
config_json: Record<string, unknown>
|
||||
version: number
|
||||
created_at: number
|
||||
updated_at: number
|
||||
published_at: number | null
|
||||
}
|
||||
|
||||
interface MockRoutingGroupVersion {
|
||||
id: string
|
||||
group_id: string
|
||||
version: number
|
||||
config_json: Record<string, unknown>
|
||||
created_at: number
|
||||
created_by: string | null
|
||||
}
|
||||
|
||||
interface MockRoutingGroupBinding {
|
||||
id: string
|
||||
group_id: string
|
||||
subject_type: 'user' | 'api_key' | 'user_group'
|
||||
subject_id: string
|
||||
is_default: boolean
|
||||
allow_explicit_select: boolean
|
||||
created_at: number
|
||||
updated_at: number
|
||||
}
|
||||
|
||||
const mockRoutingNow = Math.floor(Date.now() / 1000)
|
||||
const MOCK_ROUTING_GROUPS: MockRoutingGroup[] = [
|
||||
{
|
||||
id: 'routing-default',
|
||||
name: '默认调度策略',
|
||||
description: '演示模式默认分组,保持 Provider 优先和缓存亲和',
|
||||
enabled: true,
|
||||
is_system_default: true,
|
||||
config_json: {
|
||||
allowed_models: [],
|
||||
default_policy: {
|
||||
priority_mode: 'provider',
|
||||
scheduling_mode: 'cache_affinity',
|
||||
keep_priority_on_conversion: false,
|
||||
},
|
||||
model_policies: [
|
||||
{
|
||||
model: 'gpt-5.1',
|
||||
allowed_providers: ['provider-002'],
|
||||
allowed_keys: [],
|
||||
provider_priority_overrides: { 'provider-002': 0 },
|
||||
key_priority_overrides: {},
|
||||
pool_policy_overrides: {},
|
||||
},
|
||||
],
|
||||
rules: [],
|
||||
},
|
||||
version: 1,
|
||||
created_at: mockRoutingNow - 86400,
|
||||
updated_at: mockRoutingNow - 3600,
|
||||
published_at: mockRoutingNow - 3600,
|
||||
},
|
||||
]
|
||||
|
||||
const MOCK_ROUTING_GROUP_VERSIONS: MockRoutingGroupVersion[] = [
|
||||
{
|
||||
id: 'routing-default-v1',
|
||||
group_id: 'routing-default',
|
||||
version: 1,
|
||||
config_json: MOCK_ROUTING_GROUPS[0].config_json,
|
||||
created_at: MOCK_ROUTING_GROUPS[0].published_at ?? mockRoutingNow,
|
||||
created_by: null,
|
||||
},
|
||||
]
|
||||
|
||||
const MOCK_ROUTING_GROUP_BINDINGS: MockRoutingGroupBinding[] = []
|
||||
|
||||
function cloneMockRoutingGroup(group: MockRoutingGroup): MockRoutingGroup {
|
||||
return JSON.parse(JSON.stringify(group)) as MockRoutingGroup
|
||||
}
|
||||
|
||||
function cloneMockRoutingVersion(version: MockRoutingGroupVersion): MockRoutingGroupVersion {
|
||||
return JSON.parse(JSON.stringify(version)) as MockRoutingGroupVersion
|
||||
}
|
||||
|
||||
function unsetOtherMockRoutingDefaults(groupId: string): void {
|
||||
for (const group of MOCK_ROUTING_GROUPS) {
|
||||
if (group.id !== groupId) {
|
||||
group.is_system_default = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeApiFormat(apiFormat: string): string {
|
||||
return apiFormat.toLowerCase().replace(/_/g, ':')
|
||||
}
|
||||
@@ -949,6 +1045,68 @@ const mockHandlers: Record<string, (config: AxiosRequestConfig) => Promise<Axios
|
||||
return createMockResponse({ ...body, id: `gm-demo-${Date.now()}`, created_at: new Date().toISOString() })
|
||||
},
|
||||
|
||||
// ========== Admin: Routing Profiles ==========
|
||||
'GET /api/admin/routing/groups': async () => {
|
||||
await delay()
|
||||
requireAdmin()
|
||||
return createMockResponse({
|
||||
items: MOCK_ROUTING_GROUPS.map(cloneMockRoutingGroup),
|
||||
total: MOCK_ROUTING_GROUPS.length,
|
||||
})
|
||||
},
|
||||
|
||||
'POST /api/admin/routing/groups': async (config) => {
|
||||
await delay()
|
||||
requireAdmin()
|
||||
const body = JSON.parse(config.data || '{}') as Partial<MockRoutingGroup>
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const group: MockRoutingGroup = {
|
||||
id: body.id || `routing-demo-${Date.now()}`,
|
||||
name: body.name || '未命名调度策略',
|
||||
description: body.description ?? null,
|
||||
enabled: body.enabled ?? true,
|
||||
is_system_default: body.is_system_default ?? false,
|
||||
config_json: body.config_json ?? {},
|
||||
version: 1,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
published_at: null,
|
||||
}
|
||||
if (group.is_system_default) {
|
||||
unsetOtherMockRoutingDefaults(group.id)
|
||||
}
|
||||
MOCK_ROUTING_GROUPS.unshift(group)
|
||||
return createMockResponse(cloneMockRoutingGroup(group))
|
||||
},
|
||||
|
||||
'GET /api/admin/routing/bindings': async () => {
|
||||
await delay()
|
||||
requireAdmin()
|
||||
return createMockResponse({
|
||||
items: MOCK_ROUTING_GROUP_BINDINGS.map(binding => ({ ...binding })),
|
||||
total: MOCK_ROUTING_GROUP_BINDINGS.length,
|
||||
})
|
||||
},
|
||||
|
||||
'POST /api/admin/routing/bindings': async (config) => {
|
||||
await delay()
|
||||
requireAdmin()
|
||||
const body = JSON.parse(config.data || '{}') as Partial<MockRoutingGroupBinding>
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const binding: MockRoutingGroupBinding = {
|
||||
id: body.id || `routing-binding-demo-${Date.now()}`,
|
||||
group_id: body.group_id || 'routing-default',
|
||||
subject_type: body.subject_type || 'api_key',
|
||||
subject_id: body.subject_id || 'demo',
|
||||
is_default: body.is_default ?? false,
|
||||
allow_explicit_select: body.allow_explicit_select ?? false,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
MOCK_ROUTING_GROUP_BINDINGS.unshift(binding)
|
||||
return createMockResponse({ ...binding })
|
||||
},
|
||||
|
||||
// ========== Admin: Model Mappings / Aliases ==========
|
||||
'GET /api/admin/models/mappings': async () => {
|
||||
await delay()
|
||||
@@ -2102,6 +2260,181 @@ registerDynamicRoute('POST', '/api/admin/models/global/:modelId/assign-to-provid
|
||||
return createMockResponse(result)
|
||||
})
|
||||
|
||||
registerDynamicRoute('GET', '/api/admin/routing/groups/:groupId', async (_config, params) => {
|
||||
await delay()
|
||||
requireAdmin()
|
||||
const group = MOCK_ROUTING_GROUPS.find(item => item.id === params.groupId)
|
||||
if (!group) {
|
||||
throw { response: createMockResponse({ detail: '调度策略不存在' }, 404) }
|
||||
}
|
||||
return createMockResponse(cloneMockRoutingGroup(group))
|
||||
})
|
||||
|
||||
registerDynamicRoute('PATCH', '/api/admin/routing/groups/:groupId', async (config, params) => {
|
||||
await delay()
|
||||
requireAdmin()
|
||||
const index = MOCK_ROUTING_GROUPS.findIndex(item => item.id === params.groupId)
|
||||
if (index < 0) {
|
||||
throw { response: createMockResponse({ detail: '调度策略不存在' }, 404) }
|
||||
}
|
||||
const body = JSON.parse(config.data || '{}') as Partial<MockRoutingGroup>
|
||||
const current = MOCK_ROUTING_GROUPS[index]
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const updated: MockRoutingGroup = {
|
||||
...current,
|
||||
...body,
|
||||
id: current.id,
|
||||
config_json: body.config_json ?? current.config_json,
|
||||
version: body.config_json ? current.version + 1 : (body.version ?? current.version),
|
||||
updated_at: now,
|
||||
}
|
||||
if (updated.is_system_default) {
|
||||
unsetOtherMockRoutingDefaults(updated.id)
|
||||
}
|
||||
MOCK_ROUTING_GROUPS[index] = updated
|
||||
return createMockResponse(cloneMockRoutingGroup(updated))
|
||||
})
|
||||
|
||||
registerDynamicRoute('DELETE', '/api/admin/routing/groups/:groupId', async (_config, params) => {
|
||||
await delay()
|
||||
requireAdmin()
|
||||
const index = MOCK_ROUTING_GROUPS.findIndex(item => item.id === params.groupId)
|
||||
if (index < 0) {
|
||||
throw { response: createMockResponse({ detail: '调度策略不存在' }, 404) }
|
||||
}
|
||||
MOCK_ROUTING_GROUPS.splice(index, 1)
|
||||
return createMockResponse({ message: '删除成功(演示模式)' })
|
||||
})
|
||||
|
||||
registerDynamicRoute('POST', '/api/admin/routing/groups/:groupId/publish', async (_config, params) => {
|
||||
await delay()
|
||||
requireAdmin()
|
||||
const group = MOCK_ROUTING_GROUPS.find(item => item.id === params.groupId)
|
||||
if (!group) {
|
||||
throw { response: createMockResponse({ detail: '调度策略不存在' }, 404) }
|
||||
}
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
group.published_at = now
|
||||
group.updated_at = now
|
||||
MOCK_ROUTING_GROUP_VERSIONS.unshift({
|
||||
id: `${group.id}-v${group.version}-${now}`,
|
||||
group_id: group.id,
|
||||
version: group.version,
|
||||
config_json: group.config_json,
|
||||
created_at: now,
|
||||
created_by: null,
|
||||
})
|
||||
return createMockResponse(cloneMockRoutingGroup(group))
|
||||
})
|
||||
|
||||
registerDynamicRoute('GET', '/api/admin/routing/groups/:groupId/versions', async (_config, params) => {
|
||||
await delay()
|
||||
requireAdmin()
|
||||
const versions = MOCK_ROUTING_GROUP_VERSIONS
|
||||
.filter(version => version.group_id === params.groupId)
|
||||
.map(cloneMockRoutingVersion)
|
||||
return createMockResponse({ items: versions, total: versions.length })
|
||||
})
|
||||
|
||||
registerDynamicRoute('POST', '/api/admin/routing/groups/:groupId/dry-run', async (config, params) => {
|
||||
await delay()
|
||||
requireAdmin()
|
||||
const group = MOCK_ROUTING_GROUPS.find(item => item.id === params.groupId)
|
||||
if (!group) {
|
||||
throw { response: createMockResponse({ detail: '调度策略不存在' }, 404) }
|
||||
}
|
||||
const body = JSON.parse(config.data || '{}') as {
|
||||
model?: string
|
||||
resolved_model?: string
|
||||
api_format?: string
|
||||
headers?: Record<string, string>
|
||||
body?: unknown
|
||||
}
|
||||
const model = body.model || 'gpt-5.1'
|
||||
const resolvedModel = body.resolved_model || model
|
||||
const rules = Array.isArray(group.config_json.rules)
|
||||
? group.config_json.rules as Array<{ id?: unknown; enabled?: unknown }>
|
||||
: []
|
||||
const selectedRules = rules
|
||||
.filter(rule => rule.enabled !== false && typeof rule.id === 'string')
|
||||
.map(rule => String(rule.id))
|
||||
const traceSeed = {
|
||||
group_id: group.id,
|
||||
group_version: group.version,
|
||||
selection_source: 'admin_dry_run',
|
||||
selected_rules: selectedRules,
|
||||
original_model: model,
|
||||
resolved_model: resolvedModel,
|
||||
client_api_format: body.api_format || 'openai:chat',
|
||||
global_candidates: [
|
||||
{
|
||||
candidate_kind: 'provider',
|
||||
provider_id: 'provider-002',
|
||||
endpoint_id: 'ep-002',
|
||||
model_id: resolvedModel,
|
||||
key_id: 'ekey-003',
|
||||
ranking_vector: {
|
||||
provider_priority_before: 0,
|
||||
provider_priority_after: 0,
|
||||
key_priority_before: 0,
|
||||
key_priority_after: 0,
|
||||
},
|
||||
skip_reason: null,
|
||||
selected_order: 0,
|
||||
},
|
||||
],
|
||||
pool_expansion: [],
|
||||
runtime_facts: {
|
||||
scheduler_mode: 'cache_affinity',
|
||||
priority_mode: 'provider',
|
||||
},
|
||||
}
|
||||
return createMockResponse({
|
||||
group: cloneMockRoutingGroup(group),
|
||||
policy: {
|
||||
selected_rules: selectedRules,
|
||||
ranking_overlay: {},
|
||||
},
|
||||
trace_seed: traceSeed,
|
||||
patch_summary: { body_paths: [], header_names: [], failed_action: null },
|
||||
mutated_body: body.body ?? { model },
|
||||
mutated_headers: body.headers ?? {},
|
||||
candidate_preview: {
|
||||
status: 'policy_only',
|
||||
ranking_overlay: {},
|
||||
note: '演示模式候选预览',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
registerDynamicRoute('PATCH', '/api/admin/routing/bindings/:bindingId', async (config, params) => {
|
||||
await delay()
|
||||
requireAdmin()
|
||||
const index = MOCK_ROUTING_GROUP_BINDINGS.findIndex(item => item.id === params.bindingId)
|
||||
if (index < 0) {
|
||||
throw { response: createMockResponse({ detail: '调度绑定不存在' }, 404) }
|
||||
}
|
||||
const body = JSON.parse(config.data || '{}') as Partial<MockRoutingGroupBinding>
|
||||
MOCK_ROUTING_GROUP_BINDINGS[index] = {
|
||||
...MOCK_ROUTING_GROUP_BINDINGS[index],
|
||||
...body,
|
||||
id: MOCK_ROUTING_GROUP_BINDINGS[index].id,
|
||||
updated_at: Math.floor(Date.now() / 1000),
|
||||
}
|
||||
return createMockResponse({ ...MOCK_ROUTING_GROUP_BINDINGS[index] })
|
||||
})
|
||||
|
||||
registerDynamicRoute('DELETE', '/api/admin/routing/bindings/:bindingId', async (_config, params) => {
|
||||
await delay()
|
||||
requireAdmin()
|
||||
const index = MOCK_ROUTING_GROUP_BINDINGS.findIndex(item => item.id === params.bindingId)
|
||||
if (index < 0) {
|
||||
throw { response: createMockResponse({ detail: '调度绑定不存在' }, 404) }
|
||||
}
|
||||
MOCK_ROUTING_GROUP_BINDINGS.splice(index, 1)
|
||||
return createMockResponse({ message: '删除成功(演示模式)' })
|
||||
})
|
||||
|
||||
// Endpoint Health 详情
|
||||
registerDynamicRoute('GET', '/api/admin/endpoints/health/endpoint/:endpointId', async (_config, params) => {
|
||||
await delay()
|
||||
|
||||
@@ -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',
|
||||
@@ -200,6 +222,11 @@ const routes: RouteRecordRaw[] = [
|
||||
name: 'ModelManagement',
|
||||
component: () => importWithRetry(() => import('@/views/admin/ModelManagement.vue'))
|
||||
},
|
||||
{
|
||||
path: 'routing',
|
||||
name: 'RoutingProfiles',
|
||||
component: () => importWithRetry(() => import('@/views/admin/RoutingProfiles.vue'))
|
||||
},
|
||||
{
|
||||
path: 'health-monitor',
|
||||
name: 'HealthMonitor',
|
||||
|
||||
@@ -25,11 +25,11 @@ describe('parseDateLike', () => {
|
||||
describe('datetime-local conversion', () => {
|
||||
it('formats RFC3339 instants for datetime-local inputs using local clock fields', () => {
|
||||
const date = new Date('2026-04-12T15:30:00Z')
|
||||
const expected = [
|
||||
const expected = `${[
|
||||
date.getFullYear(),
|
||||
padDatePart(date.getMonth() + 1),
|
||||
padDatePart(date.getDate()),
|
||||
].join('-') + `T${padDatePart(date.getHours())}:${padDatePart(date.getMinutes())}`
|
||||
].join('-') }T${padDatePart(date.getHours())}:${padDatePart(date.getMinutes())}`
|
||||
|
||||
expect(formatDateTimeLocalInput('2026-04-12T15:30:00Z')).toBe(expected)
|
||||
})
|
||||
|
||||
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,6 +4,7 @@ import { adminBillingPlansApi, epayGatewayApi } from '@/api/billing'
|
||||
import { getProvidersSummary } from '@/api/endpoints/providers'
|
||||
import { getPoolOverview, getPoolSchedulingPresets, listPoolKeys } from '@/api/endpoints/pool'
|
||||
import { listGlobalModels } from '@/api/global-models'
|
||||
import { listRoutingGroups } from '@/api/routing-profiles'
|
||||
import { usersApi } from '@/api/users'
|
||||
import { log } from '@/utils/logger'
|
||||
|
||||
@@ -50,6 +51,12 @@ const adminRouteWarmers: Record<string, () => Promise<void>> = {
|
||||
),
|
||||
])
|
||||
},
|
||||
'/admin/routing': async () => {
|
||||
await Promise.allSettled([
|
||||
import('@/views/admin/RoutingProfiles.vue'),
|
||||
listRoutingGroups(),
|
||||
])
|
||||
},
|
||||
'/admin/pool': async () => {
|
||||
const [overviewResult] = await Promise.allSettled([
|
||||
getPoolOverview({ cacheTtlMs: NAV_DATA_CACHE_TTL_MS }),
|
||||
|
||||
@@ -4,6 +4,7 @@ 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
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -511,7 +511,7 @@
|
||||
<span>用户: {{ selectedTask.username }}</span>
|
||||
</template>
|
||||
<span class="opacity-40">|</span>
|
||||
<span>{{ displayTaskSource(selectedTask) }}</span>
|
||||
<span>{{ displayTaskSource(selectedTask) }}</span>
|
||||
</div>
|
||||
<!-- 进度条 -->
|
||||
<div
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -41,12 +41,19 @@
|
||||
:class="selectedType === '__new__' ? 'bg-primary/10 text-primary' : 'text-foreground hover:bg-muted'"
|
||||
@click="selectNewConfig()"
|
||||
>
|
||||
<div class="w-7 h-7 rounded-md flex items-center justify-center text-xs font-semibold shrink-0"
|
||||
<div
|
||||
class="w-7 h-7 rounded-md flex items-center justify-center text-xs font-semibold shrink-0"
|
||||
:class="selectedType === '__new__' ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground'"
|
||||
>+</div>
|
||||
>
|
||||
+
|
||||
</div>
|
||||
<div class="flex-1 min-w-0 text-left">
|
||||
<div class="truncate font-medium text-sm">新配置</div>
|
||||
<div class="text-[10px] text-muted-foreground">未保存</div>
|
||||
<div class="truncate font-medium text-sm">
|
||||
新配置
|
||||
</div>
|
||||
<div class="text-[10px] text-muted-foreground">
|
||||
未保存
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -70,10 +77,12 @@
|
||||
src="https://cdn.linux.do/uploads/default/optimized/3X/9/d/9dd49731091ce8656243f3c2b6e5d5e5a7e3e3e3_2_32x32.png"
|
||||
class="absolute inset-0 w-full h-full object-cover"
|
||||
@error="($event.target as HTMLImageElement).remove()"
|
||||
/>
|
||||
>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0 text-left">
|
||||
<div class="truncate font-medium text-sm">{{ item.display_name }}</div>
|
||||
<div class="truncate font-medium text-sm">
|
||||
{{ item.display_name }}
|
||||
</div>
|
||||
<div class="text-[10px] text-muted-foreground">
|
||||
{{ item.configured ? (item.is_enabled ? '已启用' : '已禁用') : '未配置' }}
|
||||
</div>
|
||||
@@ -96,262 +105,261 @@
|
||||
|
||||
<!-- 右侧内容区 -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<!-- 配置表单 -->
|
||||
<CardSection
|
||||
v-if="selectedType"
|
||||
:title="selectedType === '__new__' ? '新建配置' : (selectedTypeMeta?.display_name || selectedType)"
|
||||
:description="selectedType === '__new__' ? '填写后点击保存' : (configs[selectedType]?.is_enabled ? '已启用' : '已禁用')"
|
||||
>
|
||||
<template #actions>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
:disabled="saving || testing"
|
||||
@click="handleTest"
|
||||
>
|
||||
{{ testing ? '测试中...' : '测试' }}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
:disabled="saving"
|
||||
@click="handleSave"
|
||||
>
|
||||
{{ saving ? '保存中...' : '保存' }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 配置表单 -->
|
||||
<CardSection
|
||||
v-if="selectedType"
|
||||
:title="selectedType === '__new__' ? '新建配置' : (selectedTypeMeta?.display_name || selectedType)"
|
||||
:description="selectedType === '__new__' ? '填写后点击保存' : (configs[selectedType]?.is_enabled ? '已启用' : '已禁用')"
|
||||
>
|
||||
<template #actions>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
:disabled="saving || testing"
|
||||
@click="handleTest"
|
||||
<div class="space-y-6">
|
||||
<!-- 新建时的 Display Name -->
|
||||
<div
|
||||
v-if="selectedType === '__new__'"
|
||||
class="grid grid-cols-1 md:grid-cols-2 gap-4"
|
||||
>
|
||||
{{ testing ? '测试中...' : '测试' }}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
:disabled="saving"
|
||||
@click="handleSave"
|
||||
>
|
||||
{{ saving ? '保存中...' : '保存' }}
|
||||
</Button>
|
||||
</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>
|
||||
|
||||
<!-- 凭证配置 -->
|
||||
<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>
|
||||
|
||||
<!-- 高级选项(折叠) -->
|
||||
<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>
|
||||
<Label class="block text-sm font-medium">显示名称</Label>
|
||||
<Input
|
||||
v-model="form.scopes_input"
|
||||
v-model="form.new_display_name"
|
||||
class="mt-1"
|
||||
:placeholder="selectedTypeMeta?.default_scopes?.join(' ') || '留空使用默认值'"
|
||||
placeholder="例如:My OIDC Provider"
|
||||
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>
|
||||
<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>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<!-- 凭证配置 -->
|
||||
<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>
|
||||
|
||||
<!-- 高级选项(折叠) -->
|
||||
<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">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"}"
|
||||
<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"
|
||||
/>
|
||||
</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 class="mt-1 text-xs text-muted-foreground">
|
||||
空格/逗号分隔;留空使用默认值
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- 测试结果 -->
|
||||
<div
|
||||
v-if="lastTestResult"
|
||||
class="mt-6 rounded-lg border border-border p-4 text-sm"
|
||||
>
|
||||
<div class="font-medium mb-2">
|
||||
测试结果
|
||||
<!-- 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="flex flex-wrap gap-4 text-xs">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="w-2 h-2 rounded-full"
|
||||
:class="lastTestResult.authorization_url_reachable ? 'bg-green-500' : 'bg-red-500'"
|
||||
/>
|
||||
<span class="text-muted-foreground">Authorization URL</span>
|
||||
|
||||
<!-- 测试结果 -->
|
||||
<div
|
||||
v-if="lastTestResult"
|
||||
class="mt-6 rounded-lg border border-border p-4 text-sm"
|
||||
>
|
||||
<div class="font-medium mb-2">
|
||||
测试结果
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex flex-wrap gap-4 text-xs">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="w-2 h-2 rounded-full"
|
||||
:class="lastTestResult.authorization_url_reachable ? 'bg-green-500' : 'bg-red-500'"
|
||||
/>
|
||||
<span class="text-muted-foreground">Authorization URL</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="w-2 h-2 rounded-full"
|
||||
:class="lastTestResult.token_url_reachable ? 'bg-green-500' : 'bg-red-500'"
|
||||
/>
|
||||
<span class="text-muted-foreground">Token URL</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="w-2 h-2 rounded-full"
|
||||
:class="lastTestResult.secret_status === 'likely_valid' ? 'bg-green-500' : lastTestResult.secret_status === 'invalid' ? 'bg-red-500' : 'bg-yellow-500'"
|
||||
/>
|
||||
<span class="text-muted-foreground">Secret: {{ lastTestResult.secret_status }}</span>
|
||||
</div>
|
||||
<span
|
||||
class="w-2 h-2 rounded-full"
|
||||
:class="lastTestResult.token_url_reachable ? 'bg-green-500' : 'bg-red-500'"
|
||||
/>
|
||||
<span class="text-muted-foreground">Token URL</span>
|
||||
v-if="lastTestResult.details"
|
||||
class="text-muted-foreground"
|
||||
>
|
||||
{{ lastTestResult.details }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="w-2 h-2 rounded-full"
|
||||
:class="lastTestResult.secret_status === 'likely_valid' ? 'bg-green-500' : lastTestResult.secret_status === 'invalid' ? 'bg-red-500' : 'bg-yellow-500'"
|
||||
/>
|
||||
<span class="text-muted-foreground">Secret: {{ lastTestResult.secret_status }}</span>
|
||||
</div>
|
||||
<span
|
||||
v-if="lastTestResult.details"
|
||||
class="text-muted-foreground"
|
||||
>
|
||||
{{ lastTestResult.details }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardSection>
|
||||
</CardSection>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
|
||||
@@ -822,7 +822,6 @@
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -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 '-'
|
||||
|
||||
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>
|
||||
1152
frontend/src/views/admin/RoutingProfiles.vue
Normal file
1152
frontend/src/views/admin/RoutingProfiles.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
@@ -23,7 +23,9 @@
|
||||
<div>API 端点</div>
|
||||
<div>推理程度</div>
|
||||
<div>映射参数</div>
|
||||
<div class="md:text-right">状态</div>
|
||||
<div class="md:text-right">
|
||||
状态
|
||||
</div>
|
||||
</div>
|
||||
<div class="divide-y">
|
||||
<div
|
||||
|
||||
@@ -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>
|
||||
@@ -50,15 +50,15 @@
|
||||
>
|
||||
<UsageModelTable
|
||||
:data="enhancedModelStats"
|
||||
:is-admin="authStore.canAccessAdmin"
|
||||
:is-admin="authStore.canAccessAdmin"
|
||||
/>
|
||||
<UsageProviderTable
|
||||
:data="providerStats"
|
||||
:is-admin="authStore.canAccessAdmin"
|
||||
:is-admin="authStore.canAccessAdmin"
|
||||
/>
|
||||
<UsageApiFormatTable
|
||||
:data="apiFormatStats"
|
||||
:is-admin="authStore.canAccessAdmin"
|
||||
:is-admin="authStore.canAccessAdmin"
|
||||
/>
|
||||
</div>
|
||||
<!-- 用户:模型 + API格式(2列) -->
|
||||
@@ -68,7 +68,7 @@
|
||||
>
|
||||
<UsageModelTable
|
||||
:data="enhancedModelStats"
|
||||
:is-admin="authStore.canAccessAdmin"
|
||||
:is-admin="authStore.canAccessAdmin"
|
||||
/>
|
||||
<UsageApiFormatTable
|
||||
:data="apiFormatStats"
|
||||
@@ -81,7 +81,7 @@
|
||||
<UsageRecordsTable
|
||||
:records="displayRecords"
|
||||
:is-admin="isAdminPage"
|
||||
:show-actual-cost="authStore.canAccessAdmin"
|
||||
:show-actual-cost="authStore.canAccessAdmin"
|
||||
:loading="isLoadingRecords"
|
||||
:time-range="timeRange"
|
||||
:filter-search="filterSearch"
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user