feat: add Sora admin backend and fix type inconsistencies
Backend changes: - Add SoraHandler for admin Sora management APIs - GET /api/v1/admin/sora/stats - system statistics - GET /api/v1/admin/sora/users - user storage stats - GET /api/v1/admin/sora/generations - generation records - DELETE /api/v1/admin/sora/users/:id/storage - clear user storage - Add sora_storage_quota_bytes to AdminUser DTO - Add SoraStorageQuotaBytes to UpdateUserInput for admin user updates - Add comprehensive tests for SoraHandler Frontend changes: - Add soraAdminAPI for Sora management - Add sora_storage_quota_bytes and sora_storage_used_bytes to AdminUser type - Add Sora storage quota field to UserEditModal (GB unit) - Fix UsageLog type: add media_type, fix duration_ms to optional - Fix AdminUsageLog type: add channel_id, billing_tier Test fixes: - Add window.matchMedia mock to AccountUsageCell.spec.ts - Add tlsFingerprintProfileAPI mock to EditAccountModal.spec.ts - Fix loadTLSProfiles function order in EditAccountModal.vue - Fix translation key references in AccountStatusIndicator.spec.ts
This commit is contained in:
@@ -27,6 +27,7 @@ import backupAPI from './backup'
|
||||
import tlsFingerprintProfileAPI from './tlsFingerprintProfile'
|
||||
import channelsAPI from './channels'
|
||||
import adminPaymentAPI from './payment'
|
||||
import soraAdminAPI from './sora'
|
||||
|
||||
/**
|
||||
* Unified admin API object for convenient access
|
||||
@@ -55,7 +56,8 @@ export const adminAPI = {
|
||||
backup: backupAPI,
|
||||
tlsFingerprintProfiles: tlsFingerprintProfileAPI,
|
||||
channels: channelsAPI,
|
||||
payment: adminPaymentAPI
|
||||
payment: adminPaymentAPI,
|
||||
sora: soraAdminAPI
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -82,7 +84,8 @@ export {
|
||||
backupAPI,
|
||||
tlsFingerprintProfileAPI,
|
||||
channelsAPI,
|
||||
adminPaymentAPI
|
||||
adminPaymentAPI,
|
||||
soraAdminAPI
|
||||
}
|
||||
|
||||
export default adminAPI
|
||||
|
||||
95
frontend/src/api/admin/sora.ts
Normal file
95
frontend/src/api/admin/sora.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Admin Sora API
|
||||
* 管理员 Sora 统计和用户配额管理接口
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client'
|
||||
import type { BasePaginationResponse } from '@/types'
|
||||
|
||||
export interface SoraUserStats {
|
||||
user_id: number
|
||||
username: string
|
||||
email: string
|
||||
quota_bytes: number
|
||||
used_bytes: number
|
||||
available_bytes: number
|
||||
quota_source: string
|
||||
generations_count: number
|
||||
active_count: number
|
||||
total_file_size_bytes: number
|
||||
}
|
||||
|
||||
export interface SoraSystemStats {
|
||||
total_users: number
|
||||
total_generations: number
|
||||
total_storage_bytes: number
|
||||
active_generations: number
|
||||
by_status: Record<string, number>
|
||||
by_model: Record<string, number>
|
||||
}
|
||||
|
||||
export interface SoraGenerationAdmin {
|
||||
id: number
|
||||
user_id: number
|
||||
username: string
|
||||
email: string
|
||||
model: string
|
||||
prompt: string
|
||||
media_type: string
|
||||
status: string
|
||||
storage_type: string
|
||||
media_url: string
|
||||
file_size_bytes: number
|
||||
error_message: string
|
||||
created_at: string
|
||||
completed_at: string | null
|
||||
}
|
||||
|
||||
const soraAdminAPI = {
|
||||
/**
|
||||
* 获取 Sora 系统统计
|
||||
*/
|
||||
async getSystemStats(): Promise<SoraSystemStats> {
|
||||
const { data } = await apiClient.get<{ data: SoraSystemStats }>('/admin/sora/stats')
|
||||
return data.data
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取用户 Sora 使用统计列表
|
||||
*/
|
||||
async listUserStats(params?: {
|
||||
page?: number
|
||||
page_size?: number
|
||||
search?: string
|
||||
sort_by?: string
|
||||
sort_order?: string
|
||||
}): Promise<BasePaginationResponse<SoraUserStats>> {
|
||||
const { data } = await apiClient.get<BasePaginationResponse<SoraUserStats>>('/admin/sora/users', { params })
|
||||
return data
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取 Sora 生成记录列表(管理员视角)
|
||||
*/
|
||||
async listGenerations(params?: {
|
||||
page?: number
|
||||
page_size?: number
|
||||
user_id?: number
|
||||
status?: string
|
||||
model?: string
|
||||
sort_by?: string
|
||||
sort_order?: string
|
||||
}): Promise<BasePaginationResponse<SoraGenerationAdmin>> {
|
||||
const { data } = await apiClient.get<BasePaginationResponse<SoraGenerationAdmin>>('/admin/sora/generations', { params })
|
||||
return data
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除用户的 Sora 存储空间(管理员操作)
|
||||
*/
|
||||
async clearUserStorage(userId: number): Promise<void> {
|
||||
await apiClient.delete(`/admin/sora/users/${userId}/storage`)
|
||||
},
|
||||
}
|
||||
|
||||
export default soraAdminAPI
|
||||
@@ -2294,6 +2294,16 @@ const syncFormFromAccount = (newAccount: Account | null) => {
|
||||
editApiKey.value = ''
|
||||
}
|
||||
|
||||
// loadTLSProfiles must be defined before the watch that uses it
|
||||
const loadTLSProfiles = async () => {
|
||||
try {
|
||||
const profiles = await adminAPI.tlsFingerprintProfiles.list()
|
||||
tlsFingerprintProfiles.value = profiles.map(p => ({ id: p.id, name: p.name }))
|
||||
} catch {
|
||||
tlsFingerprintProfiles.value = []
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
[() => props.show, () => props.account],
|
||||
([show, newAccount], [wasShow, previousAccount]) => {
|
||||
@@ -2308,15 +2318,6 @@ watch(
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const loadTLSProfiles = async () => {
|
||||
try {
|
||||
const profiles = await adminAPI.tlsFingerprintProfiles.list()
|
||||
tlsFingerprintProfiles.value = profiles.map(p => ({ id: p.id, name: p.name }))
|
||||
} catch {
|
||||
tlsFingerprintProfiles.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// Model mapping helpers
|
||||
const addModelMapping = () => {
|
||||
modelMappings.value.push({ from: '', to: '' })
|
||||
|
||||
@@ -122,7 +122,7 @@ describe('AccountStatusIndicator', () => {
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('account.creditsExhausted')
|
||||
expect(wrapper.text()).toContain('admin.accounts.status.creditsExhausted')
|
||||
})
|
||||
|
||||
it('模型限流 + overages 启用 + AICredits key 生效 → 普通限流样式(积分耗尽,无 ⚡)', () => {
|
||||
@@ -157,6 +157,6 @@ describe('AccountStatusIndicator', () => {
|
||||
expect(wrapper.text()).toContain('CSon45')
|
||||
expect(wrapper.text()).not.toContain('⚡')
|
||||
// AICredits 积分耗尽状态应显示
|
||||
expect(wrapper.text()).toContain('account.creditsExhausted')
|
||||
expect(wrapper.text()).toContain('admin.accounts.status.creditsExhausted')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,21 @@ import { flushPromises, mount } from '@vue/test-utils'
|
||||
import AccountUsageCell from '../AccountUsageCell.vue'
|
||||
import type { Account } from '@/types'
|
||||
|
||||
// Mock window.matchMedia for responsive hooks
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
})
|
||||
|
||||
const { getUsage } = vi.hoisted(() => ({
|
||||
getUsage: vi.fn()
|
||||
}))
|
||||
@@ -193,7 +208,7 @@ describe('AccountUsageCell', () => {
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(getUsage).toHaveBeenCalledWith(2000)
|
||||
expect(getUsage).toHaveBeenCalledWith(2000, undefined)
|
||||
expect(wrapper.text()).toContain('5h|15|300')
|
||||
expect(wrapper.text()).toContain('7d|77|300')
|
||||
})
|
||||
@@ -254,7 +269,7 @@ describe('AccountUsageCell', () => {
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(getUsage).toHaveBeenCalledWith(2001)
|
||||
expect(getUsage).toHaveBeenCalledWith(2001, undefined)
|
||||
// 单一数据源:始终使用 /usage API 返回值,忽略 codex 快照
|
||||
expect(wrapper.text()).toContain('5h|18|900')
|
||||
expect(wrapper.text()).toContain('7d|36|900')
|
||||
@@ -325,7 +340,7 @@ describe('AccountUsageCell', () => {
|
||||
|
||||
// 手动刷新再拉一次
|
||||
expect(getUsage).toHaveBeenCalledTimes(2)
|
||||
expect(getUsage).toHaveBeenCalledWith(2010)
|
||||
expect(getUsage).toHaveBeenCalledWith(2010, undefined)
|
||||
// 单一数据源:始终使用 /usage API 值
|
||||
expect(wrapper.text()).toContain('5h|18|900')
|
||||
})
|
||||
@@ -380,7 +395,7 @@ describe('AccountUsageCell', () => {
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(getUsage).toHaveBeenCalledWith(2002)
|
||||
expect(getUsage).toHaveBeenCalledWith(2002, undefined)
|
||||
expect(wrapper.text()).toContain('5h|0|27700')
|
||||
expect(wrapper.text()).toContain('7d|0|27700')
|
||||
})
|
||||
@@ -512,7 +527,7 @@ describe('AccountUsageCell', () => {
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(getUsage).toHaveBeenCalledWith(2004)
|
||||
expect(getUsage).toHaveBeenCalledWith(2004, undefined)
|
||||
expect(wrapper.text()).toContain('5h|100|106540000')
|
||||
expect(wrapper.text()).toContain('7d|100|106540000')
|
||||
})
|
||||
|
||||
@@ -34,6 +34,12 @@ vi.mock('@/api/admin/accounts', () => ({
|
||||
getAntigravityDefaultModelMapping: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/api/admin/tlsFingerprintProfile', () => ({
|
||||
tlsFingerprintProfileAPI: {
|
||||
list: vi.fn().mockResolvedValue({ items: [] })
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', async () => {
|
||||
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
|
||||
return {
|
||||
|
||||
@@ -37,6 +37,11 @@
|
||||
<label class="input-label">{{ t('admin.users.columns.concurrency') }}</label>
|
||||
<input v-model.number="form.concurrency" type="number" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.users.soraStorageQuota') }}</label>
|
||||
<input v-model.number="form.soraStorageQuotaGB" type="number" min="0" step="0.1" class="input" />
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.users.soraStorageQuotaHint') }}</p>
|
||||
</div>
|
||||
<UserAttributeForm v-model="form.customAttributes" :user-id="user?.id" />
|
||||
</form>
|
||||
<template #footer>
|
||||
@@ -66,11 +71,11 @@ const emit = defineEmits(['close', 'success'])
|
||||
const { t } = useI18n(); const appStore = useAppStore(); const { copyToClipboard } = useClipboard()
|
||||
|
||||
const submitting = ref(false); const passwordCopied = ref(false)
|
||||
const form = reactive({ email: '', password: '', username: '', notes: '', concurrency: 1, customAttributes: {} as UserAttributeValuesMap })
|
||||
const form = reactive({ email: '', password: '', username: '', notes: '', concurrency: 1, soraStorageQuotaGB: 0, customAttributes: {} as UserAttributeValuesMap })
|
||||
|
||||
watch(() => props.user, (u) => {
|
||||
if (u) {
|
||||
Object.assign(form, { email: u.email, password: '', username: u.username || '', notes: u.notes || '', concurrency: u.concurrency, customAttributes: {} })
|
||||
Object.assign(form, { email: u.email, password: '', username: u.username || '', notes: u.notes || '', concurrency: u.concurrency, soraStorageQuotaGB: Math.round((u.sora_storage_quota_bytes || 0) / (1024 * 1024 * 1024) * 10) / 10, customAttributes: {} })
|
||||
passwordCopied.value = false
|
||||
}
|
||||
}, { immediate: true })
|
||||
@@ -97,7 +102,7 @@ const handleUpdateUser = async () => {
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
const data: any = { email: form.email, username: form.username, notes: form.notes, concurrency: form.concurrency }
|
||||
const data: any = { email: form.email, username: form.username, notes: form.notes, concurrency: form.concurrency, sora_storage_quota_bytes: Math.round(form.soraStorageQuotaGB * 1024 * 1024 * 1024) }
|
||||
if (form.password.trim()) data.password = form.password.trim()
|
||||
await adminAPI.users.update(props.user.id, data)
|
||||
if (Object.keys(form.customAttributes).length > 0) await adminAPI.userAttributes.updateUserAttributeValues(props.user.id, form.customAttributes)
|
||||
|
||||
@@ -45,6 +45,10 @@ export interface AdminUser extends User {
|
||||
group_rates?: Record<number, number>
|
||||
// 当前并发数(仅管理员列表接口返回)
|
||||
current_concurrency?: number
|
||||
// Sora 存储配额(单位:字节,0 表示使用分组或系统默认配额)
|
||||
sora_storage_quota_bytes: number
|
||||
// Sora 存储已用字节
|
||||
sora_storage_used_bytes: number
|
||||
}
|
||||
|
||||
export interface LoginRequest {
|
||||
@@ -1016,12 +1020,13 @@ export interface UsageLog {
|
||||
request_type?: UsageRequestType
|
||||
stream: boolean
|
||||
openai_ws_mode?: boolean
|
||||
duration_ms: number
|
||||
duration_ms: number | null
|
||||
first_token_ms: number | null
|
||||
|
||||
// 图片生成字段
|
||||
image_count: number
|
||||
image_size: string | null
|
||||
media_type?: string | null
|
||||
|
||||
// User-Agent
|
||||
user_agent: string | null
|
||||
@@ -1049,6 +1054,12 @@ export interface AdminUsageLog extends UsageLog {
|
||||
upstream_model?: string | null
|
||||
model_mapping_chain?: string | null
|
||||
|
||||
// 渠道 ID
|
||||
channel_id?: number | null
|
||||
|
||||
// 计费层级标签(per_request/image 模式)
|
||||
billing_tier?: string | null
|
||||
|
||||
// 账号计费倍率(仅管理员可见)
|
||||
account_rate_multiplier?: number | null
|
||||
|
||||
|
||||
Reference in New Issue
Block a user