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:
MMEXA
2026-05-18 19:02:19 +00:00
446 changed files with 53043 additions and 4780 deletions

View 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,
},
})
})
})

View File

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

View File

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

View File

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

View File

@@ -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 () => {

View File

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

View File

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

View File

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

View File

@@ -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
}
/**

View File

@@ -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 表示不限制

View File

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

View File

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

View File

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

View File

@@ -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}`)
}

View File

@@ -24,6 +24,11 @@ export interface UsageRecord {
response_time?: number
created_at: string
has_fallback?: boolean // 🆕 是否发生了 fallback
client_family?: string | null
client_ip?: string | null
user_agent?: string | null
request_path?: string | null
request_path_and_query?: string | null
}
export interface UsageStats {
@@ -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?: {

View File

@@ -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>

View File

@@ -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

View File

@@ -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)

View File

@@ -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('')

View File

@@ -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('登录成功,正在跳转...')
})
})

View File

@@ -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和名称')

View File

@@ -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('未找到模型')
})
})

View File

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

View File

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

View File

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

View File

@@ -346,7 +346,7 @@
<Copy class="w-2.5 h-2.5" />
</Button>
<!-- OAuth 状态失效/过期/倒计时和刷新按钮 -->
<template v-if="shouldShowOAuthRefreshControl(key)">
<template v-if="shouldShowOAuthRefreshControl(key, provider.provider_type)">
<!-- 账号级别异常醒目提示 + 清除按钮 -->
<template v-if="isAccountLevelBlock(key)">
<Badge
@@ -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()] || ''
}

View File

@@ -60,6 +60,9 @@
<SelectItem value="gemini_cli">
Gemini CLI
</SelectItem>
<SelectItem value="grok">
Grok
</SelectItem>
<SelectItem value="kiro">
Kiro
</SelectItem>
@@ -87,6 +90,9 @@
<SelectItem value="gemini_cli">
Gemini CLI
</SelectItem>
<SelectItem value="grok">
Grok
</SelectItem>
<SelectItem value="kiro">
Kiro
</SelectItem>
@@ -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: '',
// 计费配置

View File

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

View File

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

View File

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

View File

@@ -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)
}
// 测试正则映射

View File

@@ -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}`
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: [

View File

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

View File

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

View File

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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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'

View File

@@ -0,0 +1,4 @@
export * from './components'
export * from './utils/routingConditions'
export * from './utils/routingPolicy'
export * from './utils/routingTrace'

View 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)
}

View 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'
}

View 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
})
}

View File

@@ -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

View File

@@ -139,6 +139,19 @@
<!-- 分隔线 -->
<div class="hidden sm:block h-4 w-px bg-border" />
<!-- 列显示配置桌面端 -->
<MultiSelect
v-model="visibleColumnIds"
:options="columnSelectOptions"
placeholder="显示列"
trigger-class="hidden md:flex w-40 h-8 text-xs border-border/60"
dropdown-min-width="14rem"
:searchable="false"
/>
<!-- 分隔线 -->
<div class="hidden sm:block h-4 w-px bg-border" />
<!-- 自动刷新按钮 -->
<Button
variant="ghost"
@@ -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
// 判断是否应该显示格式转换信息

View File

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

View File

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

View File

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

View File

@@ -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()
})
})

View 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),
]),
}
}

View File

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

View File

@@ -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()

View File

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

View File

@@ -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)
})

View File

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

View File

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

View File

@@ -4,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 }),

View File

@@ -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

View File

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

View File

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

View File

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

View File

@@ -511,7 +511,7 @@
<span>用户: {{ selectedTask.username }}</span>
</template>
<span class="opacity-40">|</span>
<span>{{ displayTaskSource(selectedTask) }}</span>
<span>{{ displayTaskSource(selectedTask) }}</span>
</div>
<!-- 进度条 -->
<div

View File

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

View File

@@ -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="{&quot;id&quot;: &quot;user_id&quot;, &quot;username&quot;: &quot;login&quot;}"
<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="{&quot;id&quot;: &quot;user_id&quot;, &quot;username&quot;: &quot;login&quot;}"
/>
</div>
<div>
<Label class="block text-sm font-medium">
{{ isSelectedCustomProvider ? 'Allowed Domains / Extra Config' : 'Extra Config' }}
</Label>
<Textarea
v-model="form.extra_config_json"
class="mt-1 font-mono text-xs"
rows="3"
:placeholder="extraConfigPlaceholder"
/>
<p
v-if="isSelectedCustomProvider"
class="mt-1 text-xs text-muted-foreground"
>
自定义 OIDC 必填填写 Authorization / Token / Userinfo URL 所属域名
</p>
</div>
</div>
</div>
</details>
</div>
<div class="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>

View File

@@ -822,7 +822,6 @@
</div>
</Card>
</div>
</div>
</template>

View File

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

View File

@@ -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>

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -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

View File

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

View File

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

View File

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

View File

@@ -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

View File

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

View File

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