mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
Merge remote-tracking branch 'upstream/main' into feat/356-usage-record-columns
This commit is contained in:
85
frontend/src/api/__tests__/admin-import-timeout.spec.ts
Normal file
85
frontend/src/api/__tests__/admin-import-timeout.spec.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AggregateImportRequest, ConfigImportRequest, UsersImportRequest } from '@/api/admin'
|
||||
|
||||
const { postMock } = vi.hoisted(() => ({
|
||||
postMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
default: {
|
||||
post: postMock,
|
||||
},
|
||||
}))
|
||||
|
||||
import { adminApi } from '@/api/admin'
|
||||
|
||||
const SYSTEM_DATA_IMPORT_TIMEOUT_MS = 10 * 60 * 1000
|
||||
|
||||
describe('adminApi system data import timeouts', () => {
|
||||
beforeEach(() => {
|
||||
postMock.mockReset()
|
||||
postMock.mockResolvedValue({ data: {} })
|
||||
})
|
||||
|
||||
it('uses a long timeout for config imports', async () => {
|
||||
const payload = {
|
||||
version: '1',
|
||||
exported_at: '2026-01-01T00:00:00.000Z',
|
||||
global_models: [],
|
||||
providers: [],
|
||||
merge_mode: 'skip',
|
||||
} satisfies ConfigImportRequest
|
||||
|
||||
await adminApi.importConfig(payload)
|
||||
|
||||
expect(postMock).toHaveBeenCalledWith(
|
||||
'/api/admin/system/config/import',
|
||||
payload,
|
||||
{ timeout: SYSTEM_DATA_IMPORT_TIMEOUT_MS }
|
||||
)
|
||||
})
|
||||
|
||||
it('uses a long timeout for user imports', async () => {
|
||||
const payload = {
|
||||
version: '1',
|
||||
exported_at: '2026-01-01T00:00:00.000Z',
|
||||
users: [],
|
||||
merge_mode: 'skip',
|
||||
} satisfies UsersImportRequest
|
||||
|
||||
await adminApi.importUsers(payload)
|
||||
|
||||
expect(postMock).toHaveBeenCalledWith(
|
||||
'/api/admin/system/users/import',
|
||||
payload,
|
||||
{ timeout: SYSTEM_DATA_IMPORT_TIMEOUT_MS }
|
||||
)
|
||||
})
|
||||
|
||||
it('uses a long timeout for aggregate imports', async () => {
|
||||
const payload = {
|
||||
version: '1',
|
||||
exported_at: '2026-01-01T00:00:00.000Z',
|
||||
config_data: {
|
||||
version: '1',
|
||||
exported_at: '2026-01-01T00:00:00.000Z',
|
||||
global_models: [],
|
||||
providers: [],
|
||||
},
|
||||
user_data: {
|
||||
version: '1',
|
||||
exported_at: '2026-01-01T00:00:00.000Z',
|
||||
users: [],
|
||||
},
|
||||
merge_mode: 'skip',
|
||||
} satisfies AggregateImportRequest
|
||||
|
||||
await adminApi.importAggregateData(payload)
|
||||
|
||||
expect(postMock).toHaveBeenCalledWith(
|
||||
'/api/admin/system/data/import',
|
||||
payload,
|
||||
{ timeout: SYSTEM_DATA_IMPORT_TIMEOUT_MS }
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -4,6 +4,8 @@ import { cachedRequest, buildCacheKey } from '@/utils/cache'
|
||||
import type { BillingSummary } from './auth'
|
||||
import type { ApiKeyInstallSession, InstallSessionTargetSystem, InstallTargetCli } from './me'
|
||||
|
||||
const SYSTEM_DATA_IMPORT_TIMEOUT_MS = 10 * 60 * 1000
|
||||
|
||||
function extractConflictPayload(error: unknown): ManualUsageCleanupConflict | null {
|
||||
if (!axios.isAxiosError(error) || error.response?.status !== 409) {
|
||||
return null
|
||||
@@ -310,15 +312,35 @@ export interface ManualUsageCleanupSummary {
|
||||
records_deleted: number
|
||||
}
|
||||
|
||||
export interface ManualUsageCleanupResponse {
|
||||
export type ManualUsageCleanupMode = 'policy' | 'older_than_days' | 'before_now'
|
||||
export type ManualUsageCleanupTarget = 'detail_body' | 'compressed_body' | 'headers' | 'records'
|
||||
|
||||
export interface ManualUsageCleanupTargets {
|
||||
detail_body: boolean
|
||||
compressed_body: boolean
|
||||
headers: boolean
|
||||
records: boolean
|
||||
expired_keys: boolean
|
||||
}
|
||||
|
||||
export interface ManualUsageCleanupRequest {
|
||||
mode?: ManualUsageCleanupMode
|
||||
older_than_days?: number
|
||||
targets?: ManualUsageCleanupTarget[]
|
||||
}
|
||||
|
||||
export interface ManualUsageCleanupTaskResponse {
|
||||
message: string
|
||||
mode: ManualUsageCleanupMode
|
||||
requested_older_than_days: number | null
|
||||
summary: ManualUsageCleanupSummary
|
||||
total_affected: number
|
||||
targets: ManualUsageCleanupTargets
|
||||
task: CleanupRunRecord
|
||||
}
|
||||
|
||||
export interface ManualUsageCleanupPreview {
|
||||
mode: ManualUsageCleanupMode
|
||||
requested_older_than_days: number | null
|
||||
targets: ManualUsageCleanupTargets
|
||||
effective_cutoffs: {
|
||||
detail: string
|
||||
compressed: string
|
||||
@@ -834,7 +856,8 @@ export const adminApi = {
|
||||
async importConfig(data: ConfigImportRequest): Promise<ConfigImportResponse> {
|
||||
const response = await apiClient.post<ConfigImportResponse>(
|
||||
'/api/admin/system/config/import',
|
||||
data
|
||||
data,
|
||||
{ timeout: SYSTEM_DATA_IMPORT_TIMEOUT_MS }
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
@@ -849,7 +872,8 @@ export const adminApi = {
|
||||
async importUsers(data: UsersImportRequest): Promise<UsersImportResponse> {
|
||||
const response = await apiClient.post<UsersImportResponse>(
|
||||
'/api/admin/system/users/import',
|
||||
data
|
||||
data,
|
||||
{ timeout: SYSTEM_DATA_IMPORT_TIMEOUT_MS }
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
@@ -864,7 +888,8 @@ export const adminApi = {
|
||||
async importAggregateData(data: AggregateImportRequest): Promise<AggregateImportResponse> {
|
||||
const response = await apiClient.post<AggregateImportResponse>(
|
||||
'/api/admin/system/data/import',
|
||||
data
|
||||
data,
|
||||
{ timeout: SYSTEM_DATA_IMPORT_TIMEOUT_MS }
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
@@ -1218,14 +1243,20 @@ export const adminApi = {
|
||||
},
|
||||
|
||||
async runManualUsageCleanup(
|
||||
params: { older_than_days?: number } = {}
|
||||
): Promise<ManualUsageCleanupResponse | ManualUsageCleanupConflict> {
|
||||
const body: Record<string, number> = {}
|
||||
params: ManualUsageCleanupRequest = {}
|
||||
): Promise<ManualUsageCleanupTaskResponse | ManualUsageCleanupConflict> {
|
||||
const body: ManualUsageCleanupRequest = {}
|
||||
if (params.mode) {
|
||||
body.mode = params.mode
|
||||
}
|
||||
if (typeof params.older_than_days === 'number') {
|
||||
body.older_than_days = params.older_than_days
|
||||
}
|
||||
if (params.targets?.length) {
|
||||
body.targets = params.targets
|
||||
}
|
||||
try {
|
||||
const response = await apiClient.post<ManualUsageCleanupResponse>(
|
||||
const response = await apiClient.post<ManualUsageCleanupTaskResponse>(
|
||||
'/api/admin/system/cleanup/usage/manual',
|
||||
body
|
||||
)
|
||||
@@ -1240,12 +1271,18 @@ export const adminApi = {
|
||||
},
|
||||
|
||||
async previewManualUsageCleanup(
|
||||
params: { older_than_days?: number } = {}
|
||||
params: ManualUsageCleanupRequest = {}
|
||||
): Promise<ManualUsageCleanupPreview> {
|
||||
const query: Record<string, number> = {}
|
||||
const query: Record<string, string | number> = {}
|
||||
if (params.mode) {
|
||||
query.mode = params.mode
|
||||
}
|
||||
if (typeof params.older_than_days === 'number') {
|
||||
query.older_than_days = params.older_than_days
|
||||
}
|
||||
if (params.targets?.length) {
|
||||
query.targets = params.targets.join(',')
|
||||
}
|
||||
const response = await apiClient.get<ManualUsageCleanupPreview>(
|
||||
'/api/admin/system/cleanup/usage/preview',
|
||||
{ params: query }
|
||||
|
||||
186
frontend/src/api/routing-profiles.ts
Normal file
186
frontend/src/api/routing-profiles.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import client from './client'
|
||||
import type { RoutingDecisionTrace } from '@/features/routing/utils/routingTrace'
|
||||
import type {
|
||||
RoutingGroupConfig,
|
||||
RoutingRulePhase,
|
||||
} from '@/features/routing/utils/routingPolicy'
|
||||
|
||||
export type RoutingBindingSubjectType = 'user' | 'api_key' | 'user_group'
|
||||
|
||||
export interface RoutingGroupRecord {
|
||||
id: string
|
||||
name: string
|
||||
description?: string | null
|
||||
enabled: boolean
|
||||
is_system_default: boolean
|
||||
config_json: RoutingGroupConfig
|
||||
version: number
|
||||
created_at: number
|
||||
updated_at: number
|
||||
published_at?: number | null
|
||||
}
|
||||
|
||||
export interface RoutingGroupListResponse {
|
||||
items: RoutingGroupRecord[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface RoutingGroupVersionRecord {
|
||||
id: string
|
||||
group_id: string
|
||||
version: number
|
||||
config_json: RoutingGroupConfig
|
||||
created_at: number
|
||||
created_by?: string | null
|
||||
}
|
||||
|
||||
export interface RoutingGroupVersionListResponse {
|
||||
items: RoutingGroupVersionRecord[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface RoutingGroupBindingRecord {
|
||||
id: string
|
||||
group_id: string
|
||||
subject_type: RoutingBindingSubjectType
|
||||
subject_id: string
|
||||
is_default: boolean
|
||||
allow_explicit_select: boolean
|
||||
created_at: number
|
||||
updated_at: number
|
||||
}
|
||||
|
||||
export interface RoutingGroupBindingListResponse {
|
||||
items: RoutingGroupBindingRecord[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface RoutingGroupCreateRequest {
|
||||
id?: string
|
||||
name: string
|
||||
description?: string | null
|
||||
enabled?: boolean
|
||||
is_system_default?: boolean
|
||||
config_json?: RoutingGroupConfig
|
||||
}
|
||||
|
||||
export interface RoutingGroupUpdateRequest {
|
||||
name?: string
|
||||
description?: string | null
|
||||
enabled?: boolean
|
||||
is_system_default?: boolean
|
||||
config_json?: RoutingGroupConfig
|
||||
version?: number
|
||||
published_at?: number | null
|
||||
}
|
||||
|
||||
export interface RoutingGroupBindingCreateRequest {
|
||||
id?: string
|
||||
group_id: string
|
||||
subject_type: RoutingBindingSubjectType
|
||||
subject_id: string
|
||||
is_default?: boolean
|
||||
allow_explicit_select?: boolean
|
||||
}
|
||||
|
||||
export interface RoutingGroupBindingUpdateRequest {
|
||||
group_id?: string
|
||||
subject_type?: RoutingBindingSubjectType
|
||||
subject_id?: string
|
||||
is_default?: boolean
|
||||
allow_explicit_select?: boolean
|
||||
}
|
||||
|
||||
export interface RoutingDryRunRequest {
|
||||
model: string
|
||||
resolved_model?: string
|
||||
api_format?: string
|
||||
user_id?: string
|
||||
api_key_id?: string
|
||||
headers?: Record<string, string>
|
||||
body?: unknown
|
||||
phase?: RoutingRulePhase
|
||||
}
|
||||
|
||||
export interface RoutingDryRunResponse {
|
||||
group: RoutingGroupRecord
|
||||
policy: unknown
|
||||
trace_seed: RoutingDecisionTrace
|
||||
patch_summary: unknown
|
||||
mutated_body: unknown
|
||||
mutated_headers: Record<string, string>
|
||||
candidate_preview: unknown
|
||||
}
|
||||
|
||||
export async function listRoutingGroups(): Promise<RoutingGroupListResponse> {
|
||||
const response = await client.get<RoutingGroupListResponse>('/api/admin/routing/groups')
|
||||
return response.data
|
||||
}
|
||||
|
||||
export async function getRoutingGroup(groupId: string): Promise<RoutingGroupRecord> {
|
||||
const response = await client.get<RoutingGroupRecord>(`/api/admin/routing/groups/${groupId}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export async function createRoutingGroup(data: RoutingGroupCreateRequest): Promise<RoutingGroupRecord> {
|
||||
const response = await client.post<RoutingGroupRecord>('/api/admin/routing/groups', data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export async function updateRoutingGroup(
|
||||
groupId: string,
|
||||
data: RoutingGroupUpdateRequest
|
||||
): Promise<RoutingGroupRecord> {
|
||||
const response = await client.patch<RoutingGroupRecord>(`/api/admin/routing/groups/${groupId}`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export async function deleteRoutingGroup(groupId: string): Promise<void> {
|
||||
await client.delete(`/api/admin/routing/groups/${groupId}`)
|
||||
}
|
||||
|
||||
export async function publishRoutingGroup(groupId: string): Promise<RoutingGroupRecord> {
|
||||
const response = await client.post<RoutingGroupRecord>(`/api/admin/routing/groups/${groupId}/publish`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export async function listRoutingGroupVersions(groupId: string): Promise<RoutingGroupVersionListResponse> {
|
||||
const response = await client.get<RoutingGroupVersionListResponse>(`/api/admin/routing/groups/${groupId}/versions`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export async function dryRunRoutingGroup(
|
||||
groupId: string,
|
||||
data: RoutingDryRunRequest
|
||||
): Promise<RoutingDryRunResponse> {
|
||||
const response = await client.post<RoutingDryRunResponse>(`/api/admin/routing/groups/${groupId}/dry-run`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export async function listRoutingGroupBindings(params?: {
|
||||
group_id?: string
|
||||
subject_type?: RoutingBindingSubjectType
|
||||
subject_id?: string
|
||||
}): Promise<RoutingGroupBindingListResponse> {
|
||||
const response = await client.get<RoutingGroupBindingListResponse>('/api/admin/routing/bindings', { params })
|
||||
return response.data
|
||||
}
|
||||
|
||||
export async function createRoutingGroupBinding(
|
||||
data: RoutingGroupBindingCreateRequest
|
||||
): Promise<RoutingGroupBindingRecord> {
|
||||
const response = await client.post<RoutingGroupBindingRecord>('/api/admin/routing/bindings', data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export async function updateRoutingGroupBinding(
|
||||
bindingId: string,
|
||||
data: RoutingGroupBindingUpdateRequest
|
||||
): Promise<RoutingGroupBindingRecord> {
|
||||
const response = await client.patch<RoutingGroupBindingRecord>(`/api/admin/routing/bindings/${bindingId}`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export async function deleteRoutingGroupBinding(bindingId: string): Promise<void> {
|
||||
await client.delete(`/api/admin/routing/bindings/${bindingId}`)
|
||||
}
|
||||
@@ -2,10 +2,6 @@
|
||||
import { PopoverTrigger } from 'radix-vue'
|
||||
import { useAttrs } from 'vue'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
withDefaults(defineProps<{
|
||||
asChild?: boolean
|
||||
as?: string
|
||||
@@ -14,6 +10,10 @@ withDefaults(defineProps<{
|
||||
as: 'button',
|
||||
})
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const attrs = useAttrs()
|
||||
</script>
|
||||
|
||||
|
||||
@@ -131,13 +131,19 @@
|
||||
|
||||
<!-- 登录表单 -->
|
||||
<form
|
||||
ref="loginFormEl"
|
||||
name="login"
|
||||
action="/api/auth/login"
|
||||
method="post"
|
||||
class="space-y-4"
|
||||
autocomplete="on"
|
||||
data-form-type="login"
|
||||
@submit.prevent="handleLogin"
|
||||
>
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex items-center justify-between">
|
||||
<Label
|
||||
for="login-email"
|
||||
for="username"
|
||||
class="text-sm"
|
||||
>
|
||||
{{ emailLabel }}
|
||||
@@ -160,29 +166,35 @@
|
||||
</button>
|
||||
</div>
|
||||
<Input
|
||||
id="login-email"
|
||||
id="username"
|
||||
v-model="form.email"
|
||||
type="text"
|
||||
name="username"
|
||||
required
|
||||
placeholder="用户名或邮箱"
|
||||
autocomplete="off"
|
||||
autocomplete="username"
|
||||
autocapitalize="none"
|
||||
spellcheck="false"
|
||||
:disable-autofill="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label
|
||||
for="login-password"
|
||||
for="password"
|
||||
class="text-sm"
|
||||
>
|
||||
密码
|
||||
</Label>
|
||||
<Input
|
||||
id="login-password"
|
||||
id="password"
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
name="password"
|
||||
required
|
||||
placeholder="输入密码"
|
||||
autocomplete="off"
|
||||
autocomplete="current-password"
|
||||
:disable-autofill="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -288,6 +300,7 @@ const ldapEnabled = ref(false)
|
||||
const ldapExclusive = ref(false)
|
||||
|
||||
const oauthProviders = ref<OAuthProviderInfo[]>([])
|
||||
const loginFormEl = ref<HTMLFormElement | null>(null)
|
||||
|
||||
// 保存用户的认证类型偏好
|
||||
watch(authType, (newType) => {
|
||||
@@ -328,30 +341,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 使用默认跳转逻辑
|
||||
|
||||
@@ -211,7 +211,6 @@
|
||||
两次输入的密码不一致
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
<!-- 登录链接 -->
|
||||
|
||||
@@ -17,6 +17,22 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue?: string
|
||||
siteKey: string
|
||||
action?: string
|
||||
disabled?: boolean
|
||||
}>(), {
|
||||
modelValue: '',
|
||||
action: undefined,
|
||||
disabled: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
error: [message: string]
|
||||
}>()
|
||||
|
||||
const TURNSTILE_SCRIPT_URL = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'
|
||||
|
||||
type TurnstileWidgetId = string
|
||||
@@ -46,22 +62,6 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue?: string
|
||||
siteKey: string
|
||||
action?: string
|
||||
disabled?: boolean
|
||||
}>(), {
|
||||
modelValue: '',
|
||||
action: undefined,
|
||||
disabled: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
error: [message: string]
|
||||
}>()
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
const widgetId = ref<TurnstileWidgetId | null>(null)
|
||||
const errorMessage = ref('')
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, defineComponent, h, nextTick, type App } from 'vue'
|
||||
|
||||
import LoginDialog from '../LoginDialog.vue'
|
||||
|
||||
const authStoreMock = vi.hoisted(() => ({
|
||||
loading: false,
|
||||
error: '',
|
||||
canAccessAdmin: false,
|
||||
login: vi.fn(),
|
||||
}))
|
||||
|
||||
const routerPushMock = vi.hoisted(() => vi.fn())
|
||||
const toastMocks = vi.hoisted(() => ({
|
||||
success: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}))
|
||||
|
||||
const authApiMocks = vi.hoisted(() => ({
|
||||
getRegistrationSettings: vi.fn(),
|
||||
getAuthSettings: vi.fn(),
|
||||
}))
|
||||
|
||||
const oauthApiMocks = vi.hoisted(() => ({
|
||||
getProviders: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({
|
||||
push: routerPushMock,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/auth', () => ({
|
||||
useAuthStore: () => authStoreMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useToast', () => ({
|
||||
useToast: () => toastMocks,
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useSiteInfo', () => ({
|
||||
useSiteInfo: () => ({
|
||||
siteName: 'Aether',
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/config/demo', () => ({
|
||||
isDemoMode: () => false,
|
||||
DEMO_ACCOUNTS: {
|
||||
admin: { email: 'admin@demo.aether.io', password: 'demo123' },
|
||||
user: { email: 'user@demo.aether.io', password: 'demo123' },
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/api/auth', () => ({
|
||||
authApi: authApiMocks,
|
||||
}))
|
||||
|
||||
vi.mock('@/api/oauth', () => ({
|
||||
oauthApi: oauthApiMocks,
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/deviceId', () => ({
|
||||
getClientDeviceId: () => 'device-123',
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/url', () => ({
|
||||
getApiUrl: (path: string) => path,
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/oauth-icons', () => ({
|
||||
getOAuthIcon: () => '',
|
||||
}))
|
||||
|
||||
vi.mock('../RegisterDialog.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'RegisterDialogStub',
|
||||
setup() {
|
||||
return () => null
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui', async () => {
|
||||
const { defineComponent, h } = await import('vue')
|
||||
return {
|
||||
Dialog: defineComponent({
|
||||
name: 'DialogStub',
|
||||
props: {
|
||||
modelValue: { type: Boolean, default: false },
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup(props, { slots }) {
|
||||
return () => props.modelValue ? h('div', { 'data-testid': 'dialog' }, slots.default?.()) : null
|
||||
},
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/components/ui/button.vue', async () => {
|
||||
const { defineComponent, h } = await import('vue')
|
||||
return {
|
||||
default: defineComponent({
|
||||
name: 'ButtonStub',
|
||||
props: {
|
||||
disabled: { type: Boolean, default: false },
|
||||
type: { type: String, default: 'button' },
|
||||
},
|
||||
setup(props, { attrs, slots }) {
|
||||
return () => h('button', {
|
||||
...attrs,
|
||||
type: props.type,
|
||||
disabled: props.disabled,
|
||||
}, slots.default?.())
|
||||
},
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/components/ui/label.vue', async () => {
|
||||
const { defineComponent, h } = await import('vue')
|
||||
return {
|
||||
default: defineComponent({
|
||||
name: 'LabelStub',
|
||||
setup(_props, { attrs, slots }) {
|
||||
return () => h('label', attrs, slots.default?.())
|
||||
},
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
|
||||
|
||||
function mountLoginDialog() {
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const app = createApp(LoginDialog, {
|
||||
modelValue: true,
|
||||
'onUpdate:modelValue': vi.fn(),
|
||||
})
|
||||
app.mount(root)
|
||||
mountedApps.push({ app, root })
|
||||
return root
|
||||
}
|
||||
|
||||
async function settle() {
|
||||
for (let index = 0; index < 4; index += 1) {
|
||||
await Promise.resolve()
|
||||
await nextTick()
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
authStoreMock.loading = false
|
||||
authStoreMock.error = ''
|
||||
authStoreMock.canAccessAdmin = false
|
||||
authStoreMock.login.mockReset()
|
||||
routerPushMock.mockReset()
|
||||
toastMocks.success.mockReset()
|
||||
toastMocks.warning.mockReset()
|
||||
toastMocks.error.mockReset()
|
||||
authApiMocks.getRegistrationSettings.mockResolvedValue({
|
||||
enable_registration: false,
|
||||
require_email_verification: false,
|
||||
email_configured: true,
|
||||
password_policy_level: 'weak',
|
||||
turnstile_enabled: false,
|
||||
turnstile_site_key: null,
|
||||
})
|
||||
authApiMocks.getAuthSettings.mockResolvedValue({
|
||||
local_enabled: true,
|
||||
ldap_enabled: false,
|
||||
ldap_exclusive: false,
|
||||
})
|
||||
oauthApiMocks.getProviders.mockResolvedValue([])
|
||||
sessionStorage.clear()
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const { app, root } of mountedApps.splice(0)) {
|
||||
app.unmount()
|
||||
root.remove()
|
||||
}
|
||||
document.body.innerHTML = ''
|
||||
sessionStorage.clear()
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
describe('LoginDialog password manager contract', () => {
|
||||
it('exposes standard login form and field autocomplete metadata', async () => {
|
||||
const root = mountLoginDialog()
|
||||
await settle()
|
||||
|
||||
const form = root.querySelector('form')
|
||||
expect(form?.getAttribute('name')).toBe('login')
|
||||
expect(form?.getAttribute('action')).toBe('/api/auth/login')
|
||||
expect(form?.getAttribute('method')).toBe('post')
|
||||
expect(form?.getAttribute('autocomplete')).toBe('on')
|
||||
expect(form?.getAttribute('data-form-type')).toBe('login')
|
||||
|
||||
const username = root.querySelector<HTMLInputElement>('input[name="username"]')
|
||||
const password = root.querySelector<HTMLInputElement>('input[name="password"]')
|
||||
|
||||
expect(username?.id).toBe('username')
|
||||
expect(username?.getAttribute('autocomplete')).toBe('username')
|
||||
expect(username?.getAttribute('autocapitalize')).toBe('none')
|
||||
expect(username?.getAttribute('spellcheck')).toBe('false')
|
||||
expect(password?.id).toBe('password')
|
||||
expect(password?.type).toBe('password')
|
||||
expect(password?.getAttribute('autocomplete')).toBe('current-password')
|
||||
})
|
||||
|
||||
it('submits DOM-filled credentials and awaits router navigation without timer delay', async () => {
|
||||
authStoreMock.login.mockResolvedValue(true)
|
||||
routerPushMock.mockResolvedValue(undefined)
|
||||
sessionStorage.setItem('redirectPath', '/admin/dashboard')
|
||||
const root = mountLoginDialog()
|
||||
await settle()
|
||||
|
||||
const form = root.querySelector('form')
|
||||
const username = root.querySelector<HTMLInputElement>('input[name="username"]')
|
||||
const password = root.querySelector<HTMLInputElement>('input[name="password"]')
|
||||
expect(form).not.toBeNull()
|
||||
expect(username).not.toBeNull()
|
||||
expect(password).not.toBeNull()
|
||||
|
||||
username!.value = ' admin@example.com '
|
||||
password!.value = 'secret-from-manager'
|
||||
form!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
|
||||
await settle()
|
||||
|
||||
expect(authStoreMock.login).toHaveBeenCalledWith('admin@example.com', 'secret-from-manager', 'local')
|
||||
expect(routerPushMock).toHaveBeenCalledWith('/admin/dashboard')
|
||||
expect(sessionStorage.getItem('redirectPath')).toBeNull()
|
||||
expect(toastMocks.success).toHaveBeenCalledWith('登录成功,正在跳转...')
|
||||
})
|
||||
})
|
||||
@@ -1361,7 +1361,14 @@ let providerLoadRequestId = 0
|
||||
let endpointsLoadRequestId = 0
|
||||
let keysLoadRequestId = 0
|
||||
let mappingPreviewLoadRequestId = 0
|
||||
const PROVIDER_KEYS_PAGE_SIZE = 20
|
||||
const DEFAULT_PROVIDER_KEYS_PAGE_SIZE = 3
|
||||
const CUSTOM_PROVIDER_KEYS_PAGE_SIZE = 4
|
||||
|
||||
function getProviderKeysPageSize(providerType?: string | null): number {
|
||||
return (providerType || '').trim().toLowerCase() === 'custom'
|
||||
? CUSTOM_PROVIDER_KEYS_PAGE_SIZE
|
||||
: DEFAULT_PROVIDER_KEYS_PAGE_SIZE
|
||||
}
|
||||
|
||||
// 系统级格式转换配置
|
||||
const systemFormatConversionEnabled = ref(false)
|
||||
@@ -1521,7 +1528,7 @@ function syncCurrentSelections(
|
||||
// ===== 账号列表后端分页 =====
|
||||
const providerKeysTotal = ref(0)
|
||||
const currentKeyPage = ref(1)
|
||||
const keyPageSize = ref(PROVIDER_KEYS_PAGE_SIZE)
|
||||
const keyPageSize = ref(DEFAULT_PROVIDER_KEYS_PAGE_SIZE)
|
||||
const totalKeyPages = computed(() => Math.max(1, Math.ceil(providerKeysTotal.value / keyPageSize.value)))
|
||||
const shouldPaginateKeys = computed(() => totalKeyPages.value > 1)
|
||||
const paginatedKeys = computed(() => allKeys.value)
|
||||
@@ -1548,15 +1555,16 @@ watch(
|
||||
const hasInitialProvider = props.initialProvider?.id === newId
|
||||
if (hasInitialProvider) {
|
||||
provider.value = props.initialProvider
|
||||
keyPageSize.value = getProviderKeysPageSize(provider.value?.provider_type)
|
||||
loading.value = false
|
||||
}
|
||||
void loadSystemFormatConversionConfig()
|
||||
// mapping-preview 较慢,不阻塞首屏渲染
|
||||
void loadMappingPreview()
|
||||
const endpointsPromise = loadEndpoints()
|
||||
if (!hasInitialProvider) {
|
||||
await loadProvider()
|
||||
}
|
||||
const endpointsPromise = loadEndpoints()
|
||||
// 仅在抽屉刚打开时启动倒计时
|
||||
if (newOpen && !oldOpen) {
|
||||
startCountdownTimer()
|
||||
@@ -1578,7 +1586,7 @@ watch(
|
||||
providerKeys.value = [] // 清空 Provider 级别的 keys
|
||||
providerKeysTotal.value = 0
|
||||
currentKeyPage.value = 1
|
||||
keyPageSize.value = PROVIDER_KEYS_PAGE_SIZE
|
||||
keyPageSize.value = DEFAULT_PROVIDER_KEYS_PAGE_SIZE
|
||||
providerModels.value = []
|
||||
providerMappingPreview.value = null
|
||||
loadingProviderEndpoints.value = false
|
||||
@@ -3484,6 +3492,7 @@ async function loadProvider() {
|
||||
const providerData = await getProvider(props.providerId)
|
||||
if (requestId !== providerLoadRequestId) return
|
||||
provider.value = providerData
|
||||
keyPageSize.value = getProviderKeysPageSize(providerData.provider_type)
|
||||
|
||||
if (!provider.value) {
|
||||
throw new Error('Provider 不存在')
|
||||
|
||||
@@ -28,7 +28,10 @@
|
||||
{{ manualGlobalModelMode ? '选择已有模型' : '手动添加' }}
|
||||
</Button>
|
||||
</div>
|
||||
<div v-if="!manualGlobalModelMode" class="space-y-2">
|
||||
<div
|
||||
v-if="!manualGlobalModelMode"
|
||||
class="space-y-2"
|
||||
>
|
||||
<Select
|
||||
:model-value="form.global_model_id"
|
||||
:disabled="loadingGlobalModels"
|
||||
@@ -48,10 +51,16 @@
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div v-else class="rounded-lg border border-border/60 bg-muted/20 p-3 space-y-3">
|
||||
<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>
|
||||
<Label
|
||||
for="manual-global-model-name"
|
||||
class="text-xs"
|
||||
>模型ID *</Label>
|
||||
<Input
|
||||
id="manual-global-model-name"
|
||||
v-model="form.manual_global_model_name"
|
||||
@@ -60,7 +69,10 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label for="manual-global-model-display-name" class="text-xs">显示名称</Label>
|
||||
<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"
|
||||
@@ -79,7 +91,10 @@
|
||||
没有可选择的本地全局模型。可以切换到“手动添加”继续保存。
|
||||
</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"
|
||||
|
||||
132
frontend/src/features/routing/__tests__/routingPolicy.spec.ts
Normal file
132
frontend/src/features/routing/__tests__/routingPolicy.spec.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
DEFAULT_ROUTING_POLICY_MODEL,
|
||||
createEmptyModelPolicy,
|
||||
createEmptyRoutingGroupConfig,
|
||||
getDefaultModelPolicy,
|
||||
getModelScheduling,
|
||||
modelSchedulingRuleId,
|
||||
normalizeRoutingGroupConfig,
|
||||
setDefaultPoolPriorityOverrides,
|
||||
setDefaultProviderPriorityOverrides,
|
||||
upsertModelSchedulingRule,
|
||||
upsertModelPolicy,
|
||||
} from '../utils/routingPolicy'
|
||||
import { sortCandidateTraces, summarizeRoutingTrace, type RoutingDecisionTrace } from '../utils/routingTrace'
|
||||
|
||||
describe('routingPolicy', () => {
|
||||
it('normalizes partial configs with stable defaults', () => {
|
||||
const config = normalizeRoutingGroupConfig({
|
||||
allowed_models: ['gpt-5'],
|
||||
})
|
||||
|
||||
expect(config.default_policy.priority_mode).toBe('provider')
|
||||
expect(config.default_policy.scheduling_mode).toBe('cache_affinity')
|
||||
expect(config.allowed_models).toEqual(['gpt-5'])
|
||||
})
|
||||
|
||||
it('upserts model policies by model name', () => {
|
||||
const config = createEmptyRoutingGroupConfig()
|
||||
const next = upsertModelPolicy(config, {
|
||||
...createEmptyModelPolicy('gpt-5'),
|
||||
allowed_providers: ['provider-a'],
|
||||
})
|
||||
|
||||
expect(next.model_policies).toHaveLength(1)
|
||||
expect(next.model_policies[0].allowed_providers).toEqual(['provider-a'])
|
||||
})
|
||||
|
||||
it('stores default priority overrides on the wildcard model policy', () => {
|
||||
const config = upsertModelPolicy(createEmptyRoutingGroupConfig(), createEmptyModelPolicy('gpt-5'))
|
||||
const next = setDefaultProviderPriorityOverrides(config, {
|
||||
'provider-a': 0,
|
||||
'provider-b': 2,
|
||||
})
|
||||
|
||||
const policy = getDefaultModelPolicy(next)
|
||||
expect(policy.model).toBe(DEFAULT_ROUTING_POLICY_MODEL)
|
||||
expect(next.model_policies.map(item => item.model)).toEqual([DEFAULT_ROUTING_POLICY_MODEL, 'gpt-5'])
|
||||
expect(policy.provider_priority_overrides).toEqual({
|
||||
'provider-a': 0,
|
||||
'provider-b': 2,
|
||||
})
|
||||
})
|
||||
|
||||
it('stores pool priority overrides separately from key overrides', () => {
|
||||
const next = setDefaultPoolPriorityOverrides(createEmptyRoutingGroupConfig(), {
|
||||
'provider-pool': 3,
|
||||
})
|
||||
|
||||
const policy = getDefaultModelPolicy(next)
|
||||
expect(policy.pool_priority_overrides).toEqual({
|
||||
'provider-pool': 3,
|
||||
})
|
||||
expect(policy.key_priority_overrides).toEqual({})
|
||||
})
|
||||
|
||||
it('stores per-model scheduling as generated routing rules', () => {
|
||||
const next = upsertModelSchedulingRule(createEmptyRoutingGroupConfig(), 'gpt-5', {
|
||||
priority_mode: 'global_key',
|
||||
scheduling_mode: 'fixed_order',
|
||||
})
|
||||
|
||||
expect(next.rules).toHaveLength(1)
|
||||
expect(next.rules[0].id).toBe(modelSchedulingRuleId('gpt-5'))
|
||||
expect(next.rules[0].conditions).toEqual({
|
||||
field: 'model',
|
||||
op: 'eq',
|
||||
value: 'gpt-5',
|
||||
})
|
||||
expect(getModelScheduling(next, 'gpt-5')).toMatchObject({
|
||||
priority_mode: 'global_key',
|
||||
scheduling_mode: 'fixed_order',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('routingTrace', () => {
|
||||
it('sorts candidate traces by selected order', () => {
|
||||
const sorted = sortCandidateTraces([
|
||||
candidate('provider-b', 2),
|
||||
candidate('provider-a', 1),
|
||||
])
|
||||
|
||||
expect(sorted.map(item => item.provider_id)).toEqual(['provider-a', 'provider-b'])
|
||||
})
|
||||
|
||||
it('summarizes trace metadata', () => {
|
||||
const trace: RoutingDecisionTrace = {
|
||||
group_id: 'group-a',
|
||||
group_version: 3,
|
||||
selection_source: 'explicit',
|
||||
selected_rules: ['rule-a'],
|
||||
original_model: 'gpt-5',
|
||||
resolved_model: 'gpt-5',
|
||||
client_api_format: 'openai:chat',
|
||||
global_candidates: [candidate('provider-a', 0)],
|
||||
pool_expansion: [],
|
||||
runtime_facts: {},
|
||||
}
|
||||
|
||||
expect(summarizeRoutingTrace(trace)).toContain('分组: group-a')
|
||||
expect(summarizeRoutingTrace(trace)).toContain('候选: 1')
|
||||
})
|
||||
})
|
||||
|
||||
function candidate(providerId: string, selectedOrder: number) {
|
||||
return {
|
||||
candidate_kind: 'provider' as const,
|
||||
provider_id: providerId,
|
||||
endpoint_id: `${providerId}-endpoint`,
|
||||
model_id: 'model-a',
|
||||
key_id: `${providerId}-key`,
|
||||
selected_order: selectedOrder,
|
||||
ranking_vector: {
|
||||
provider_priority_before: selectedOrder,
|
||||
provider_priority_after: selectedOrder,
|
||||
key_priority_before: selectedOrder,
|
||||
key_priority_after: selectedOrder,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<input
|
||||
v-model="draft.model"
|
||||
class="h-10 rounded-md border border-border bg-background px-3 text-sm"
|
||||
placeholder="模型"
|
||||
>
|
||||
<input
|
||||
v-model="draft.api_format"
|
||||
class="h-10 rounded-md border border-border bg-background px-3 text-sm"
|
||||
placeholder="API 格式"
|
||||
>
|
||||
</div>
|
||||
|
||||
<RoutingTraceViewer
|
||||
v-if="trace"
|
||||
:trace="trace"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive } from 'vue'
|
||||
|
||||
import RoutingTraceViewer from './RoutingTraceViewer.vue'
|
||||
import type { RoutingDecisionTrace } from '../utils/routingTrace'
|
||||
|
||||
const props = defineProps<{
|
||||
trace?: RoutingDecisionTrace | null
|
||||
model?: string
|
||||
apiFormat?: string
|
||||
}>()
|
||||
|
||||
const draft = reactive({
|
||||
model: props.model ?? '',
|
||||
api_format: props.apiFormat ?? 'openai:chat',
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<section class="space-y-4">
|
||||
<div class="grid gap-3">
|
||||
<label class="space-y-1 text-sm">
|
||||
<span class="text-muted-foreground">允许模型</span>
|
||||
<input
|
||||
v-model="allowedModelsText"
|
||||
class="h-10 w-full rounded-md border border-border bg-background px-3 text-sm"
|
||||
placeholder="gpt-5, claude-sonnet-*"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<RoutingModelPolicyEditor
|
||||
:model-policies="config.model_policies"
|
||||
@update:model-policies="updateModelPolicies"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import RoutingModelPolicyEditor from './RoutingModelPolicyEditor.vue'
|
||||
import { normalizeRoutingGroupConfig, type RoutingGroupConfig, type RoutingModelPolicy } from '../utils/routingPolicy'
|
||||
|
||||
const props = defineProps<{
|
||||
config: RoutingGroupConfig
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:config': [value: RoutingGroupConfig]
|
||||
}>()
|
||||
|
||||
const config = computed(() => normalizeRoutingGroupConfig(props.config))
|
||||
|
||||
const allowedModelsText = computed({
|
||||
get: () => config.value.allowed_models.join(', '),
|
||||
set: value => {
|
||||
emit('update:config', {
|
||||
...config.value,
|
||||
allowed_models: value.split(',').map(item => item.trim()).filter(Boolean),
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
function updateModelPolicies(modelPolicies: RoutingModelPolicy[]) {
|
||||
emit('update:config', {
|
||||
...config.value,
|
||||
model_policies: modelPolicies,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="group in groups"
|
||||
:key="group.id"
|
||||
class="rounded-lg border border-border/60 bg-background px-4 py-3"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-medium text-foreground">
|
||||
{{ group.name }}
|
||||
</p>
|
||||
<p class="truncate text-xs text-muted-foreground">
|
||||
{{ group.description || '未填写描述' }}
|
||||
</p>
|
||||
</div>
|
||||
<span class="shrink-0 rounded-md border px-2 py-1 text-xs text-muted-foreground">
|
||||
v{{ group.version }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
export interface RoutingGroupListItem {
|
||||
id: string
|
||||
name: string
|
||||
description?: string | null
|
||||
enabled: boolean
|
||||
version: number
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
groups: RoutingGroupListItem[]
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h3 class="text-sm font-medium">
|
||||
模型策略
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-border px-3 py-1.5 text-xs"
|
||||
@click="addPolicy"
|
||||
>
|
||||
添加
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="(policy, index) in draftPolicies"
|
||||
:key="`${policy.model}-${index}`"
|
||||
class="grid gap-3 rounded-lg border border-border/60 p-3 sm:grid-cols-[1fr_1fr_auto]"
|
||||
>
|
||||
<input
|
||||
v-model="policy.model"
|
||||
class="h-9 rounded-md border border-border bg-background px-3 text-sm"
|
||||
placeholder="模型"
|
||||
@change="commit"
|
||||
>
|
||||
<input
|
||||
:value="policy.allowed_providers.join(', ')"
|
||||
class="h-9 rounded-md border border-border bg-background px-3 text-sm"
|
||||
placeholder="允许 Provider"
|
||||
@change="event => updateProviders(index, event)"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-border px-3 text-xs text-muted-foreground"
|
||||
@click="removePolicy(index)"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import { createEmptyModelPolicy, type RoutingModelPolicy } from '../utils/routingPolicy'
|
||||
|
||||
const props = defineProps<{
|
||||
modelPolicies: RoutingModelPolicy[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:model-policies': [value: RoutingModelPolicy[]]
|
||||
}>()
|
||||
|
||||
const draftPolicies = ref<RoutingModelPolicy[]>(props.modelPolicies.map(policy => ({ ...policy })))
|
||||
|
||||
watch(() => props.modelPolicies, value => {
|
||||
draftPolicies.value = value.map(policy => ({ ...policy }))
|
||||
})
|
||||
|
||||
function addPolicy() {
|
||||
draftPolicies.value.push(createEmptyModelPolicy())
|
||||
commit()
|
||||
}
|
||||
|
||||
function removePolicy(index: number) {
|
||||
draftPolicies.value.splice(index, 1)
|
||||
commit()
|
||||
}
|
||||
|
||||
function updateProviders(index: number, event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
draftPolicies.value[index].allowed_providers = target.value.split(',').map(item => item.trim()).filter(Boolean)
|
||||
commit()
|
||||
}
|
||||
|
||||
function commit() {
|
||||
emit('update:model-policies', draftPolicies.value.map(policy => ({ ...policy })))
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,884 @@
|
||||
<template>
|
||||
<section class="space-y-4">
|
||||
<div
|
||||
v-if="showPriorityMode || showSchedulingMode"
|
||||
class="grid gap-3"
|
||||
:class="showPriorityMode ? 'lg:grid-cols-[1fr_1.4fr]' : ''"
|
||||
>
|
||||
<div
|
||||
v-if="showPriorityMode"
|
||||
class="space-y-1 text-sm"
|
||||
>
|
||||
<span class="text-muted-foreground">优先级模式</span>
|
||||
<div class="grid grid-cols-2 gap-1 rounded-lg bg-muted/40 p-1">
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-9 items-center justify-center gap-2 rounded-md px-3 text-sm font-medium transition-colors"
|
||||
:class="effectivePriorityMode === 'provider'
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:bg-background/60 hover:text-foreground'"
|
||||
@click="updatePriorityMode('provider')"
|
||||
>
|
||||
<Layers class="h-4 w-4" />
|
||||
Provider
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-9 items-center justify-center gap-2 rounded-md px-3 text-sm font-medium transition-colors"
|
||||
:class="effectivePriorityMode === 'global_key'
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:bg-background/60 hover:text-foreground'"
|
||||
@click="updatePriorityMode('global_key')"
|
||||
>
|
||||
<Key class="h-4 w-4" />
|
||||
Key
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="showSchedulingMode"
|
||||
class="space-y-1 text-sm"
|
||||
>
|
||||
<span class="text-muted-foreground">调度策略</span>
|
||||
<div class="grid grid-cols-3 gap-1 rounded-lg bg-muted/40 p-1">
|
||||
<button
|
||||
v-for="mode in schedulingModes"
|
||||
:key="mode.value"
|
||||
type="button"
|
||||
class="h-9 rounded-md px-3 text-sm font-medium transition-colors"
|
||||
:class="effectiveSchedulingMode === mode.value
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:bg-background/60 hover:text-foreground'"
|
||||
@click="updateSchedulingMode(mode.value)"
|
||||
>
|
||||
{{ mode.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-border/60">
|
||||
<div class="flex flex-col gap-3 border-b border-border/60 px-4 py-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h3 class="text-sm font-medium">
|
||||
{{ effectivePriorityMode === 'provider' ? '提供商排序' : 'Key 排序' }}
|
||||
</h3>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
v-if="effectivePriorityMode === 'global_key'"
|
||||
v-model="selectedApiFormat"
|
||||
class="h-9 min-w-[180px] rounded-md border border-border bg-background px-3 text-sm"
|
||||
>
|
||||
<option
|
||||
v-for="format in apiFormats"
|
||||
:key="format"
|
||||
:value="format"
|
||||
>
|
||||
{{ formatLabel(format) }}
|
||||
</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-9 items-center gap-2 rounded-md border border-border px-3 text-xs"
|
||||
@click="refresh"
|
||||
>
|
||||
<RefreshCw
|
||||
class="h-3.5 w-3.5"
|
||||
:class="{ 'animate-spin': loading }"
|
||||
/>
|
||||
刷新
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="h-9 rounded-md border border-border px-3 text-xs text-muted-foreground"
|
||||
@click="clearActiveOverrides"
|
||||
>
|
||||
清空排序
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-h-[180px] max-h-[420px] overflow-y-auto p-3">
|
||||
<div
|
||||
v-if="loading"
|
||||
class="py-10 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
正在加载
|
||||
</div>
|
||||
<div
|
||||
v-else-if="loadError"
|
||||
class="rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive"
|
||||
>
|
||||
{{ loadError }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="effectivePriorityMode === 'provider'"
|
||||
class="space-y-2"
|
||||
>
|
||||
<div
|
||||
v-if="providerRows.length === 0"
|
||||
class="rounded-lg border border-dashed border-border/70 px-4 py-8 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
暂无 Provider
|
||||
</div>
|
||||
<div
|
||||
v-for="(row, index) in providerRows"
|
||||
v-else
|
||||
:key="row.id"
|
||||
class="group grid items-center gap-3 rounded-lg border px-3 py-2 transition-colors sm:grid-cols-[auto_auto_76px_minmax(0,1fr)_auto]"
|
||||
:class="draggedProviderId === row.id
|
||||
? 'border-primary/50 bg-primary/5 shadow-sm'
|
||||
: dragOverProviderId === row.id
|
||||
? 'border-primary/30 bg-primary/5'
|
||||
: 'border-border/50 bg-background hover:bg-muted/30'"
|
||||
draggable="true"
|
||||
@dragstart="handleProviderDragStart(row.id, $event)"
|
||||
@dragend="handleProviderDragEnd"
|
||||
@dragover.prevent="handleProviderDragOver(row.id)"
|
||||
@dragleave="handleProviderDragLeave"
|
||||
@drop="handleProviderDrop(row.id)"
|
||||
>
|
||||
<div class="cursor-grab rounded p-1 text-muted-foreground/40 transition-colors group-hover:text-muted-foreground active:cursor-grabbing">
|
||||
<GripVertical class="h-4 w-4" />
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-30"
|
||||
:disabled="index === 0"
|
||||
@click="moveProvider(row.id, -1)"
|
||||
>
|
||||
<ArrowUp class="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-30"
|
||||
:disabled="index === providerRows.length - 1"
|
||||
@click="moveProvider(row.id, 1)"
|
||||
>
|
||||
<ArrowDown class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
:value="row.priority"
|
||||
type="number"
|
||||
min="0"
|
||||
class="h-8 w-full rounded-md border border-border bg-background px-2 text-sm"
|
||||
@change="event => setProviderPriority(row.id, event)"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="truncate text-sm font-medium">{{ row.name }}</span>
|
||||
<span
|
||||
v-if="row.kind === 'pool'"
|
||||
class="rounded bg-primary/10 px-1.5 py-0.5 text-[10px] text-primary"
|
||||
>
|
||||
Pool
|
||||
</span>
|
||||
<span
|
||||
v-if="!row.is_active"
|
||||
class="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground"
|
||||
>
|
||||
停用
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-0.5 truncate text-xs text-muted-foreground">
|
||||
{{ row.id }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="hidden max-w-[240px] flex-wrap justify-end gap-1 sm:flex">
|
||||
<span
|
||||
v-for="format in row.api_formats.slice(0, 3)"
|
||||
:key="format"
|
||||
class="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground"
|
||||
>
|
||||
{{ format }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="space-y-2"
|
||||
>
|
||||
<div
|
||||
v-if="keyRows.length === 0"
|
||||
class="rounded-lg border border-dashed border-border/70 px-4 py-8 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
暂无 Key
|
||||
</div>
|
||||
<div
|
||||
v-for="(row, index) in keyRows"
|
||||
v-else
|
||||
:key="row.id"
|
||||
class="group grid items-center gap-3 rounded-lg border px-3 py-2 transition-colors sm:grid-cols-[auto_auto_76px_minmax(0,1fr)_auto]"
|
||||
:class="draggedKeyId === row.id
|
||||
? 'border-primary/50 bg-primary/5 shadow-sm'
|
||||
: dragOverKeyId === row.id
|
||||
? 'border-primary/30 bg-primary/5'
|
||||
: 'border-border/50 bg-background hover:bg-muted/30'"
|
||||
draggable="true"
|
||||
@dragstart="handleKeyDragStart(row.id, $event)"
|
||||
@dragend="handleKeyDragEnd"
|
||||
@dragover.prevent="handleKeyDragOver(row.id)"
|
||||
@dragleave="handleKeyDragLeave"
|
||||
@drop="handleKeyDrop(row.id)"
|
||||
>
|
||||
<div class="cursor-grab rounded p-1 text-muted-foreground/40 transition-colors group-hover:text-muted-foreground active:cursor-grabbing">
|
||||
<GripVertical class="h-4 w-4" />
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-30"
|
||||
:disabled="index === 0"
|
||||
@click="moveKey(row.id, -1)"
|
||||
>
|
||||
<ArrowUp class="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-30"
|
||||
:disabled="index === keyRows.length - 1"
|
||||
@click="moveKey(row.id, 1)"
|
||||
>
|
||||
<ArrowDown class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
:value="row.priority"
|
||||
type="number"
|
||||
min="0"
|
||||
class="h-8 w-full rounded-md border border-border bg-background px-2 text-sm"
|
||||
@change="event => setKeyPriority(row.id, event)"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="truncate text-sm font-medium">{{ row.name }}</span>
|
||||
<span
|
||||
v-if="!row.is_active"
|
||||
class="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground"
|
||||
>
|
||||
停用
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-0.5 truncate font-mono text-xs text-muted-foreground">
|
||||
{{ row.masked }} · {{ row.provider_name }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="hidden max-w-[240px] flex-wrap justify-end gap-1 sm:flex">
|
||||
<span
|
||||
v-for="format in row.api_formats.slice(0, 3)"
|
||||
:key="format"
|
||||
class="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground"
|
||||
>
|
||||
{{ format }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { ArrowDown, ArrowUp, GripVertical, Key, Layers, RefreshCw } from 'lucide-vue-next'
|
||||
|
||||
import client from '@/api/client'
|
||||
import {
|
||||
getProvidersSummary,
|
||||
type ProviderWithEndpointsSummary,
|
||||
} from '@/api/endpoints'
|
||||
import { formatApiFormat, normalizeApiFormatAlias, sortApiFormats } from '@/api/endpoints/types/api-format'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import {
|
||||
DEFAULT_ROUTING_POLICY_MODEL,
|
||||
getDefaultModelPolicy,
|
||||
getModelPolicy,
|
||||
normalizeRoutingGroupConfig,
|
||||
setModelKeyPriorityOverrides,
|
||||
setModelPoolPriorityOverrides,
|
||||
setModelProviderPriorityOverrides,
|
||||
type RoutingDefaultPolicy,
|
||||
type RoutingGroupConfig,
|
||||
type RoutingPriorityMode,
|
||||
type RoutingSchedulingMode,
|
||||
} from '../utils/routingPolicy'
|
||||
|
||||
interface ProviderPriorityRow {
|
||||
id: string
|
||||
name: string
|
||||
is_active: boolean
|
||||
api_formats: string[]
|
||||
priority: number
|
||||
}
|
||||
|
||||
interface KeyPriorityRow {
|
||||
id: string
|
||||
kind: 'key' | 'pool'
|
||||
target_id: string
|
||||
name: string
|
||||
masked: string
|
||||
is_active: boolean
|
||||
api_formats: string[]
|
||||
priority: number
|
||||
provider_id: string
|
||||
provider_name: string
|
||||
pool_key_count?: number
|
||||
pool_active_key_count?: number
|
||||
}
|
||||
|
||||
interface GlobalKeySource {
|
||||
id: string
|
||||
provider_id: string
|
||||
provider_name: string
|
||||
name: string
|
||||
api_key_masked: string
|
||||
internal_priority: number
|
||||
global_priority_by_format: Record<string, number> | null
|
||||
is_active: boolean
|
||||
provider_active: boolean
|
||||
api_formats: string[]
|
||||
api_format: string
|
||||
health_score: number | null
|
||||
request_count: number
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
config: RoutingGroupConfig
|
||||
model?: string
|
||||
priorityMode?: RoutingPriorityMode
|
||||
schedulingMode?: RoutingSchedulingMode
|
||||
showPriorityMode?: boolean
|
||||
showSchedulingMode?: boolean
|
||||
subtitle?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:config': [value: RoutingGroupConfig]
|
||||
'update:priority-mode': [value: RoutingPriorityMode]
|
||||
'update:scheduling-mode': [value: RoutingSchedulingMode]
|
||||
}>()
|
||||
|
||||
const schedulingModes: Array<{ value: RoutingDefaultPolicy['scheduling_mode']; label: string }> = [
|
||||
{ value: 'cache_affinity', label: '缓存亲和' },
|
||||
{ value: 'load_balance', label: '负载均衡' },
|
||||
{ value: 'fixed_order', label: '固定顺序' },
|
||||
]
|
||||
|
||||
const providers = ref<ProviderWithEndpointsSummary[]>([])
|
||||
const keysByFormat = ref<Record<string, GlobalKeySource[]>>({})
|
||||
const selectedApiFormat = ref('')
|
||||
const loadingProviders = ref(false)
|
||||
const loadingKeys = ref(false)
|
||||
const loadError = ref<string | null>(null)
|
||||
const draggedProviderId = ref<string | null>(null)
|
||||
const dragOverProviderId = ref<string | null>(null)
|
||||
const draggedKeyId = ref<string | null>(null)
|
||||
const dragOverKeyId = ref<string | null>(null)
|
||||
|
||||
const config = computed(() => normalizeRoutingGroupConfig(props.config))
|
||||
const targetModel = computed(() => props.model?.trim() || DEFAULT_ROUTING_POLICY_MODEL)
|
||||
const targetModelPolicy = computed(() => targetModel.value === DEFAULT_ROUTING_POLICY_MODEL
|
||||
? getDefaultModelPolicy(config.value)
|
||||
: getModelPolicy(config.value, targetModel.value))
|
||||
const showPriorityMode = computed(() => props.showPriorityMode !== false)
|
||||
const showSchedulingMode = computed(() => props.showSchedulingMode !== false)
|
||||
const effectivePriorityMode = computed(() => props.priorityMode ?? config.value.default_policy.priority_mode)
|
||||
const effectiveSchedulingMode = computed(() => props.schedulingMode ?? config.value.default_policy.scheduling_mode)
|
||||
const subtitle = computed(() => props.subtitle ?? '默认作用于全部模型')
|
||||
const loading = computed(() => loadingProviders.value || loadingKeys.value)
|
||||
const apiFormats = computed(() => sortApiFormats(Object.keys(keysByFormat.value)))
|
||||
const providerById = computed(() => {
|
||||
const map = new Map<string, ProviderWithEndpointsSummary>()
|
||||
for (const provider of providers.value) {
|
||||
map.set(provider.id, provider)
|
||||
}
|
||||
return map
|
||||
})
|
||||
const providerIdByName = computed(() => {
|
||||
const map = new Map<string, string>()
|
||||
for (const provider of providers.value) {
|
||||
if (!map.has(provider.name)) {
|
||||
map.set(provider.name, provider.id)
|
||||
}
|
||||
}
|
||||
return map
|
||||
})
|
||||
const poolProviderIds = computed(() => {
|
||||
const set = new Set<string>()
|
||||
for (const provider of providers.value) {
|
||||
if (provider.pool_advanced) {
|
||||
set.add(provider.id)
|
||||
}
|
||||
}
|
||||
return set
|
||||
})
|
||||
|
||||
const providerRows = computed<ProviderPriorityRow[]>(() => {
|
||||
const overrides = targetModelPolicy.value.provider_priority_overrides
|
||||
return providers.value
|
||||
.map(provider => ({
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
is_active: provider.is_active,
|
||||
api_formats: provider.api_formats ?? [],
|
||||
priority: priorityValue(overrides[provider.id], provider.provider_priority),
|
||||
}))
|
||||
.sort(comparePriorityRows)
|
||||
})
|
||||
|
||||
const keyRows = computed<KeyPriorityRow[]>(() => {
|
||||
const format = selectedApiFormat.value
|
||||
const keyOverrides = targetModelPolicy.value.key_priority_overrides
|
||||
const poolOverrides = targetModelPolicy.value.pool_priority_overrides
|
||||
const normalRows: KeyPriorityRow[] = []
|
||||
const poolGroups = new Map<string, GlobalKeySource[]>()
|
||||
|
||||
for (const key of keysByFormat.value[format] ?? []) {
|
||||
const providerId = resolveProviderId(key)
|
||||
if (isPoolManagedProvider(providerId)) {
|
||||
if (!poolGroups.has(providerId)) {
|
||||
poolGroups.set(providerId, [])
|
||||
}
|
||||
poolGroups.get(providerId)?.push(key)
|
||||
continue
|
||||
}
|
||||
normalRows.push({
|
||||
id: key.id,
|
||||
kind: 'key',
|
||||
target_id: key.id,
|
||||
name: key.name,
|
||||
masked: key.api_key_masked,
|
||||
is_active: key.is_active && key.provider_active,
|
||||
api_formats: key.api_formats,
|
||||
priority: priorityValue(keyOverrides[key.id], fallbackKeyPriority(key, format)),
|
||||
provider_id: providerId,
|
||||
provider_name: key.provider_name,
|
||||
})
|
||||
}
|
||||
|
||||
const poolRows = Array.from(poolGroups.entries()).map(([providerId, keys]) =>
|
||||
buildPoolRow(format, providerId, keys, poolOverrides)
|
||||
)
|
||||
|
||||
return [...normalRows, ...poolRows].sort(comparePriorityRows)
|
||||
})
|
||||
|
||||
watch(effectivePriorityMode, mode => {
|
||||
if (mode === 'global_key') {
|
||||
void loadGlobalKeys()
|
||||
}
|
||||
})
|
||||
|
||||
watch(apiFormats, formats => {
|
||||
if (!formats.includes(selectedApiFormat.value)) {
|
||||
selectedApiFormat.value = formats[0] ?? ''
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
void (async () => {
|
||||
await loadProviders()
|
||||
if (effectivePriorityMode.value === 'global_key') {
|
||||
await loadGlobalKeys()
|
||||
}
|
||||
})()
|
||||
})
|
||||
|
||||
function updateConfig(value: RoutingGroupConfig): void {
|
||||
emit('update:config', normalizeRoutingGroupConfig(value))
|
||||
}
|
||||
|
||||
function updateDefaultPolicy(patch: Partial<RoutingDefaultPolicy>): void {
|
||||
updateConfig({
|
||||
...config.value,
|
||||
default_policy: {
|
||||
...config.value.default_policy,
|
||||
...patch,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function updatePriorityMode(mode: RoutingPriorityMode): void {
|
||||
if (props.priorityMode != null) {
|
||||
emit('update:priority-mode', mode)
|
||||
return
|
||||
}
|
||||
updateDefaultPolicy({ priority_mode: mode })
|
||||
}
|
||||
|
||||
function updateSchedulingMode(mode: RoutingSchedulingMode): void {
|
||||
if (props.schedulingMode != null) {
|
||||
emit('update:scheduling-mode', mode)
|
||||
return
|
||||
}
|
||||
updateDefaultPolicy({ scheduling_mode: mode })
|
||||
}
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
if (effectivePriorityMode.value === 'provider') {
|
||||
await loadProviders()
|
||||
} else {
|
||||
await loadProviders()
|
||||
await loadGlobalKeys(true)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProviders(): Promise<void> {
|
||||
loadingProviders.value = true
|
||||
loadError.value = null
|
||||
try {
|
||||
const response = await getProvidersSummary({ page: 1, page_size: 9999 })
|
||||
providers.value = response.items
|
||||
} catch (err) {
|
||||
loadError.value = parseApiError(err, '加载 Provider 失败')
|
||||
providers.value = []
|
||||
} finally {
|
||||
loadingProviders.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadGlobalKeys(force = false): Promise<void> {
|
||||
if (!force && Object.keys(keysByFormat.value).length > 0) return
|
||||
loadingKeys.value = true
|
||||
loadError.value = null
|
||||
try {
|
||||
const response = await client.get<Record<string, Record<string, unknown>[]>>(
|
||||
'/api/admin/endpoints/keys/grouped-by-format',
|
||||
)
|
||||
const next: Record<string, GlobalKeySource[]> = {}
|
||||
for (const [rawFormat, rawKeys] of Object.entries(response.data ?? {})) {
|
||||
const format = normalizeFormat(rawFormat)
|
||||
if (!format) continue
|
||||
next[format] = normalizeGlobalKeys(format, rawKeys)
|
||||
}
|
||||
keysByFormat.value = next
|
||||
if (!selectedApiFormat.value || !Object.keys(next).includes(selectedApiFormat.value)) {
|
||||
selectedApiFormat.value = sortApiFormats(Object.keys(next))[0] ?? ''
|
||||
}
|
||||
} catch (err) {
|
||||
loadError.value = parseApiError(err, '加载全局 Key 失败')
|
||||
} finally {
|
||||
loadingKeys.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function setProviderPriority(providerId: string, event: Event): void {
|
||||
const priority = readPriorityInput(event)
|
||||
if (priority == null) return
|
||||
updateProviderOverrides({
|
||||
...targetModelPolicy.value.provider_priority_overrides,
|
||||
[providerId]: priority,
|
||||
})
|
||||
}
|
||||
|
||||
function moveProvider(providerId: string, direction: -1 | 1): void {
|
||||
const rows = moveRow(providerRows.value, providerId, direction)
|
||||
updateProviderOverrides(Object.fromEntries(rows.map((row, index) => [row.id, index])))
|
||||
}
|
||||
|
||||
function updateProviderOverrides(overrides: Record<string, number>): void {
|
||||
updateConfig(setModelProviderPriorityOverrides(config.value, targetModel.value, overrides))
|
||||
}
|
||||
|
||||
function setKeyPriority(keyId: string, event: Event): void {
|
||||
const priority = readPriorityInput(event)
|
||||
if (priority == null) return
|
||||
const row = keyRows.value.find(item => item.id === keyId)
|
||||
if (!row) return
|
||||
if (row.kind === 'pool') {
|
||||
updatePoolOverrides({
|
||||
...targetModelPolicy.value.pool_priority_overrides,
|
||||
[row.target_id]: priority,
|
||||
})
|
||||
} else {
|
||||
updateKeyOverrides({
|
||||
...targetModelPolicy.value.key_priority_overrides,
|
||||
[row.target_id]: priority,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function moveKey(keyId: string, direction: -1 | 1): void {
|
||||
const rows = moveRow(keyRows.value, keyId, direction)
|
||||
updateVisibleKeyAndPoolOverrides(rows)
|
||||
}
|
||||
|
||||
function updateKeyOverrides(overrides: Record<string, number>): void {
|
||||
updateConfig(setModelKeyPriorityOverrides(config.value, targetModel.value, overrides))
|
||||
}
|
||||
|
||||
function updatePoolOverrides(overrides: Record<string, number>): void {
|
||||
updateConfig(setModelPoolPriorityOverrides(config.value, targetModel.value, overrides))
|
||||
}
|
||||
|
||||
function updateKeyAndPoolOverrides(
|
||||
keyOverrides: Record<string, number>,
|
||||
poolOverrides: Record<string, number>,
|
||||
): void {
|
||||
const next = setModelPoolPriorityOverrides(
|
||||
setModelKeyPriorityOverrides(config.value, targetModel.value, keyOverrides),
|
||||
targetModel.value,
|
||||
poolOverrides,
|
||||
)
|
||||
updateConfig(next)
|
||||
}
|
||||
|
||||
function updateVisibleKeyAndPoolOverrides(rows: KeyPriorityRow[]): void {
|
||||
const keyOverrides = { ...targetModelPolicy.value.key_priority_overrides }
|
||||
const poolOverrides = { ...targetModelPolicy.value.pool_priority_overrides }
|
||||
|
||||
for (const row of keyRows.value) {
|
||||
if (row.kind === 'pool') {
|
||||
delete poolOverrides[row.target_id]
|
||||
} else {
|
||||
delete keyOverrides[row.target_id]
|
||||
}
|
||||
}
|
||||
|
||||
rows.forEach((row, index) => {
|
||||
if (row.kind === 'pool') {
|
||||
poolOverrides[row.target_id] = index
|
||||
} else {
|
||||
keyOverrides[row.target_id] = index
|
||||
}
|
||||
})
|
||||
|
||||
updateKeyAndPoolOverrides(keyOverrides, poolOverrides)
|
||||
}
|
||||
|
||||
function handleProviderDragStart(providerId: string, event: DragEvent): void {
|
||||
draggedProviderId.value = providerId
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
event.dataTransfer.setData('text/plain', providerId)
|
||||
}
|
||||
}
|
||||
|
||||
function handleProviderDragEnd(): void {
|
||||
draggedProviderId.value = null
|
||||
dragOverProviderId.value = null
|
||||
}
|
||||
|
||||
function handleProviderDragOver(providerId: string): void {
|
||||
dragOverProviderId.value = providerId
|
||||
}
|
||||
|
||||
function handleProviderDragLeave(): void {
|
||||
dragOverProviderId.value = null
|
||||
}
|
||||
|
||||
function handleProviderDrop(providerId: string): void {
|
||||
const draggedId = draggedProviderId.value
|
||||
if (!draggedId || draggedId === providerId) {
|
||||
handleProviderDragEnd()
|
||||
return
|
||||
}
|
||||
const rows = reorderRows(providerRows.value, draggedId, providerId)
|
||||
updateProviderOverrides(Object.fromEntries(rows.map((row, index) => [row.id, index])))
|
||||
handleProviderDragEnd()
|
||||
}
|
||||
|
||||
function handleKeyDragStart(keyId: string, event: DragEvent): void {
|
||||
draggedKeyId.value = keyId
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
event.dataTransfer.setData('text/plain', keyId)
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDragEnd(): void {
|
||||
draggedKeyId.value = null
|
||||
dragOverKeyId.value = null
|
||||
}
|
||||
|
||||
function handleKeyDragOver(keyId: string): void {
|
||||
dragOverKeyId.value = keyId
|
||||
}
|
||||
|
||||
function handleKeyDragLeave(): void {
|
||||
dragOverKeyId.value = null
|
||||
}
|
||||
|
||||
function handleKeyDrop(keyId: string): void {
|
||||
const draggedId = draggedKeyId.value
|
||||
if (!draggedId || draggedId === keyId) {
|
||||
handleKeyDragEnd()
|
||||
return
|
||||
}
|
||||
const rows = reorderRows(keyRows.value, draggedId, keyId)
|
||||
updateVisibleKeyAndPoolOverrides(rows)
|
||||
handleKeyDragEnd()
|
||||
}
|
||||
|
||||
function clearActiveOverrides(): void {
|
||||
if (effectivePriorityMode.value === 'provider') {
|
||||
updateProviderOverrides({})
|
||||
} else {
|
||||
updateVisibleKeyAndPoolOverrides([])
|
||||
}
|
||||
}
|
||||
|
||||
function moveRow<T extends { id: string }>(rows: T[], id: string, direction: -1 | 1): T[] {
|
||||
const next = [...rows]
|
||||
const index = next.findIndex(row => row.id === id)
|
||||
const targetIndex = index + direction
|
||||
if (index < 0 || targetIndex < 0 || targetIndex >= next.length) {
|
||||
return next
|
||||
}
|
||||
const [item] = next.splice(index, 1)
|
||||
next.splice(targetIndex, 0, item)
|
||||
return next
|
||||
}
|
||||
|
||||
function reorderRows<T extends { id: string }>(rows: T[], draggedId: string, targetId: string): T[] {
|
||||
const next = [...rows]
|
||||
const fromIndex = next.findIndex(row => row.id === draggedId)
|
||||
const toIndex = next.findIndex(row => row.id === targetId)
|
||||
if (fromIndex < 0 || toIndex < 0) return next
|
||||
const [item] = next.splice(fromIndex, 1)
|
||||
next.splice(toIndex, 0, item)
|
||||
return next
|
||||
}
|
||||
|
||||
function readPriorityInput(event: Event): number | null {
|
||||
const value = Number((event.target as HTMLInputElement).value)
|
||||
if (!Number.isFinite(value) || value < 0) {
|
||||
return null
|
||||
}
|
||||
return Math.trunc(value)
|
||||
}
|
||||
|
||||
function priorityValue(override: number | undefined, fallback: number | null | undefined): number {
|
||||
if (typeof override === 'number' && Number.isFinite(override)) return override
|
||||
if (typeof fallback === 'number' && Number.isFinite(fallback)) return fallback
|
||||
return 0
|
||||
}
|
||||
|
||||
function fallbackKeyPriority(key: GlobalKeySource, format: string): number {
|
||||
const normalizedFormat = normalizeFormat(format)
|
||||
if (normalizedFormat && typeof key.global_priority_by_format?.[normalizedFormat] === 'number') {
|
||||
return key.global_priority_by_format[normalizedFormat]
|
||||
}
|
||||
return key.internal_priority
|
||||
}
|
||||
|
||||
function normalizeGlobalKeys(format: string, rawKeys: Record<string, unknown>[]): GlobalKeySource[] {
|
||||
const deduped = new Map<string, GlobalKeySource>()
|
||||
for (const raw of rawKeys) {
|
||||
const id = String(raw.id || '').trim()
|
||||
if (!id) continue
|
||||
const providerName = String(raw.provider_name || '')
|
||||
const providerId = String(raw.provider_id || '') || providerIdByName.value.get(providerName) || ''
|
||||
const priorityMap = normalizePriorityMap(raw.global_priority_by_format as Record<string, unknown> | null | undefined)
|
||||
const source: GlobalKeySource = {
|
||||
id,
|
||||
provider_id: providerId,
|
||||
provider_name: providerName || providerById.value.get(providerId)?.name || 'Unknown Provider',
|
||||
name: String(raw.name || 'Unnamed Key'),
|
||||
api_key_masked: String(raw.api_key_masked || '***'),
|
||||
internal_priority: toNumberOrNull(raw.internal_priority) ?? 0,
|
||||
global_priority_by_format: Object.keys(priorityMap).length > 0 ? priorityMap : null,
|
||||
is_active: raw.is_active !== false,
|
||||
provider_active: raw.provider_active !== false,
|
||||
api_formats: Array.isArray(raw.api_formats) ? raw.api_formats.map(item => normalizeFormat(String(item))).filter(Boolean) : [format],
|
||||
api_format: format,
|
||||
health_score: toNumberOrNull(raw.health_score),
|
||||
request_count: toNumberOrNull(raw.request_count) ?? 0,
|
||||
}
|
||||
const existing = deduped.get(id)
|
||||
if (!existing) {
|
||||
deduped.set(id, source)
|
||||
continue
|
||||
}
|
||||
deduped.set(id, {
|
||||
...existing,
|
||||
...source,
|
||||
global_priority_by_format: {
|
||||
...(existing.global_priority_by_format ?? {}),
|
||||
...(source.global_priority_by_format ?? {}),
|
||||
},
|
||||
api_formats: Array.from(new Set([...existing.api_formats, ...source.api_formats])),
|
||||
})
|
||||
}
|
||||
return Array.from(deduped.values())
|
||||
}
|
||||
|
||||
function buildPoolRow(
|
||||
format: string,
|
||||
providerId: string,
|
||||
keys: GlobalKeySource[],
|
||||
overrides: Record<string, number>,
|
||||
): KeyPriorityRow {
|
||||
const provider = providerById.value.get(providerId)
|
||||
const activeKeyCount = keys.filter(key => key.is_active).length
|
||||
return {
|
||||
id: `pool:${providerId}:${format}`,
|
||||
kind: 'pool',
|
||||
target_id: providerId,
|
||||
name: provider?.name || keys[0]?.provider_name || '未知 Provider',
|
||||
masked: '[Pool]',
|
||||
is_active: (provider?.is_active ?? keys.some(key => key.provider_active)) && activeKeyCount > 0,
|
||||
api_formats: [format],
|
||||
priority: priorityValue(
|
||||
overrides[providerId],
|
||||
provider?.pool_advanced?.global_priority ?? provider?.provider_priority ?? 999999,
|
||||
),
|
||||
provider_id: providerId,
|
||||
provider_name: provider?.name || keys[0]?.provider_name || 'Unknown Provider',
|
||||
pool_key_count: keys.length,
|
||||
pool_active_key_count: activeKeyCount,
|
||||
}
|
||||
}
|
||||
|
||||
function resolveProviderId(key: Pick<GlobalKeySource, 'provider_id' | 'provider_name'>): string {
|
||||
if (key.provider_id) return key.provider_id
|
||||
return providerIdByName.value.get(key.provider_name) || ''
|
||||
}
|
||||
|
||||
function isPoolManagedProvider(providerId: string): boolean {
|
||||
return providerId !== '' && poolProviderIds.value.has(providerId)
|
||||
}
|
||||
|
||||
function normalizeFormat(value: string | null | undefined): string {
|
||||
return normalizeApiFormatAlias(value).trim()
|
||||
}
|
||||
|
||||
function formatLabel(format: string): string {
|
||||
return formatApiFormat(format)
|
||||
}
|
||||
|
||||
function normalizePriorityMap(value: Record<string, unknown> | null | undefined): Record<string, number> {
|
||||
if (!value) return {}
|
||||
const normalized: Record<string, number> = {}
|
||||
for (const [rawFormat, rawPriority] of Object.entries(value)) {
|
||||
const format = normalizeFormat(rawFormat)
|
||||
const priority = toNumberOrNull(rawPriority)
|
||||
if (!format || priority == null) continue
|
||||
normalized[format] = priority
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
function toNumberOrNull(value: unknown): number | null {
|
||||
const numberValue = Number(value)
|
||||
return Number.isFinite(numberValue) ? Math.trunc(numberValue) : null
|
||||
}
|
||||
|
||||
function comparePriorityRows(left: ProviderPriorityRow | KeyPriorityRow, right: ProviderPriorityRow | KeyPriorityRow): number {
|
||||
return left.priority - right.priority
|
||||
|| Number(right.is_active) - Number(left.is_active)
|
||||
|| left.name.localeCompare(right.name)
|
||||
|| left.id.localeCompare(right.id)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="rule in rules"
|
||||
:key="rule.id"
|
||||
class="rounded-lg border border-border/60 px-3 py-2"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<p class="truncate text-sm font-medium">
|
||||
{{ rule.id }}
|
||||
</p>
|
||||
<span class="rounded-md bg-muted px-2 py-1 text-xs text-muted-foreground">
|
||||
P{{ rule.priority }} / {{ rule.phase }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
{{ summarizeRule(rule) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { summarizeRoutingCondition } from '../utils/routingConditions'
|
||||
import type { RoutingRule } from '../utils/routingPolicy'
|
||||
|
||||
defineProps<{
|
||||
rules: RoutingRule[]
|
||||
}>()
|
||||
|
||||
function summarizeRule(rule: RoutingRule): string {
|
||||
return summarizeRoutingCondition(rule.conditions as never)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<section class="space-y-4">
|
||||
<div class="rounded-lg border border-border/60 p-3">
|
||||
<p
|
||||
v-for="line in summary"
|
||||
:key="line"
|
||||
class="text-sm text-muted-foreground"
|
||||
>
|
||||
{{ line }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="candidate in candidates"
|
||||
:key="`${candidate.provider_id}-${candidate.endpoint_id}-${candidate.key_id ?? 'pool'}`"
|
||||
class="rounded-lg border border-border/60 px-3 py-2"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<p class="truncate text-sm font-medium">
|
||||
{{ candidateTraceLabel(candidate) }}
|
||||
</p>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ candidate.skip_reason || `#${candidate.selected_order ?? '-'}` }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { candidateTraceLabel, sortCandidateTraces, summarizeRoutingTrace, type RoutingDecisionTrace } from '../utils/routingTrace'
|
||||
|
||||
const props = defineProps<{
|
||||
trace: RoutingDecisionTrace
|
||||
}>()
|
||||
|
||||
const summary = computed(() => summarizeRoutingTrace(props.trace))
|
||||
const candidates = computed(() => sortCandidateTraces(props.trace.global_candidates))
|
||||
</script>
|
||||
7
frontend/src/features/routing/components/index.ts
Normal file
7
frontend/src/features/routing/components/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { default as RoutingDryRunDialog } from './RoutingDryRunDialog.vue'
|
||||
export { default as RoutingGroupEditor } from './RoutingGroupEditor.vue'
|
||||
export { default as RoutingGroupList } from './RoutingGroupList.vue'
|
||||
export { default as RoutingModelPolicyEditor } from './RoutingModelPolicyEditor.vue'
|
||||
export { default as RoutingPriorityPolicyEditor } from './RoutingPriorityPolicyEditor.vue'
|
||||
export { default as RoutingRuleEditor } from './RoutingRuleEditor.vue'
|
||||
export { default as RoutingTraceViewer } from './RoutingTraceViewer.vue'
|
||||
4
frontend/src/features/routing/index.ts
Normal file
4
frontend/src/features/routing/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './components'
|
||||
export * from './utils/routingConditions'
|
||||
export * from './utils/routingPolicy'
|
||||
export * from './utils/routingTrace'
|
||||
73
frontend/src/features/routing/utils/routingConditions.ts
Normal file
73
frontend/src/features/routing/utils/routingConditions.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
export type RoutingConditionOp = 'eq' | 'ne' | 'in' | 'contains' | 'exists' | 'matches'
|
||||
|
||||
export interface RoutingConditionLeaf {
|
||||
field: string
|
||||
op: RoutingConditionOp
|
||||
value?: unknown
|
||||
}
|
||||
|
||||
export interface RoutingConditionGroup {
|
||||
all?: RoutingCondition[]
|
||||
any?: RoutingCondition[]
|
||||
not?: RoutingCondition
|
||||
}
|
||||
|
||||
export type RoutingCondition = RoutingConditionLeaf | RoutingConditionGroup
|
||||
|
||||
export const routingConditionFieldLabels: Record<string, string> = {
|
||||
model: '模型',
|
||||
api_format: 'API 格式',
|
||||
user_id: '用户',
|
||||
api_key_id: 'API Key',
|
||||
}
|
||||
|
||||
export const routingConditionOpLabels: Record<RoutingConditionOp, string> = {
|
||||
eq: '等于',
|
||||
ne: '不等于',
|
||||
in: '包含于',
|
||||
contains: '包含',
|
||||
exists: '存在',
|
||||
matches: '匹配',
|
||||
}
|
||||
|
||||
export function isConditionLeaf(condition: RoutingCondition): condition is RoutingConditionLeaf {
|
||||
return typeof (condition as RoutingConditionLeaf).field === 'string'
|
||||
}
|
||||
|
||||
export function summarizeRoutingCondition(condition: RoutingCondition): string {
|
||||
if (isConditionLeaf(condition)) {
|
||||
const field = routingConditionFieldLabels[condition.field] ?? condition.field
|
||||
const op = routingConditionOpLabels[condition.op] ?? condition.op
|
||||
return `${field} ${op} ${formatConditionValue(condition.value)}`
|
||||
}
|
||||
|
||||
if (condition.all?.length) {
|
||||
return condition.all.map(summarizeRoutingCondition).join(' 且 ')
|
||||
}
|
||||
|
||||
if (condition.any?.length) {
|
||||
return condition.any.map(summarizeRoutingCondition).join(' 或 ')
|
||||
}
|
||||
|
||||
if (condition.not) {
|
||||
return `非 ${summarizeRoutingCondition(condition.not)}`
|
||||
}
|
||||
|
||||
return '无条件'
|
||||
}
|
||||
|
||||
function formatConditionValue(value: unknown): string {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(formatConditionValue).join(', ')
|
||||
}
|
||||
|
||||
if (value === undefined || value === null) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
|
||||
return String(value)
|
||||
}
|
||||
350
frontend/src/features/routing/utils/routingPolicy.ts
Normal file
350
frontend/src/features/routing/utils/routingPolicy.ts
Normal file
@@ -0,0 +1,350 @@
|
||||
export type RoutingPriorityMode = 'provider' | 'global_key'
|
||||
export type RoutingSchedulingMode = 'fixed_order' | 'cache_affinity' | 'load_balance'
|
||||
export type RoutingRulePhase = 'client_request' | 'provider_request'
|
||||
|
||||
export interface RoutingDefaultPolicy {
|
||||
priority_mode: RoutingPriorityMode
|
||||
scheduling_mode: RoutingSchedulingMode
|
||||
keep_priority_on_conversion: boolean
|
||||
}
|
||||
|
||||
export interface RoutingPoolSchedulingPreset {
|
||||
preset: string
|
||||
enabled: boolean
|
||||
mode?: string | null
|
||||
}
|
||||
|
||||
export interface RoutingPoolPolicyOverride {
|
||||
scheduling_presets: RoutingPoolSchedulingPreset[]
|
||||
}
|
||||
|
||||
export interface RoutingModelPolicy {
|
||||
model: string
|
||||
allowed_providers: string[]
|
||||
allowed_keys: string[]
|
||||
provider_priority_overrides: Record<string, number>
|
||||
key_priority_overrides: Record<string, number>
|
||||
pool_priority_overrides: Record<string, number>
|
||||
pool_policy_overrides: Record<string, RoutingPoolPolicyOverride>
|
||||
}
|
||||
|
||||
export interface RoutingRule {
|
||||
id: string
|
||||
priority: number
|
||||
enabled: boolean
|
||||
phase: RoutingRulePhase
|
||||
conditions: unknown
|
||||
actions: unknown[]
|
||||
stop_processing: boolean
|
||||
}
|
||||
|
||||
export interface RoutingPredicateCondition {
|
||||
field: string
|
||||
op: 'eq' | 'prefix'
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface RoutingSetSchedulingAction {
|
||||
type: 'set_scheduling'
|
||||
priority_mode: RoutingPriorityMode
|
||||
scheduling_mode: RoutingSchedulingMode
|
||||
}
|
||||
|
||||
export interface RoutingGroupConfig {
|
||||
allowed_models: string[]
|
||||
default_policy: RoutingDefaultPolicy
|
||||
model_policies: RoutingModelPolicy[]
|
||||
rules: RoutingRule[]
|
||||
}
|
||||
|
||||
export const DEFAULT_ROUTING_POLICY_MODEL = '*'
|
||||
export const MODEL_SCHEDULING_RULE_PREFIX = 'ui_model_scheduling:'
|
||||
|
||||
export function createEmptyRoutingGroupConfig(): RoutingGroupConfig {
|
||||
return {
|
||||
allowed_models: [],
|
||||
default_policy: {
|
||||
priority_mode: 'provider',
|
||||
scheduling_mode: 'cache_affinity',
|
||||
keep_priority_on_conversion: false,
|
||||
},
|
||||
model_policies: [],
|
||||
rules: [],
|
||||
}
|
||||
}
|
||||
|
||||
export function createEmptyModelPolicy(model = ''): RoutingModelPolicy {
|
||||
return {
|
||||
model,
|
||||
allowed_providers: [],
|
||||
allowed_keys: [],
|
||||
provider_priority_overrides: {},
|
||||
key_priority_overrides: {},
|
||||
pool_priority_overrides: {},
|
||||
pool_policy_overrides: {},
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeRoutingGroupConfig(value: Partial<RoutingGroupConfig> | null | undefined): RoutingGroupConfig {
|
||||
const base = createEmptyRoutingGroupConfig()
|
||||
|
||||
return {
|
||||
allowed_models: Array.isArray(value?.allowed_models) ? [...value.allowed_models] : base.allowed_models,
|
||||
default_policy: {
|
||||
...base.default_policy,
|
||||
...(value?.default_policy ?? {}),
|
||||
},
|
||||
model_policies: Array.isArray(value?.model_policies)
|
||||
? value.model_policies.map(policy => ({
|
||||
...createEmptyModelPolicy(policy.model),
|
||||
...policy,
|
||||
allowed_providers: Array.isArray(policy.allowed_providers) ? [...policy.allowed_providers] : [],
|
||||
allowed_keys: Array.isArray(policy.allowed_keys) ? [...policy.allowed_keys] : [],
|
||||
provider_priority_overrides: { ...(policy.provider_priority_overrides ?? {}) },
|
||||
key_priority_overrides: { ...(policy.key_priority_overrides ?? {}) },
|
||||
pool_priority_overrides: { ...(policy.pool_priority_overrides ?? {}) },
|
||||
pool_policy_overrides: { ...(policy.pool_policy_overrides ?? {}) },
|
||||
}))
|
||||
: base.model_policies,
|
||||
rules: Array.isArray(value?.rules) ? value.rules.map(rule => ({ ...rule })) : base.rules,
|
||||
}
|
||||
}
|
||||
|
||||
export function upsertModelPolicy(config: RoutingGroupConfig, policy: RoutingModelPolicy): RoutingGroupConfig {
|
||||
const model = policy.model.trim()
|
||||
if (!model) {
|
||||
return normalizeRoutingGroupConfig(config)
|
||||
}
|
||||
|
||||
const next = normalizeRoutingGroupConfig(config)
|
||||
const index = next.model_policies.findIndex(item => item.model === model)
|
||||
const normalizedPolicy = { ...createEmptyModelPolicy(model), ...policy, model }
|
||||
|
||||
if (index >= 0) {
|
||||
next.model_policies[index] = normalizedPolicy
|
||||
} else {
|
||||
next.model_policies.push(normalizedPolicy)
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
export function removeModelPolicy(config: RoutingGroupConfig, model: string): RoutingGroupConfig {
|
||||
const next = normalizeRoutingGroupConfig(config)
|
||||
next.model_policies = next.model_policies.filter(policy => policy.model !== model)
|
||||
return next
|
||||
}
|
||||
|
||||
export function getDefaultModelPolicy(config: RoutingGroupConfig): RoutingModelPolicy {
|
||||
const normalized = normalizeRoutingGroupConfig(config)
|
||||
return normalized.model_policies.find(policy => policy.model === DEFAULT_ROUTING_POLICY_MODEL)
|
||||
?? createEmptyModelPolicy(DEFAULT_ROUTING_POLICY_MODEL)
|
||||
}
|
||||
|
||||
export function getModelPolicy(config: RoutingGroupConfig, model: string): RoutingModelPolicy {
|
||||
const normalizedModel = model.trim() || DEFAULT_ROUTING_POLICY_MODEL
|
||||
if (normalizedModel === DEFAULT_ROUTING_POLICY_MODEL) {
|
||||
return getDefaultModelPolicy(config)
|
||||
}
|
||||
const normalized = normalizeRoutingGroupConfig(config)
|
||||
return normalized.model_policies.find(policy => policy.model === normalizedModel)
|
||||
?? createEmptyModelPolicy(normalizedModel)
|
||||
}
|
||||
|
||||
export function upsertDefaultModelPolicy(
|
||||
config: RoutingGroupConfig,
|
||||
patch: Partial<Omit<RoutingModelPolicy, 'model'>>,
|
||||
): RoutingGroupConfig {
|
||||
const current = getDefaultModelPolicy(config)
|
||||
const next = upsertModelPolicy(config, {
|
||||
...current,
|
||||
...patch,
|
||||
model: DEFAULT_ROUTING_POLICY_MODEL,
|
||||
})
|
||||
next.model_policies = [
|
||||
...next.model_policies.filter(policy => policy.model === DEFAULT_ROUTING_POLICY_MODEL),
|
||||
...next.model_policies.filter(policy => policy.model !== DEFAULT_ROUTING_POLICY_MODEL),
|
||||
]
|
||||
return next
|
||||
}
|
||||
|
||||
export function setDefaultProviderPriorityOverrides(
|
||||
config: RoutingGroupConfig,
|
||||
overrides: Record<string, number>,
|
||||
): RoutingGroupConfig {
|
||||
return upsertDefaultModelPolicy(config, {
|
||||
provider_priority_overrides: normalizePriorityOverrides(overrides),
|
||||
})
|
||||
}
|
||||
|
||||
export function setDefaultKeyPriorityOverrides(
|
||||
config: RoutingGroupConfig,
|
||||
overrides: Record<string, number>,
|
||||
): RoutingGroupConfig {
|
||||
return upsertDefaultModelPolicy(config, {
|
||||
key_priority_overrides: normalizePriorityOverrides(overrides),
|
||||
})
|
||||
}
|
||||
|
||||
export function setDefaultPoolPriorityOverrides(
|
||||
config: RoutingGroupConfig,
|
||||
overrides: Record<string, number>,
|
||||
): RoutingGroupConfig {
|
||||
return upsertDefaultModelPolicy(config, {
|
||||
pool_priority_overrides: normalizePriorityOverrides(overrides),
|
||||
})
|
||||
}
|
||||
|
||||
export function setModelProviderPriorityOverrides(
|
||||
config: RoutingGroupConfig,
|
||||
model: string,
|
||||
overrides: Record<string, number>,
|
||||
): RoutingGroupConfig {
|
||||
const normalizedModel = model.trim() || DEFAULT_ROUTING_POLICY_MODEL
|
||||
if (normalizedModel === DEFAULT_ROUTING_POLICY_MODEL) {
|
||||
return setDefaultProviderPriorityOverrides(config, overrides)
|
||||
}
|
||||
return upsertModelPolicy(config, {
|
||||
...getModelPolicy(config, normalizedModel),
|
||||
model: normalizedModel,
|
||||
provider_priority_overrides: normalizePriorityOverrides(overrides),
|
||||
})
|
||||
}
|
||||
|
||||
export function setModelKeyPriorityOverrides(
|
||||
config: RoutingGroupConfig,
|
||||
model: string,
|
||||
overrides: Record<string, number>,
|
||||
): RoutingGroupConfig {
|
||||
const normalizedModel = model.trim() || DEFAULT_ROUTING_POLICY_MODEL
|
||||
if (normalizedModel === DEFAULT_ROUTING_POLICY_MODEL) {
|
||||
return setDefaultKeyPriorityOverrides(config, overrides)
|
||||
}
|
||||
return upsertModelPolicy(config, {
|
||||
...getModelPolicy(config, normalizedModel),
|
||||
model: normalizedModel,
|
||||
key_priority_overrides: normalizePriorityOverrides(overrides),
|
||||
})
|
||||
}
|
||||
|
||||
export function setModelPoolPriorityOverrides(
|
||||
config: RoutingGroupConfig,
|
||||
model: string,
|
||||
overrides: Record<string, number>,
|
||||
): RoutingGroupConfig {
|
||||
const normalizedModel = model.trim() || DEFAULT_ROUTING_POLICY_MODEL
|
||||
if (normalizedModel === DEFAULT_ROUTING_POLICY_MODEL) {
|
||||
return setDefaultPoolPriorityOverrides(config, overrides)
|
||||
}
|
||||
return upsertModelPolicy(config, {
|
||||
...getModelPolicy(config, normalizedModel),
|
||||
model: normalizedModel,
|
||||
pool_priority_overrides: normalizePriorityOverrides(overrides),
|
||||
})
|
||||
}
|
||||
|
||||
export function modelSchedulingRuleId(model: string): string {
|
||||
return `${MODEL_SCHEDULING_RULE_PREFIX}${encodeURIComponent(model.trim())}`
|
||||
}
|
||||
|
||||
export function isGeneratedModelSchedulingRule(rule: RoutingRule): boolean {
|
||||
return rule.id.startsWith(MODEL_SCHEDULING_RULE_PREFIX)
|
||||
}
|
||||
|
||||
export function modelPatternCondition(model: string): RoutingPredicateCondition {
|
||||
const normalizedModel = model.trim()
|
||||
if (normalizedModel.endsWith('*')) {
|
||||
return {
|
||||
field: 'model',
|
||||
op: 'prefix',
|
||||
value: normalizedModel.slice(0, -1),
|
||||
}
|
||||
}
|
||||
return {
|
||||
field: 'model',
|
||||
op: 'eq',
|
||||
value: normalizedModel,
|
||||
}
|
||||
}
|
||||
|
||||
export function getModelScheduling(
|
||||
config: RoutingGroupConfig,
|
||||
model: string,
|
||||
): RoutingDefaultPolicy {
|
||||
const normalized = normalizeRoutingGroupConfig(config)
|
||||
const rule = normalized.rules.find(rule => rule.id === modelSchedulingRuleId(model))
|
||||
const action = rule?.actions.find(isSetSchedulingAction)
|
||||
return {
|
||||
priority_mode: action?.priority_mode ?? normalized.default_policy.priority_mode,
|
||||
scheduling_mode: action?.scheduling_mode ?? normalized.default_policy.scheduling_mode,
|
||||
keep_priority_on_conversion: normalized.default_policy.keep_priority_on_conversion,
|
||||
}
|
||||
}
|
||||
|
||||
export function upsertModelSchedulingRule(
|
||||
config: RoutingGroupConfig,
|
||||
model: string,
|
||||
scheduling: Pick<RoutingDefaultPolicy, 'priority_mode' | 'scheduling_mode'>,
|
||||
): RoutingGroupConfig {
|
||||
const normalizedModel = model.trim()
|
||||
if (!normalizedModel || normalizedModel === DEFAULT_ROUTING_POLICY_MODEL) {
|
||||
return normalizeRoutingGroupConfig(config)
|
||||
}
|
||||
|
||||
const next = normalizeRoutingGroupConfig(config)
|
||||
const rule: RoutingRule = {
|
||||
id: modelSchedulingRuleId(normalizedModel),
|
||||
priority: 10_000 + next.rules.filter(isGeneratedModelSchedulingRule).length,
|
||||
enabled: true,
|
||||
phase: 'client_request',
|
||||
conditions: modelPatternCondition(normalizedModel),
|
||||
actions: [{
|
||||
type: 'set_scheduling',
|
||||
priority_mode: scheduling.priority_mode,
|
||||
scheduling_mode: scheduling.scheduling_mode,
|
||||
} satisfies RoutingSetSchedulingAction],
|
||||
stop_processing: false,
|
||||
}
|
||||
|
||||
const index = next.rules.findIndex(item => item.id === rule.id)
|
||||
if (index >= 0) {
|
||||
next.rules[index] = {
|
||||
...next.rules[index],
|
||||
...rule,
|
||||
priority: next.rules[index].priority,
|
||||
}
|
||||
} else {
|
||||
next.rules.push(rule)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
export function removeModelSchedulingRule(config: RoutingGroupConfig, model: string): RoutingGroupConfig {
|
||||
const ruleId = modelSchedulingRuleId(model)
|
||||
const next = normalizeRoutingGroupConfig(config)
|
||||
next.rules = next.rules.filter(rule => rule.id !== ruleId)
|
||||
return next
|
||||
}
|
||||
|
||||
export function removeGeneratedModelSchedulingRules(config: RoutingGroupConfig): RoutingGroupConfig {
|
||||
const next = normalizeRoutingGroupConfig(config)
|
||||
next.rules = next.rules.filter(rule => !isGeneratedModelSchedulingRule(rule))
|
||||
return next
|
||||
}
|
||||
|
||||
export function normalizePriorityOverrides(overrides: Record<string, number>): Record<string, number> {
|
||||
const normalized: Record<string, number> = {}
|
||||
for (const [rawId, rawPriority] of Object.entries(overrides)) {
|
||||
const id = rawId.trim()
|
||||
const priority = Math.max(0, Math.trunc(Number(rawPriority)))
|
||||
if (!id || !Number.isFinite(priority)) continue
|
||||
normalized[id] = priority
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
function isSetSchedulingAction(action: unknown): action is RoutingSetSchedulingAction {
|
||||
if (!action || typeof action !== 'object') return false
|
||||
const candidate = action as Partial<RoutingSetSchedulingAction>
|
||||
return candidate.type === 'set_scheduling'
|
||||
}
|
||||
59
frontend/src/features/routing/utils/routingTrace.ts
Normal file
59
frontend/src/features/routing/utils/routingTrace.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
export interface RoutingCandidateRankVector {
|
||||
provider_priority_before: number
|
||||
provider_priority_after: number
|
||||
key_priority_before: number
|
||||
key_priority_after: number
|
||||
}
|
||||
|
||||
export interface RoutingCandidateTrace {
|
||||
candidate_kind: 'provider' | 'pool_group'
|
||||
provider_id: string
|
||||
endpoint_id: string
|
||||
model_id: string
|
||||
key_id?: string | null
|
||||
ranking_vector: RoutingCandidateRankVector
|
||||
skip_reason?: string | null
|
||||
selected_order?: number | null
|
||||
}
|
||||
|
||||
export interface RoutingDecisionTrace {
|
||||
group_id?: string | null
|
||||
group_version?: number | null
|
||||
selection_source: string
|
||||
selected_rules: string[]
|
||||
original_model: string
|
||||
resolved_model: string
|
||||
client_api_format: string
|
||||
global_candidates: RoutingCandidateTrace[]
|
||||
pool_expansion: unknown[]
|
||||
runtime_facts: Record<string, unknown>
|
||||
}
|
||||
|
||||
export function candidateTraceLabel(candidate: RoutingCandidateTrace): string {
|
||||
const kind = candidate.candidate_kind === 'pool_group' ? '号池' : 'Provider'
|
||||
const key = candidate.key_id ? ` / ${candidate.key_id}` : ''
|
||||
return `${kind} ${candidate.provider_id}${key}`
|
||||
}
|
||||
|
||||
export function summarizeRoutingTrace(trace: RoutingDecisionTrace): string[] {
|
||||
const lines = [
|
||||
`分组: ${trace.group_id ?? 'legacy'}`,
|
||||
`来源: ${trace.selection_source}`,
|
||||
`模型: ${trace.original_model} -> ${trace.resolved_model}`,
|
||||
]
|
||||
|
||||
if (trace.selected_rules.length > 0) {
|
||||
lines.push(`规则: ${trace.selected_rules.join(', ')}`)
|
||||
}
|
||||
|
||||
lines.push(`候选: ${trace.global_candidates.length}`)
|
||||
return lines
|
||||
}
|
||||
|
||||
export function sortCandidateTraces(candidates: readonly RoutingCandidateTrace[]): RoutingCandidateTrace[] {
|
||||
return [...candidates].sort((left, right) => {
|
||||
const leftOrder = left.selected_order ?? Number.MAX_SAFE_INTEGER
|
||||
const rightOrder = right.selected_order ?? Number.MAX_SAFE_INTEGER
|
||||
return leftOrder - rightOrder
|
||||
})
|
||||
}
|
||||
@@ -306,7 +306,10 @@
|
||||
</div>
|
||||
|
||||
<!-- 桌面端表格视图 -->
|
||||
<Table :class="['hidden md:table table-fixed w-full', desktopTableMinWidthClass]">
|
||||
<Table
|
||||
class="hidden md:table table-fixed w-full"
|
||||
:class="[desktopTableMinWidthClass]"
|
||||
>
|
||||
<colgroup v-if="isAdmin">
|
||||
<col v-if="isColumnVisible('time')" class="w-[8%]">
|
||||
<col v-if="isColumnVisible('user')" class="w-[12%]">
|
||||
@@ -366,7 +369,8 @@
|
||||
</TableHead>
|
||||
<SortableTableHead
|
||||
v-if="isColumnVisible('model')"
|
||||
:class="['h-12 font-semibold', isAdmin ? 'w-[14%]' : 'w-[22%]']"
|
||||
class="h-12 font-semibold"
|
||||
:class="[isAdmin ? 'w-[14%]' : 'w-[22%]']"
|
||||
column-key="model"
|
||||
:sortable="false"
|
||||
:filter-active="filterModel !== '__all__'"
|
||||
@@ -404,7 +408,8 @@
|
||||
</SortableTableHead>
|
||||
<SortableTableHead
|
||||
v-if="isColumnVisible('api_format')"
|
||||
:class="['h-12 font-semibold', isAdmin ? 'w-[15%]' : 'w-[14%]']"
|
||||
class="h-12 font-semibold"
|
||||
:class="[isAdmin ? 'w-[15%]' : 'w-[14%]']"
|
||||
column-key="api_format"
|
||||
:sortable="false"
|
||||
:filter-active="filterApiFormat !== '__all__'"
|
||||
@@ -543,7 +548,8 @@
|
||||
</TableCell>
|
||||
<TableCell
|
||||
v-if="isColumnVisible('model')"
|
||||
:class="['font-medium py-4', isAdmin ? 'w-[14%]' : 'w-[22%]']"
|
||||
class="font-medium py-4"
|
||||
:class="[isAdmin ? 'w-[14%]' : 'w-[22%]']"
|
||||
:title="getModelTooltip(record)"
|
||||
>
|
||||
<div
|
||||
@@ -631,7 +637,8 @@
|
||||
</TableCell>
|
||||
<TableCell
|
||||
v-if="isColumnVisible('api_format')"
|
||||
:class="['py-4', isAdmin ? 'w-[15%]' : 'w-[14%]']"
|
||||
class="py-4"
|
||||
:class="[isAdmin ? 'w-[15%]' : 'w-[14%]']"
|
||||
:title="getApiFormatTooltip(record)"
|
||||
>
|
||||
<!-- 有格式转换或同族格式差异:两行显示 -->
|
||||
@@ -731,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' : ''
|
||||
]"
|
||||
>
|
||||
@@ -742,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' : ''
|
||||
]"
|
||||
>
|
||||
|
||||
@@ -696,6 +696,7 @@ 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 },
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -200,6 +200,11 @@ const routes: RouteRecordRaw[] = [
|
||||
name: 'ModelManagement',
|
||||
component: () => importWithRetry(() => import('@/views/admin/ModelManagement.vue'))
|
||||
},
|
||||
{
|
||||
path: 'routing',
|
||||
name: 'RoutingProfiles',
|
||||
component: () => importWithRetry(() => import('@/views/admin/RoutingProfiles.vue'))
|
||||
},
|
||||
{
|
||||
path: 'health-monitor',
|
||||
name: 'HealthMonitor',
|
||||
|
||||
@@ -25,11 +25,11 @@ describe('parseDateLike', () => {
|
||||
describe('datetime-local conversion', () => {
|
||||
it('formats RFC3339 instants for datetime-local inputs using local clock fields', () => {
|
||||
const date = new Date('2026-04-12T15:30:00Z')
|
||||
const expected = [
|
||||
const expected = `${[
|
||||
date.getFullYear(),
|
||||
padDatePart(date.getMonth() + 1),
|
||||
padDatePart(date.getDate()),
|
||||
].join('-') + `T${padDatePart(date.getHours())}:${padDatePart(date.getMinutes())}`
|
||||
].join('-') }T${padDatePart(date.getHours())}:${padDatePart(date.getMinutes())}`
|
||||
|
||||
expect(formatDateTimeLocalInput('2026-04-12T15:30:00Z')).toBe(expected)
|
||||
})
|
||||
|
||||
@@ -82,4 +82,16 @@ describe('oauthRefreshFeedback', () => {
|
||||
message: 'Token 刷新成功,已重新检查额度/账号状态',
|
||||
})
|
||||
})
|
||||
|
||||
it('includes the recheck failure detail after a successful token refresh', () => {
|
||||
expect(
|
||||
getOAuthRefreshFeedback({
|
||||
accountStateRecheckAttempted: true,
|
||||
accountStateRecheckError: 'wham/usage API 返回状态码 401',
|
||||
}),
|
||||
).toEqual({
|
||||
tone: 'warning',
|
||||
message: 'Token 刷新成功,但额度/账号状态复检失败:wham/usage API 返回状态码 401',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -20,6 +20,13 @@ function normalizeText(value: unknown): string | null {
|
||||
return text || null
|
||||
}
|
||||
|
||||
function formatRecheckError(value: string): string {
|
||||
const collapsed = value.replace(/\s+/g, ' ').trim()
|
||||
const maxLength = 180
|
||||
if (collapsed.length <= maxLength) return collapsed
|
||||
return `${collapsed.slice(0, maxLength - 3).trimEnd()}...`
|
||||
}
|
||||
|
||||
export function resolveOAuthAccountBlockDisplay(
|
||||
snapshot: ProviderKeyStatusCarrier,
|
||||
): OAuthAccountBlockDisplay {
|
||||
@@ -45,7 +52,7 @@ export function getOAuthRefreshFeedback(
|
||||
if (recheckError) {
|
||||
return {
|
||||
tone: 'warning',
|
||||
message: 'Token 刷新成功,但额度/账号状态复检失败',
|
||||
message: `Token 刷新成功,但额度/账号状态复检失败:${formatRecheckError(recheckError)}`,
|
||||
}
|
||||
}
|
||||
if (blockedLabel) {
|
||||
|
||||
@@ -511,7 +511,7 @@
|
||||
<span>用户: {{ selectedTask.username }}</span>
|
||||
</template>
|
||||
<span class="opacity-40">|</span>
|
||||
<span>{{ displayTaskSource(selectedTask) }}</span>
|
||||
<span>{{ displayTaskSource(selectedTask) }}</span>
|
||||
</div>
|
||||
<!-- 进度条 -->
|
||||
<div
|
||||
|
||||
@@ -41,12 +41,19 @@
|
||||
:class="selectedType === '__new__' ? 'bg-primary/10 text-primary' : 'text-foreground hover:bg-muted'"
|
||||
@click="selectNewConfig()"
|
||||
>
|
||||
<div class="w-7 h-7 rounded-md flex items-center justify-center text-xs font-semibold shrink-0"
|
||||
<div
|
||||
class="w-7 h-7 rounded-md flex items-center justify-center text-xs font-semibold shrink-0"
|
||||
:class="selectedType === '__new__' ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground'"
|
||||
>+</div>
|
||||
>
|
||||
+
|
||||
</div>
|
||||
<div class="flex-1 min-w-0 text-left">
|
||||
<div class="truncate font-medium text-sm">新配置</div>
|
||||
<div class="text-[10px] text-muted-foreground">未保存</div>
|
||||
<div class="truncate font-medium text-sm">
|
||||
新配置
|
||||
</div>
|
||||
<div class="text-[10px] text-muted-foreground">
|
||||
未保存
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -70,10 +77,12 @@
|
||||
src="https://cdn.linux.do/uploads/default/optimized/3X/9/d/9dd49731091ce8656243f3c2b6e5d5e5a7e3e3e3_2_32x32.png"
|
||||
class="absolute inset-0 w-full h-full object-cover"
|
||||
@error="($event.target as HTMLImageElement).remove()"
|
||||
/>
|
||||
>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0 text-left">
|
||||
<div class="truncate font-medium text-sm">{{ item.display_name }}</div>
|
||||
<div class="truncate font-medium text-sm">
|
||||
{{ item.display_name }}
|
||||
</div>
|
||||
<div class="text-[10px] text-muted-foreground">
|
||||
{{ item.configured ? (item.is_enabled ? '已启用' : '已禁用') : '未配置' }}
|
||||
</div>
|
||||
@@ -96,262 +105,261 @@
|
||||
|
||||
<!-- 右侧内容区 -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<!-- 配置表单 -->
|
||||
<CardSection
|
||||
v-if="selectedType"
|
||||
:title="selectedType === '__new__' ? '新建配置' : (selectedTypeMeta?.display_name || selectedType)"
|
||||
:description="selectedType === '__new__' ? '填写后点击保存' : (configs[selectedType]?.is_enabled ? '已启用' : '已禁用')"
|
||||
>
|
||||
<template #actions>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
:disabled="saving || testing"
|
||||
@click="handleTest"
|
||||
>
|
||||
{{ testing ? '测试中...' : '测试' }}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
:disabled="saving"
|
||||
@click="handleSave"
|
||||
>
|
||||
{{ saving ? '保存中...' : '保存' }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 配置表单 -->
|
||||
<CardSection
|
||||
v-if="selectedType"
|
||||
:title="selectedType === '__new__' ? '新建配置' : (selectedTypeMeta?.display_name || selectedType)"
|
||||
:description="selectedType === '__new__' ? '填写后点击保存' : (configs[selectedType]?.is_enabled ? '已启用' : '已禁用')"
|
||||
>
|
||||
<template #actions>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
:disabled="saving || testing"
|
||||
@click="handleTest"
|
||||
<div class="space-y-6">
|
||||
<!-- 新建时的 Display Name -->
|
||||
<div
|
||||
v-if="selectedType === '__new__'"
|
||||
class="grid grid-cols-1 md:grid-cols-2 gap-4"
|
||||
>
|
||||
{{ testing ? '测试中...' : '测试' }}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
:disabled="saving"
|
||||
@click="handleSave"
|
||||
>
|
||||
{{ saving ? '保存中...' : '保存' }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- 新建时的 Display Name -->
|
||||
<div
|
||||
v-if="selectedType === '__new__'"
|
||||
class="grid grid-cols-1 md:grid-cols-2 gap-4"
|
||||
>
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">显示名称</Label>
|
||||
<Input
|
||||
v-model="form.new_display_name"
|
||||
class="mt-1"
|
||||
placeholder="例如:My OIDC Provider"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">配置标识</Label>
|
||||
<Input
|
||||
v-model="form.new_provider_type"
|
||||
class="mt-1"
|
||||
placeholder="custom_oidc_work"
|
||||
autocomplete="off"
|
||||
@blur="normalizeNewProviderType"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 凭证配置 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">Client ID</Label>
|
||||
<Input
|
||||
v-model="form.client_id"
|
||||
class="mt-1"
|
||||
placeholder="client_id"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">Client Secret</Label>
|
||||
<Input
|
||||
v-model="form.client_secret"
|
||||
masked
|
||||
class="mt-1"
|
||||
:placeholder="hasSecret ? '已设置(留空保持不变)' : '请输入 secret'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 回调地址 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">Redirect URI(后端回调)</Label>
|
||||
<Input
|
||||
v-model="form.redirect_uri"
|
||||
class="mt-1"
|
||||
placeholder="http://localhost:8084/api/oauth/xxx/callback"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">前端回调页</Label>
|
||||
<Input
|
||||
v-model="form.frontend_callback_url"
|
||||
class="mt-1"
|
||||
placeholder="http://localhost:5173/auth/callback"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- custom_oidc 必填端点 -->
|
||||
<div
|
||||
v-if="isSelectedCustomProvider"
|
||||
class="grid grid-cols-1 md:grid-cols-3 gap-4"
|
||||
>
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">Authorization URL</Label>
|
||||
<Input
|
||||
v-model="form.authorization_url_override"
|
||||
class="mt-1"
|
||||
placeholder="https://example.com/oauth/authorize"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">Token URL</Label>
|
||||
<Input
|
||||
v-model="form.token_url_override"
|
||||
class="mt-1"
|
||||
placeholder="https://example.com/oauth/token"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">Userinfo URL</Label>
|
||||
<Input
|
||||
v-model="form.userinfo_url_override"
|
||||
class="mt-1"
|
||||
placeholder="https://example.com/api/user"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 高级选项(折叠) -->
|
||||
<details class="group">
|
||||
<summary class="cursor-pointer text-sm font-medium text-muted-foreground hover:text-foreground transition-colors">
|
||||
高级选项
|
||||
</summary>
|
||||
<div class="mt-4 space-y-4 pl-4 border-l-2 border-border">
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">Scopes</Label>
|
||||
<Label class="block text-sm font-medium">显示名称</Label>
|
||||
<Input
|
||||
v-model="form.scopes_input"
|
||||
v-model="form.new_display_name"
|
||||
class="mt-1"
|
||||
:placeholder="selectedTypeMeta?.default_scopes?.join(' ') || '留空使用默认值'"
|
||||
placeholder="例如:My OIDC Provider"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
空格/逗号分隔;留空使用默认值
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- linuxdo 的可选端点覆盖 -->
|
||||
<div
|
||||
v-if="!isSelectedCustomProvider"
|
||||
class="grid grid-cols-1 md:grid-cols-3 gap-4"
|
||||
>
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">Authorization URL</Label>
|
||||
<Input
|
||||
v-model="form.authorization_url_override"
|
||||
class="mt-1"
|
||||
:placeholder="selectedTypeMeta?.default_authorization_url || '默认'"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">Token URL</Label>
|
||||
<Input
|
||||
v-model="form.token_url_override"
|
||||
class="mt-1"
|
||||
:placeholder="selectedTypeMeta?.default_token_url || '默认'"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">Userinfo URL</Label>
|
||||
<Input
|
||||
v-model="form.userinfo_url_override"
|
||||
class="mt-1"
|
||||
:placeholder="selectedTypeMeta?.default_userinfo_url || '默认'"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">配置标识</Label>
|
||||
<Input
|
||||
v-model="form.new_provider_type"
|
||||
class="mt-1"
|
||||
placeholder="custom_oidc_work"
|
||||
autocomplete="off"
|
||||
@blur="normalizeNewProviderType"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<!-- 凭证配置 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">Client ID</Label>
|
||||
<Input
|
||||
v-model="form.client_id"
|
||||
class="mt-1"
|
||||
placeholder="client_id"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">Client Secret</Label>
|
||||
<Input
|
||||
v-model="form.client_secret"
|
||||
masked
|
||||
class="mt-1"
|
||||
:placeholder="hasSecret ? '已设置(留空保持不变)' : '请输入 secret'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 回调地址 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">Redirect URI(后端回调)</Label>
|
||||
<Input
|
||||
v-model="form.redirect_uri"
|
||||
class="mt-1"
|
||||
placeholder="http://localhost:8084/api/oauth/xxx/callback"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">前端回调页</Label>
|
||||
<Input
|
||||
v-model="form.frontend_callback_url"
|
||||
class="mt-1"
|
||||
placeholder="http://localhost:5173/auth/callback"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- custom_oidc 必填端点 -->
|
||||
<div
|
||||
v-if="isSelectedCustomProvider"
|
||||
class="grid grid-cols-1 md:grid-cols-3 gap-4"
|
||||
>
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">Authorization URL</Label>
|
||||
<Input
|
||||
v-model="form.authorization_url_override"
|
||||
class="mt-1"
|
||||
placeholder="https://example.com/oauth/authorize"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">Token URL</Label>
|
||||
<Input
|
||||
v-model="form.token_url_override"
|
||||
class="mt-1"
|
||||
placeholder="https://example.com/oauth/token"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">Userinfo URL</Label>
|
||||
<Input
|
||||
v-model="form.userinfo_url_override"
|
||||
class="mt-1"
|
||||
placeholder="https://example.com/api/user"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 高级选项(折叠) -->
|
||||
<details class="group">
|
||||
<summary class="cursor-pointer text-sm font-medium text-muted-foreground hover:text-foreground transition-colors">
|
||||
高级选项
|
||||
</summary>
|
||||
<div class="mt-4 space-y-4 pl-4 border-l-2 border-border">
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">Attribute Mapping</Label>
|
||||
<Textarea
|
||||
v-model="form.attribute_mapping_json"
|
||||
class="mt-1 font-mono text-xs"
|
||||
rows="3"
|
||||
placeholder="{"id": "user_id", "username": "login"}"
|
||||
<Label class="block text-sm font-medium">Scopes</Label>
|
||||
<Input
|
||||
v-model="form.scopes_input"
|
||||
class="mt-1"
|
||||
:placeholder="selectedTypeMeta?.default_scopes?.join(' ') || '留空使用默认值'"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">
|
||||
{{ isSelectedCustomProvider ? 'Allowed Domains / Extra Config' : 'Extra Config' }}
|
||||
</Label>
|
||||
<Textarea
|
||||
v-model="form.extra_config_json"
|
||||
class="mt-1 font-mono text-xs"
|
||||
rows="3"
|
||||
:placeholder="extraConfigPlaceholder"
|
||||
/>
|
||||
<p
|
||||
v-if="isSelectedCustomProvider"
|
||||
class="mt-1 text-xs text-muted-foreground"
|
||||
>
|
||||
自定义 OIDC 必填;填写 Authorization / Token / Userinfo URL 所属域名。
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
空格/逗号分隔;留空使用默认值
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- 测试结果 -->
|
||||
<div
|
||||
v-if="lastTestResult"
|
||||
class="mt-6 rounded-lg border border-border p-4 text-sm"
|
||||
>
|
||||
<div class="font-medium mb-2">
|
||||
测试结果
|
||||
<!-- linuxdo 的可选端点覆盖 -->
|
||||
<div
|
||||
v-if="!isSelectedCustomProvider"
|
||||
class="grid grid-cols-1 md:grid-cols-3 gap-4"
|
||||
>
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">Authorization URL</Label>
|
||||
<Input
|
||||
v-model="form.authorization_url_override"
|
||||
class="mt-1"
|
||||
:placeholder="selectedTypeMeta?.default_authorization_url || '默认'"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">Token URL</Label>
|
||||
<Input
|
||||
v-model="form.token_url_override"
|
||||
class="mt-1"
|
||||
:placeholder="selectedTypeMeta?.default_token_url || '默认'"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">Userinfo URL</Label>
|
||||
<Input
|
||||
v-model="form.userinfo_url_override"
|
||||
class="mt-1"
|
||||
:placeholder="selectedTypeMeta?.default_userinfo_url || '默认'"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">Attribute Mapping</Label>
|
||||
<Textarea
|
||||
v-model="form.attribute_mapping_json"
|
||||
class="mt-1 font-mono text-xs"
|
||||
rows="3"
|
||||
placeholder="{"id": "user_id", "username": "login"}"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">
|
||||
{{ isSelectedCustomProvider ? 'Allowed Domains / Extra Config' : 'Extra Config' }}
|
||||
</Label>
|
||||
<Textarea
|
||||
v-model="form.extra_config_json"
|
||||
class="mt-1 font-mono text-xs"
|
||||
rows="3"
|
||||
:placeholder="extraConfigPlaceholder"
|
||||
/>
|
||||
<p
|
||||
v-if="isSelectedCustomProvider"
|
||||
class="mt-1 text-xs text-muted-foreground"
|
||||
>
|
||||
自定义 OIDC 必填;填写 Authorization / Token / Userinfo URL 所属域名。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-4 text-xs">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="w-2 h-2 rounded-full"
|
||||
:class="lastTestResult.authorization_url_reachable ? 'bg-green-500' : 'bg-red-500'"
|
||||
/>
|
||||
<span class="text-muted-foreground">Authorization URL</span>
|
||||
|
||||
<!-- 测试结果 -->
|
||||
<div
|
||||
v-if="lastTestResult"
|
||||
class="mt-6 rounded-lg border border-border p-4 text-sm"
|
||||
>
|
||||
<div class="font-medium mb-2">
|
||||
测试结果
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex flex-wrap gap-4 text-xs">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="w-2 h-2 rounded-full"
|
||||
:class="lastTestResult.authorization_url_reachable ? 'bg-green-500' : 'bg-red-500'"
|
||||
/>
|
||||
<span class="text-muted-foreground">Authorization URL</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="w-2 h-2 rounded-full"
|
||||
:class="lastTestResult.token_url_reachable ? 'bg-green-500' : 'bg-red-500'"
|
||||
/>
|
||||
<span class="text-muted-foreground">Token URL</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="w-2 h-2 rounded-full"
|
||||
:class="lastTestResult.secret_status === 'likely_valid' ? 'bg-green-500' : lastTestResult.secret_status === 'invalid' ? 'bg-red-500' : 'bg-yellow-500'"
|
||||
/>
|
||||
<span class="text-muted-foreground">Secret: {{ lastTestResult.secret_status }}</span>
|
||||
</div>
|
||||
<span
|
||||
class="w-2 h-2 rounded-full"
|
||||
:class="lastTestResult.token_url_reachable ? 'bg-green-500' : 'bg-red-500'"
|
||||
/>
|
||||
<span class="text-muted-foreground">Token URL</span>
|
||||
v-if="lastTestResult.details"
|
||||
class="text-muted-foreground"
|
||||
>
|
||||
{{ lastTestResult.details }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="w-2 h-2 rounded-full"
|
||||
:class="lastTestResult.secret_status === 'likely_valid' ? 'bg-green-500' : lastTestResult.secret_status === 'invalid' ? 'bg-red-500' : 'bg-yellow-500'"
|
||||
/>
|
||||
<span class="text-muted-foreground">Secret: {{ lastTestResult.secret_status }}</span>
|
||||
</div>
|
||||
<span
|
||||
v-if="lastTestResult.details"
|
||||
class="text-muted-foreground"
|
||||
>
|
||||
{{ lastTestResult.details }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardSection>
|
||||
</CardSection>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
|
||||
@@ -822,7 +822,6 @@
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
1152
frontend/src/views/admin/RoutingProfiles.vue
Normal file
1152
frontend/src/views/admin/RoutingProfiles.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -288,7 +288,8 @@
|
||||
<ManualCleanupConfirmDialog
|
||||
:open="manualCleanupDialogOpen"
|
||||
@update:open="manualCleanupDialogOpen = $event"
|
||||
@confirm="handleManualCleanupConfirm"
|
||||
@running-change="manualCleanupRunning = $event"
|
||||
@completed="handleManualCleanupCompleted"
|
||||
/>
|
||||
|
||||
<div
|
||||
@@ -402,7 +403,7 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { RefreshCw, Trash2 } from 'lucide-vue-next'
|
||||
import { adminApi, type CleanupRunRecord, type ManualUsageCleanupResponse } from '@/api/admin'
|
||||
import { adminApi, type CleanupRunRecord } from '@/api/admin'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Input from '@/components/ui/input.vue'
|
||||
import Label from '@/components/ui/label.vue'
|
||||
@@ -410,7 +411,6 @@ import Switch from '@/components/ui/switch.vue'
|
||||
import { CardSection } from '@/components/layout'
|
||||
import ManualCleanupConfirmDialog from './ManualCleanupConfirmDialog.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
|
||||
defineProps<{
|
||||
enableAutoCleanup: boolean
|
||||
@@ -459,52 +459,18 @@ function openManualCleanupDialog() {
|
||||
manualCleanupDialogOpen.value = true
|
||||
}
|
||||
|
||||
async function handleManualCleanupConfirm(olderThanDays: number | undefined) {
|
||||
manualCleanupRunning.value = true
|
||||
try {
|
||||
const response = await adminApi.runManualUsageCleanup(
|
||||
typeof olderThanDays === 'number' ? { older_than_days: olderThanDays } : {},
|
||||
)
|
||||
if ('detail' in response && response.detail === 'usage_cleanup_already_running') {
|
||||
manualCleanupResult.value = {
|
||||
title: '已有一次清理正在进行中',
|
||||
description: response.message,
|
||||
}
|
||||
toast.warning(response.message)
|
||||
} else {
|
||||
const completed = response as ManualUsageCleanupResponse
|
||||
manualCleanupResult.value = {
|
||||
title: completed.message,
|
||||
description: summarizeManualCleanup(completed),
|
||||
}
|
||||
toast.success(completed.message)
|
||||
}
|
||||
manualCleanupDialogOpen.value = false
|
||||
} catch (error) {
|
||||
const message = parseApiError(error).message
|
||||
manualCleanupResult.value = {
|
||||
title: '请求记录清理失败',
|
||||
description: message,
|
||||
}
|
||||
toast.error(message)
|
||||
} finally {
|
||||
manualCleanupRunning.value = false
|
||||
void loadCleanupRuns()
|
||||
function handleManualCleanupCompleted(task: CleanupRunRecord) {
|
||||
manualCleanupRunning.value = false
|
||||
manualCleanupResult.value = {
|
||||
title: task.message,
|
||||
description: cleanupSummaryText(task.summary),
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeManualCleanup(response: ManualUsageCleanupResponse): string {
|
||||
const { summary } = response
|
||||
const parts: string[] = []
|
||||
if (summary.records_deleted > 0) parts.push(`删除整条记录 ${summary.records_deleted}`)
|
||||
if (summary.body_externalized > 0) parts.push(`压缩 body ${summary.body_externalized}`)
|
||||
if (summary.body_cleaned > 0) parts.push(`清除过期 body ${summary.body_cleaned}`)
|
||||
if (summary.header_cleaned > 0) parts.push(`清除 headers ${summary.header_cleaned}`)
|
||||
if (summary.legacy_body_refs_migrated > 0) {
|
||||
parts.push(`迁移遗留引用 ${summary.legacy_body_refs_migrated}`)
|
||||
if (task.status === 'failed') {
|
||||
toast.error(task.error || task.message)
|
||||
} else {
|
||||
toast.success(task.message)
|
||||
}
|
||||
if (summary.keys_cleaned > 0) parts.push(`回收 Key ${summary.keys_cleaned}`)
|
||||
return parts.length > 0 ? parts.join(' / ') : '无数据变更'
|
||||
void loadCleanupRuns()
|
||||
}
|
||||
|
||||
async function loadCleanupRuns() {
|
||||
@@ -568,7 +534,7 @@ function cleanupSummaryText(summary: Record<string, unknown>): string {
|
||||
|
||||
function summaryLabel(key: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
body_externalized: '压缩',
|
||||
body_externalized: '详细体',
|
||||
legacy_body_refs_migrated: '迁移',
|
||||
body_cleaned: '清体',
|
||||
header_cleaned: '清头',
|
||||
|
||||
@@ -3,30 +3,86 @@
|
||||
:open="open"
|
||||
size="lg"
|
||||
title="立即清理请求记录"
|
||||
description="按现有分级保留策略主动清理请求记录,可选指定清理更早时间的数据。操作不可逆。"
|
||||
:persistent="submitting"
|
||||
description="默认按当前分级保留策略执行,也可以选择指定范围。操作不可逆。"
|
||||
:persistent="isLocked"
|
||||
@update:open="handleOpenChange"
|
||||
>
|
||||
<div class="px-4 sm:px-6 py-4 space-y-4">
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">
|
||||
清理方式
|
||||
</Label>
|
||||
<div class="mt-2 grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
<button
|
||||
v-for="item in modeOptions"
|
||||
:key="item.value"
|
||||
type="button"
|
||||
class="rounded-md border px-3 py-2 text-left text-sm transition-colors"
|
||||
:class="mode === item.value ? 'border-primary bg-primary/10 text-primary' : 'border-border bg-card hover:bg-muted/60'"
|
||||
:disabled="isLocked"
|
||||
@click="setMode(item.value)"
|
||||
>
|
||||
<span class="font-medium">{{ item.label }}</span>
|
||||
<span class="mt-1 block text-xs text-muted-foreground">{{ item.description }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="mode === 'older_than_days'">
|
||||
<Label
|
||||
for="manual-cleanup-older-than-days"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
清理 N 天前的记录(可选)
|
||||
清理 N 天前的记录
|
||||
</Label>
|
||||
<Input
|
||||
id="manual-cleanup-older-than-days"
|
||||
:model-value="olderThanDays ?? ''"
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="留空代表按当前保留策略"
|
||||
placeholder="例如 30"
|
||||
class="mt-1"
|
||||
:disabled="submitting"
|
||||
:disabled="isLocked"
|
||||
@update:model-value="handleDaysChange"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
留空代表按当前保留策略清理。填入数字代表清理 N 天前的记录;该值只能比策略更宽松,不会删除更新的数据。
|
||||
该值会与当前策略取更保守的时间点,不会清理比策略更新的数据。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">
|
||||
清理范围
|
||||
</Label>
|
||||
<div class="mt-2 grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<label
|
||||
v-for="target in targetOptions"
|
||||
:key="target.value"
|
||||
class="flex min-h-16 items-start gap-3 rounded-md border border-border bg-card px-3 py-2"
|
||||
>
|
||||
<Checkbox
|
||||
class="mt-0.5"
|
||||
:checked="selectedTargets.includes(target.value)"
|
||||
:disabled="isLocked"
|
||||
@update:checked="toggleTarget(target.value, $event)"
|
||||
/>
|
||||
<span>
|
||||
<span class="block text-sm font-medium">{{ target.label }}</span>
|
||||
<span class="block text-xs text-muted-foreground">{{ target.description }}</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<p
|
||||
v-if="mode === 'before_now'"
|
||||
class="mt-2 text-xs text-amber-600"
|
||||
>
|
||||
当前时刻之前模式只允许清理详细请求体和压缩请求体,不会清请求头或整条记录。
|
||||
</p>
|
||||
<p
|
||||
v-if="targetError"
|
||||
class="mt-2 text-xs text-destructive"
|
||||
>
|
||||
{{ targetError }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -39,7 +95,7 @@
|
||||
v-if="!previewLoading"
|
||||
type="button"
|
||||
class="text-xs text-muted-foreground hover:text-foreground"
|
||||
:disabled="submitting"
|
||||
:disabled="isLocked"
|
||||
@click="loadPreview"
|
||||
>
|
||||
刷新预估
|
||||
@@ -86,6 +142,45 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="activeTask || taskError"
|
||||
class="rounded-md border border-border bg-card px-4 py-3"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-medium">
|
||||
{{ activeTask?.message || '请求记录清理失败' }}
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-muted-foreground">
|
||||
{{ activeTask ? cleanupStatusLabel(activeTask.status) : taskError }}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
v-if="activeTask"
|
||||
:class="cleanupStatusClass(activeTask.status)"
|
||||
class="shrink-0 text-xs"
|
||||
>
|
||||
{{ cleanupStatusLabel(activeTask.status) }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="activeTask"
|
||||
class="mt-3 h-2 overflow-hidden rounded-full bg-muted"
|
||||
>
|
||||
<div
|
||||
class="h-full rounded-full bg-primary transition-all"
|
||||
:class="{ 'animate-pulse': activeTask.status === 'processing' }"
|
||||
:style="{ width: `${taskProgressPercent}%` }"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="activeTask"
|
||||
class="mt-2 text-xs text-muted-foreground"
|
||||
>
|
||||
{{ cleanupSummaryText(activeTask.summary) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label
|
||||
for="manual-cleanup-confirm-phrase"
|
||||
@@ -99,47 +194,59 @@
|
||||
class="mt-1"
|
||||
autocomplete="off"
|
||||
:placeholder="confirmPhrase"
|
||||
:disabled="submitting"
|
||||
:disabled="isLocked || isFinished"
|
||||
@update:model-value="typedPhrase = String($event)"
|
||||
@keydown.enter.prevent="maybeSubmitOnEnter"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
确认操作后会立刻执行清理,且不可撤销。
|
||||
确认后会在当前弹窗中显示执行状态,完成前不能关闭。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button
|
||||
v-if="!isFinished"
|
||||
variant="destructive"
|
||||
:disabled="!canSubmit"
|
||||
@click="handleConfirm"
|
||||
>
|
||||
{{ submitting ? '清理中…' : '确认清理' }}
|
||||
{{ isLocked ? '清理中…' : '确认清理' }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
:disabled="submitting"
|
||||
:disabled="isLocked"
|
||||
@click="handleCancel"
|
||||
>
|
||||
取消
|
||||
{{ isFinished ? '关闭' : '取消' }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
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 { adminApi, type ManualUsageCleanupPreview } from '@/api/admin'
|
||||
import {
|
||||
adminApi,
|
||||
type CleanupRunRecord,
|
||||
type ManualUsageCleanupPreview,
|
||||
type ManualUsageCleanupRequest,
|
||||
} from '@/api/admin'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import {
|
||||
MANUAL_USAGE_CLEANUP_CONFIRM_PHRASE,
|
||||
allowedTargetsForMode,
|
||||
defaultManualCleanupTargets,
|
||||
isConfirmPhraseMatched,
|
||||
normalizeManualCleanupTargets,
|
||||
normalizeOlderThanDaysInput,
|
||||
type ManualCleanupMode,
|
||||
type ManualCleanupTarget,
|
||||
} from './manualCleanupForm'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -148,50 +255,124 @@ const props = defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
confirm: [olderThanDays: number | undefined]
|
||||
'running-change': [value: boolean]
|
||||
completed: [task: CleanupRunRecord]
|
||||
}>()
|
||||
|
||||
const confirmPhrase = MANUAL_USAGE_CLEANUP_CONFIRM_PHRASE
|
||||
|
||||
const mode = ref<ManualCleanupMode>('policy')
|
||||
const olderThanDays = ref<number | null>(null)
|
||||
const selectedTargets = ref<ManualCleanupTarget[]>(defaultManualCleanupTargets('policy'))
|
||||
const targetsTouched = ref(false)
|
||||
const typedPhrase = ref('')
|
||||
const preview = ref<ManualUsageCleanupPreview | null>(null)
|
||||
const previewLoading = ref(false)
|
||||
const previewError = ref<string | null>(null)
|
||||
const submitting = ref(false)
|
||||
const activeTask = ref<CleanupRunRecord | null>(null)
|
||||
const taskError = ref<string | null>(null)
|
||||
|
||||
let previewDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let previewSeq = 0
|
||||
let taskPollTimer: ReturnType<typeof window.setInterval> | null = null
|
||||
|
||||
const normalizedPhrase = computed(() => typedPhrase.value)
|
||||
const modeOptions: Array<{ value: ManualCleanupMode; label: string; description: string }> = [
|
||||
{ value: 'policy', label: '按当前策略', description: '沿用页面上配置的保留天数' },
|
||||
{ value: 'older_than_days', label: '指定天数前', description: '在策略内取更保守时间点' },
|
||||
{ value: 'before_now', label: '当前时刻之前', description: '只清已选请求体内容' },
|
||||
]
|
||||
|
||||
const targetLabels: Record<ManualCleanupTarget, { label: string; description: string }> = {
|
||||
detail_body: { label: '详细请求体', description: '把详细 body 移入压缩/外置存储' },
|
||||
compressed_body: { label: '压缩请求体', description: '删除已压缩或外置的 body 内容' },
|
||||
headers: { label: '请求头', description: '清空请求/响应 headers 字段' },
|
||||
records: { label: '整条记录', description: '删除超过记录保留期的 usage 行' },
|
||||
}
|
||||
|
||||
const targetOptions = computed(() =>
|
||||
allowedTargetsForMode(mode.value).map(value => ({
|
||||
value,
|
||||
...targetLabels[value],
|
||||
}))
|
||||
)
|
||||
|
||||
const normalizedTargets = computed(() =>
|
||||
normalizeManualCleanupTargets(mode.value, selectedTargets.value)
|
||||
)
|
||||
|
||||
const targetError = computed(() => {
|
||||
if (normalizedTargets.value.length === 0) return '至少选择一个清理范围'
|
||||
return null
|
||||
})
|
||||
|
||||
const currentTaskRunning = computed(() => activeTask.value?.status === 'processing')
|
||||
const isLocked = computed(() => submitting.value || currentTaskRunning.value)
|
||||
const isFinished = computed(() =>
|
||||
activeTask.value?.status === 'completed' || activeTask.value?.status === 'failed'
|
||||
)
|
||||
|
||||
const canSubmit = computed(
|
||||
() =>
|
||||
!submitting.value &&
|
||||
!isLocked.value &&
|
||||
!isFinished.value &&
|
||||
!previewLoading.value &&
|
||||
isConfirmPhraseMatched(normalizedPhrase.value),
|
||||
!targetError.value &&
|
||||
modeIsValid.value &&
|
||||
isConfirmPhraseMatched(typedPhrase.value),
|
||||
)
|
||||
|
||||
const modeIsValid = computed(() => mode.value !== 'older_than_days' || olderThanDays.value !== null)
|
||||
|
||||
const taskProgressPercent = computed(() => {
|
||||
const task = activeTask.value
|
||||
if (!task) return 0
|
||||
if (task.status === 'completed') return 100
|
||||
if (task.status === 'failed') return 100
|
||||
const raw = task.summary?.progress_percent
|
||||
if (typeof raw === 'number' && Number.isFinite(raw) && raw > 0) {
|
||||
return Math.max(1, Math.min(99, Math.round(raw)))
|
||||
}
|
||||
return 12
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(isOpen) => {
|
||||
if (isOpen) {
|
||||
resetForm()
|
||||
void loadPreview()
|
||||
} else if (previewDebounceTimer) {
|
||||
clearTimeout(previewDebounceTimer)
|
||||
previewDebounceTimer = null
|
||||
} else {
|
||||
clearPreviewTimer()
|
||||
stopTaskPolling()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
function resetForm() {
|
||||
mode.value = 'policy'
|
||||
olderThanDays.value = null
|
||||
selectedTargets.value = defaultManualCleanupTargets('policy')
|
||||
targetsTouched.value = false
|
||||
typedPhrase.value = ''
|
||||
preview.value = null
|
||||
previewError.value = null
|
||||
previewLoading.value = false
|
||||
submitting.value = false
|
||||
activeTask.value = null
|
||||
taskError.value = null
|
||||
emit('running-change', false)
|
||||
}
|
||||
|
||||
function setMode(nextMode: ManualCleanupMode) {
|
||||
if (isLocked.value || mode.value === nextMode) return
|
||||
mode.value = nextMode
|
||||
olderThanDays.value = null
|
||||
selectedTargets.value = defaultManualCleanupTargets(nextMode)
|
||||
targetsTouched.value = false
|
||||
activeTask.value = null
|
||||
taskError.value = null
|
||||
schedulePreview()
|
||||
}
|
||||
|
||||
function handleDaysChange(value: string | number) {
|
||||
@@ -199,33 +380,59 @@ function handleDaysChange(value: string | number) {
|
||||
schedulePreview()
|
||||
}
|
||||
|
||||
function schedulePreview() {
|
||||
if (previewDebounceTimer) {
|
||||
clearTimeout(previewDebounceTimer)
|
||||
function toggleTarget(target: ManualCleanupTarget, checked: boolean) {
|
||||
targetsTouched.value = true
|
||||
activeTask.value = null
|
||||
taskError.value = null
|
||||
const current = new Set(selectedTargets.value)
|
||||
if (checked) {
|
||||
current.add(target)
|
||||
} else {
|
||||
current.delete(target)
|
||||
}
|
||||
selectedTargets.value = normalizeManualCleanupTargets(mode.value, Array.from(current))
|
||||
schedulePreview()
|
||||
}
|
||||
|
||||
function buildRequest(): ManualUsageCleanupRequest {
|
||||
const request: ManualUsageCleanupRequest = { mode: mode.value }
|
||||
if (mode.value === 'older_than_days' && olderThanDays.value !== null) {
|
||||
request.older_than_days = olderThanDays.value
|
||||
}
|
||||
if (targetsTouched.value) {
|
||||
request.targets = normalizedTargets.value
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
function schedulePreview() {
|
||||
clearPreviewTimer()
|
||||
previewDebounceTimer = setTimeout(() => {
|
||||
previewDebounceTimer = null
|
||||
void loadPreview()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function clearPreviewTimer() {
|
||||
if (previewDebounceTimer) {
|
||||
clearTimeout(previewDebounceTimer)
|
||||
previewDebounceTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPreview() {
|
||||
const seq = ++previewSeq
|
||||
previewLoading.value = true
|
||||
previewError.value = null
|
||||
try {
|
||||
const params: { older_than_days?: number } = {}
|
||||
if (olderThanDays.value !== null) {
|
||||
params.older_than_days = olderThanDays.value
|
||||
}
|
||||
const result = await adminApi.previewManualUsageCleanup(params)
|
||||
const result = await adminApi.previewManualUsageCleanup(buildRequest())
|
||||
if (seq === previewSeq) {
|
||||
preview.value = result
|
||||
}
|
||||
} catch (error) {
|
||||
if (seq === previewSeq) {
|
||||
preview.value = null
|
||||
previewError.value = parseApiError(error).message
|
||||
previewError.value = parseApiError(error)
|
||||
}
|
||||
} finally {
|
||||
if (seq === previewSeq) {
|
||||
@@ -235,14 +442,14 @@ async function loadPreview() {
|
||||
}
|
||||
|
||||
function handleOpenChange(value: boolean) {
|
||||
if (!value && submitting.value) {
|
||||
if (!value && isLocked.value) {
|
||||
return
|
||||
}
|
||||
emit('update:open', value)
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
if (submitting.value) return
|
||||
if (isLocked.value) return
|
||||
emit('update:open', false)
|
||||
}
|
||||
|
||||
@@ -255,14 +462,101 @@ function maybeSubmitOnEnter() {
|
||||
async function handleConfirm() {
|
||||
if (!canSubmit.value) return
|
||||
submitting.value = true
|
||||
taskError.value = null
|
||||
try {
|
||||
emit('confirm', olderThanDays.value ?? undefined)
|
||||
const response = await adminApi.runManualUsageCleanup(buildRequest())
|
||||
if ('detail' in response && response.detail === 'usage_cleanup_already_running') {
|
||||
taskError.value = response.message
|
||||
emit('running-change', false)
|
||||
return
|
||||
}
|
||||
activeTask.value = response.task
|
||||
emit('running-change', response.task.status === 'processing')
|
||||
if (response.task.status === 'processing') {
|
||||
startTaskPolling(response.task.id)
|
||||
} else {
|
||||
emit('completed', response.task)
|
||||
}
|
||||
} catch (error) {
|
||||
taskError.value = parseApiError(error)
|
||||
emit('running-change', false)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function startTaskPolling(taskId: string) {
|
||||
stopTaskPolling()
|
||||
void pollTask(taskId)
|
||||
taskPollTimer = window.setInterval(() => {
|
||||
void pollTask(taskId)
|
||||
}, 1_500)
|
||||
}
|
||||
|
||||
function stopTaskPolling() {
|
||||
if (taskPollTimer) {
|
||||
window.clearInterval(taskPollTimer)
|
||||
taskPollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
async function pollTask(taskId: string) {
|
||||
try {
|
||||
const response = await adminApi.getCleanupRuns()
|
||||
const task = response.items.find(item => item.id === taskId)
|
||||
if (!task) return
|
||||
activeTask.value = task
|
||||
const running = task.status === 'processing'
|
||||
emit('running-change', running)
|
||||
if (!running) {
|
||||
stopTaskPolling()
|
||||
emit('completed', task)
|
||||
void loadPreview()
|
||||
}
|
||||
} catch (error) {
|
||||
taskError.value = parseApiError(error)
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupStatusLabel(status: string): string {
|
||||
if (status === 'processing') return '执行中'
|
||||
if (status === 'failed') return '失败'
|
||||
return '完成'
|
||||
}
|
||||
|
||||
function cleanupStatusClass(status: string): string {
|
||||
if (status === 'processing') return 'text-amber-500'
|
||||
if (status === 'failed') return 'text-destructive'
|
||||
return 'text-emerald-500'
|
||||
}
|
||||
|
||||
function cleanupSummaryText(summary: Record<string, unknown>): string {
|
||||
const total = typeof summary.total === 'number' ? summary.total : null
|
||||
if (total !== null && total > 0) return `影响 ${total} 项`
|
||||
const entries = Object.entries(summary)
|
||||
.filter(([key, value]) => key !== 'progress_percent' && typeof value === 'number' && value > 0)
|
||||
.map(([key, value]) => `${summaryLabel(key)} ${value}`)
|
||||
return entries.length > 0 ? entries.join(' / ') : '等待后台返回结果'
|
||||
}
|
||||
|
||||
function summaryLabel(key: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
body_externalized: '详细体',
|
||||
legacy_body_refs_migrated: '迁移',
|
||||
body_cleaned: '清体',
|
||||
header_cleaned: '清头',
|
||||
keys_cleaned: 'Key',
|
||||
records_deleted: '删记录',
|
||||
}
|
||||
return labels[key] || key
|
||||
}
|
||||
|
||||
function formatCount(value: number): string {
|
||||
return value.toLocaleString()
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearPreviewTimer()
|
||||
stopTaskPolling()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
allowedTargetsForMode,
|
||||
defaultManualCleanupTargets,
|
||||
MANUAL_USAGE_CLEANUP_CONFIRM_PHRASE,
|
||||
isConfirmPhraseMatched,
|
||||
normalizeManualCleanupTargets,
|
||||
normalizeConfirmPhraseInput,
|
||||
normalizeOlderThanDaysInput,
|
||||
} from '../manualCleanupForm'
|
||||
@@ -61,4 +64,34 @@ describe('manualCleanupForm', () => {
|
||||
expect(normalizeOlderThanDaysInput('abc')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('cleanup targets', () => {
|
||||
it('keeps all ranges available for policy cleanup', () => {
|
||||
expect(allowedTargetsForMode('policy')).toEqual([
|
||||
'detail_body',
|
||||
'compressed_body',
|
||||
'headers',
|
||||
'records',
|
||||
])
|
||||
expect(defaultManualCleanupTargets('older_than_days')).toEqual([
|
||||
'detail_body',
|
||||
'compressed_body',
|
||||
'headers',
|
||||
'records',
|
||||
])
|
||||
})
|
||||
|
||||
it('limits before-now cleanup to body targets', () => {
|
||||
expect(allowedTargetsForMode('before_now')).toEqual([
|
||||
'detail_body',
|
||||
'compressed_body',
|
||||
])
|
||||
expect(normalizeManualCleanupTargets('before_now', [
|
||||
'detail_body',
|
||||
'headers',
|
||||
'compressed_body',
|
||||
'records',
|
||||
])).toEqual(['detail_body', 'compressed_body'])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const { errorMock, successMock } = vi.hoisted(() => ({
|
||||
errorMock: vi.fn(),
|
||||
successMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useToast', () => ({
|
||||
useToast: () => ({
|
||||
error: errorMock,
|
||||
success: successMock,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/admin', () => ({
|
||||
adminApi: {},
|
||||
}))
|
||||
|
||||
import { useConfigExportImport } from '../composables/useConfigExportImport'
|
||||
|
||||
function buildFileInputEvent(file: File): Event {
|
||||
return {
|
||||
target: {
|
||||
files: [file],
|
||||
value: 'selected.json',
|
||||
},
|
||||
} as unknown as Event
|
||||
}
|
||||
|
||||
function makeSizedFile(content: string, size: number): File {
|
||||
const file = new File([content], 'config.json', { type: 'application/json' })
|
||||
Object.defineProperty(file, 'size', { value: size })
|
||||
return file
|
||||
}
|
||||
|
||||
describe('useConfigExportImport file size limits', () => {
|
||||
beforeEach(() => {
|
||||
errorMock.mockReset()
|
||||
successMock.mockReset()
|
||||
})
|
||||
|
||||
it('accepts config import files larger than the old 10MB limit', async () => {
|
||||
const state = useConfigExportImport(ref({ site_name: 'Aether' }))
|
||||
const file = makeSizedFile(
|
||||
JSON.stringify({
|
||||
version: '1',
|
||||
exported_at: '2026-01-01T00:00:00.000Z',
|
||||
global_models: [],
|
||||
providers: [],
|
||||
}),
|
||||
11 * 1024 * 1024
|
||||
)
|
||||
|
||||
state.handleConfigFileSelect(buildFileInputEvent(file))
|
||||
await vi.waitFor(() => expect(state.importDialogOpen.value).toBe(true))
|
||||
|
||||
expect(errorMock).not.toHaveBeenCalledWith('文件大小不能超过 10MB')
|
||||
expect(state.importPreview.value).toEqual({
|
||||
version: '1',
|
||||
exported_at: '2026-01-01T00:00:00.000Z',
|
||||
global_models: [],
|
||||
providers: [],
|
||||
})
|
||||
expect(state.importDialogOpen.value).toBe(true)
|
||||
})
|
||||
|
||||
it('shows the updated config import limit when the file is too large', () => {
|
||||
const state = useConfigExportImport(ref({ site_name: 'Aether' }))
|
||||
const file = makeSizedFile('{}', 501 * 1024 * 1024)
|
||||
|
||||
state.handleConfigFileSelect(buildFileInputEvent(file))
|
||||
|
||||
expect(errorMock).toHaveBeenCalledWith('文件大小不能超过 500MB')
|
||||
})
|
||||
})
|
||||
@@ -13,9 +13,12 @@ import { parseApiError } from '@/utils/errorParser'
|
||||
import { log } from '@/utils/logger'
|
||||
import type { SystemConfig } from './useSystemConfig'
|
||||
|
||||
// 文件大小限制:聚合数据包含配置和用户数据,允许更大的备份文件。
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024
|
||||
const MAX_AGGREGATE_FILE_SIZE = 20 * 1024 * 1024
|
||||
// 文件大小限制:导出文件可能包含大量 Provider Key、模型和用户数据。
|
||||
const BYTES_PER_MB = 1024 * 1024
|
||||
const MAX_FILE_SIZE_MB = 500
|
||||
const MAX_AGGREGATE_FILE_SIZE_MB = 500
|
||||
const MAX_FILE_SIZE = MAX_FILE_SIZE_MB * BYTES_PER_MB
|
||||
const MAX_AGGREGATE_FILE_SIZE = MAX_AGGREGATE_FILE_SIZE_MB * BYTES_PER_MB
|
||||
|
||||
type JsonObject = Record<string, unknown>
|
||||
|
||||
@@ -49,6 +52,10 @@ function looksLikeAggregateExport(value: JsonObject): boolean {
|
||||
&& asJsonObject(value.user_data) != null
|
||||
}
|
||||
|
||||
function fileSizeLimitMessage(limitMb: number): string {
|
||||
return `文件大小不能超过 ${limitMb}MB`
|
||||
}
|
||||
|
||||
function downloadJson(data: unknown, filename: string) {
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
@@ -126,7 +133,7 @@ export function useConfigExportImport(systemConfig: { value: SystemConfig }) {
|
||||
if (!file) return
|
||||
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
error('文件大小不能超过 10MB')
|
||||
error(fileSizeLimitMessage(MAX_FILE_SIZE_MB))
|
||||
input.value = ''
|
||||
return
|
||||
}
|
||||
@@ -223,7 +230,7 @@ export function useConfigExportImport(systemConfig: { value: SystemConfig }) {
|
||||
if (!file) return
|
||||
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
error('文件大小不能超过 10MB')
|
||||
error(fileSizeLimitMessage(MAX_FILE_SIZE_MB))
|
||||
input.value = ''
|
||||
return
|
||||
}
|
||||
@@ -325,7 +332,7 @@ export function useConfigExportImport(systemConfig: { value: SystemConfig }) {
|
||||
if (!file) return
|
||||
|
||||
if (file.size > MAX_AGGREGATE_FILE_SIZE) {
|
||||
error('文件大小不能超过 20MB')
|
||||
error(fileSizeLimitMessage(MAX_AGGREGATE_FILE_SIZE_MB))
|
||||
input.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
export const MANUAL_USAGE_CLEANUP_CONFIRM_PHRASE = '确认清理'
|
||||
|
||||
export type ManualCleanupMode = 'policy' | 'older_than_days' | 'before_now'
|
||||
export type ManualCleanupTarget = 'detail_body' | 'compressed_body' | 'headers' | 'records'
|
||||
|
||||
export const MANUAL_CLEANUP_TARGETS: ManualCleanupTarget[] = [
|
||||
'detail_body',
|
||||
'compressed_body',
|
||||
'headers',
|
||||
'records',
|
||||
]
|
||||
|
||||
export const BEFORE_NOW_ALLOWED_TARGETS: ManualCleanupTarget[] = [
|
||||
'detail_body',
|
||||
'compressed_body',
|
||||
]
|
||||
|
||||
export function normalizeConfirmPhraseInput(raw: string): string {
|
||||
return raw.replace(/\r?\n/g, '').trim()
|
||||
}
|
||||
@@ -14,3 +29,21 @@ export function normalizeOlderThanDaysInput(raw: string | number | null | undefi
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) return null
|
||||
return Math.floor(parsed)
|
||||
}
|
||||
|
||||
export function allowedTargetsForMode(mode: ManualCleanupMode): ManualCleanupTarget[] {
|
||||
return mode === 'before_now' ? BEFORE_NOW_ALLOWED_TARGETS : MANUAL_CLEANUP_TARGETS
|
||||
}
|
||||
|
||||
export function normalizeManualCleanupTargets(
|
||||
mode: ManualCleanupMode,
|
||||
targets: ManualCleanupTarget[],
|
||||
): ManualCleanupTarget[] {
|
||||
const allowed = new Set(allowedTargetsForMode(mode))
|
||||
return targets.filter((target, index) =>
|
||||
allowed.has(target) && targets.indexOf(target) === index
|
||||
)
|
||||
}
|
||||
|
||||
export function defaultManualCleanupTargets(mode: ManualCleanupMode): ManualCleanupTarget[] {
|
||||
return allowedTargetsForMode(mode)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user