mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
Merge origin/main into worktree/brave-valley-9578
Resolve conflict in api_key_repo.go: keep both ListAllByUserID (this PR) and attachLastUsedIPs/latestUsageLogIPs (main), and have ListAllByUserID attach last-used IPs so the current_concurrency sort path keeps the last_used_ip column populated.
This commit is contained in:
@@ -560,7 +560,9 @@ export async function syncFromCrs(params: {
|
||||
action: string
|
||||
error?: string
|
||||
}>
|
||||
}>('/admin/accounts/sync/crs', params)
|
||||
}>('/admin/accounts/sync/crs', params, {
|
||||
timeout: 180000 // 180s timeout: sync refreshes each existing account's OAuth token serially
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
|
||||
@@ -205,7 +205,9 @@ apiClient.interceptors.response.use(
|
||||
const refreshResponse = await axios.post(
|
||||
`${getAPIBaseURL()}/auth/refresh`,
|
||||
{ refresh_token: refreshToken },
|
||||
{ headers: { 'Content-Type': 'application/json' } }
|
||||
// 显式设置超时:裸 axios 默认无限等待,若刷新请求挂起会导致 isRefreshing
|
||||
// 永远为 true,所有排队的 401 重试请求永久卡死,页面 loading 无法恢复。
|
||||
{ headers: { 'Content-Type': 'application/json' }, timeout: 30000 }
|
||||
)
|
||||
|
||||
const refreshData = refreshResponse.data as ApiResponse<{
|
||||
|
||||
@@ -107,7 +107,7 @@ describe('BulkEditAccountModal', () => {
|
||||
expect(mappingTab).toBeTruthy()
|
||||
await mappingTab!.trigger('click')
|
||||
|
||||
expect(wrapper.text()).toContain('3.1-Flash-Image passthrough')
|
||||
expect(wrapper.text()).toContain('3.1-Flash-Image透传')
|
||||
expect(wrapper.text()).toContain('3-Pro-Image→3.1')
|
||||
expect(wrapper.text()).not.toContain('GPT-5.3 Codex Spark')
|
||||
})
|
||||
|
||||
@@ -294,7 +294,8 @@ const billingModeOptions = ref<SelectOption[]>([
|
||||
{ value: null, label: t('admin.usage.allBillingModes') },
|
||||
{ value: 'token', label: t('admin.usage.billingModeToken') },
|
||||
{ value: 'per_request', label: t('admin.usage.billingModePerRequest') },
|
||||
{ value: 'image', label: t('admin.usage.billingModeImage') }
|
||||
{ value: 'image', label: t('admin.usage.billingModeImage') },
|
||||
{ value: 'video', label: t('admin.usage.billingModeVideo') }
|
||||
])
|
||||
|
||||
const emitChange = () => emit('change')
|
||||
|
||||
@@ -249,6 +249,7 @@ import LocaleSwitcher from '@/components/common/LocaleSwitcher.vue'
|
||||
import SubscriptionProgressMini from '@/components/common/SubscriptionProgressMini.vue'
|
||||
import AnnouncementBell from '@/components/common/AnnouncementBell.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { sanitizeUrl } from '@/utils/url'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -262,7 +263,7 @@ const user = computed(() => authStore.user)
|
||||
const dropdownOpen = ref(false)
|
||||
const dropdownRef = ref<HTMLElement | null>(null)
|
||||
const contactInfo = computed(() => appStore.contactInfo)
|
||||
const docUrl = computed(() => appStore.docUrl)
|
||||
const docUrl = computed(() => sanitizeUrl(appStore.docUrl))
|
||||
const avatarUrl = computed(() => user.value?.avatar_url?.trim() || '')
|
||||
const availableBalance = computed(() => Number(user.value?.balance || 0))
|
||||
const frozenBalance = computed(() => Number(user.value?.frozen_balance || 0))
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const dir = dirname(fileURLToPath(import.meta.url))
|
||||
const headerSource = readFileSync(resolve(dir, '../AppHeader.vue'), 'utf8')
|
||||
const homeViewSource = readFileSync(resolve(dir, '../../../views/HomeView.vue'), 'utf8')
|
||||
const keyUsageViewSource = readFileSync(resolve(dir, '../../../views/KeyUsageView.vue'), 'utf8')
|
||||
|
||||
describe('doc_url sanitization', () => {
|
||||
it('AppHeader imports sanitizeUrl', () => {
|
||||
expect(headerSource).toContain("import { sanitizeUrl } from '@/utils/url'")
|
||||
})
|
||||
|
||||
it('AppHeader applies sanitizeUrl to docUrl', () => {
|
||||
expect(headerSource).toContain('sanitizeUrl(appStore.docUrl)')
|
||||
})
|
||||
|
||||
it('HomeView imports sanitizeUrl', () => {
|
||||
expect(homeViewSource).toContain("import { sanitizeUrl } from '@/utils/url'")
|
||||
})
|
||||
|
||||
it('HomeView applies sanitizeUrl to docUrl', () => {
|
||||
expect(homeViewSource).toContain('sanitizeUrl(appStore.cachedPublicSettings?.doc_url || appStore.docUrl')
|
||||
})
|
||||
|
||||
it('KeyUsageView imports sanitizeUrl', () => {
|
||||
expect(keyUsageViewSource).toContain("import { sanitizeUrl } from '@/utils/url'")
|
||||
})
|
||||
|
||||
it('KeyUsageView applies sanitizeUrl to docUrl', () => {
|
||||
expect(keyUsageViewSource).toContain('sanitizeUrl(appStore.cachedPublicSettings?.doc_url || appStore.docUrl')
|
||||
})
|
||||
})
|
||||
@@ -275,22 +275,33 @@ async function tryRecoverPendingOrder(order: PaymentOrder): Promise<PaymentOrder
|
||||
}
|
||||
}
|
||||
|
||||
let pollInFlight = false
|
||||
async function pollStatus() {
|
||||
if (!props.orderId || outcome.value) return
|
||||
let order = await paymentStore.pollOrderStatus(props.orderId)
|
||||
if (!order) return
|
||||
order = await tryRecoverPendingOrder(order)
|
||||
if (isSuccessStatus(order.status)) {
|
||||
cleanup()
|
||||
paidOrder.value = order
|
||||
setOutcome('success')
|
||||
emit('success')
|
||||
} else if (order.status === 'CANCELLED') {
|
||||
cleanup()
|
||||
setOutcome('cancelled')
|
||||
} else if (order.status === 'EXPIRED' || order.status === 'FAILED') {
|
||||
cleanup()
|
||||
setOutcome('expired')
|
||||
// 防重入:接口(含 verifyOrder 二次确认)响应慢于 3 秒轮询间隔时避免并发重叠请求。
|
||||
if (pollInFlight) return
|
||||
pollInFlight = true
|
||||
try {
|
||||
let order = await paymentStore.pollOrderStatus(props.orderId)
|
||||
if (!order) return
|
||||
// 已进入终态则不再处理迟到的响应。
|
||||
if (outcome.value) return
|
||||
order = await tryRecoverPendingOrder(order)
|
||||
if (outcome.value) return
|
||||
if (isSuccessStatus(order.status)) {
|
||||
cleanup()
|
||||
paidOrder.value = order
|
||||
setOutcome('success')
|
||||
emit('success')
|
||||
} else if (order.status === 'CANCELLED') {
|
||||
cleanup()
|
||||
setOutcome('cancelled')
|
||||
} else if (order.status === 'EXPIRED' || order.status === 'FAILED') {
|
||||
cleanup()
|
||||
setOutcome('expired')
|
||||
}
|
||||
} finally {
|
||||
pollInFlight = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,33 @@ describe('useModelWhitelist', () => {
|
||||
expect(getModelsByPlatform('antigravity')).toContain('claude-opus-4-8')
|
||||
})
|
||||
|
||||
it('xAI 模型列表包含 Grok 4.5 官方模型和别名', () => {
|
||||
const models = getModelsByPlatform('grok')
|
||||
|
||||
expect(models).toContain('grok-4.5')
|
||||
expect(models).toContain('grok-4.5-latest')
|
||||
expect(models).toContain('grok-build-latest')
|
||||
})
|
||||
|
||||
it('combined 模式支持 Grok 4.5 官方别名映射', () => {
|
||||
const mapping = buildModelMappingObject(
|
||||
'combined',
|
||||
['grok-4.5'],
|
||||
[
|
||||
{ from: 'grok-latest', to: 'grok-4.5' },
|
||||
{ from: 'grok-4.5-latest', to: 'grok-4.5' },
|
||||
{ from: 'grok-build-latest', to: 'grok-4.5' }
|
||||
]
|
||||
)
|
||||
|
||||
expect(mapping).toEqual({
|
||||
'grok-4.5': 'grok-4.5',
|
||||
'grok-latest': 'grok-4.5',
|
||||
'grok-4.5-latest': 'grok-4.5',
|
||||
'grok-build-latest': 'grok-4.5'
|
||||
})
|
||||
})
|
||||
|
||||
it('gemini 模型列表包含原生生图模型', () => {
|
||||
const models = getModelsByPlatform('gemini')
|
||||
|
||||
|
||||
@@ -135,6 +135,7 @@ const metaModels = [
|
||||
|
||||
// xAI Grok
|
||||
const xaiModels = [
|
||||
'grok-4.5',
|
||||
'grok-4.3',
|
||||
'grok-build-0.1',
|
||||
'grok-composer-2.5-fast',
|
||||
@@ -143,7 +144,9 @@ const xaiModels = [
|
||||
'grok-4.20-multi-agent-0309',
|
||||
'grok',
|
||||
'grok-latest',
|
||||
'grok-4.5-latest',
|
||||
'grok-build',
|
||||
'grok-build-latest',
|
||||
'grok-composer',
|
||||
'grok-4.20-reasoning',
|
||||
'grok-4.20-non-reasoning',
|
||||
@@ -296,9 +299,12 @@ const geminiPresetMappings = [
|
||||
]
|
||||
|
||||
const grokPresetMappings = [
|
||||
{ label: 'Grok 4.5', from: 'grok-4.5', to: 'grok-4.5', color: 'bg-slate-100 text-slate-700 hover:bg-slate-200 dark:bg-slate-800/50 dark:text-slate-300' },
|
||||
{ label: 'Grok 4.3', from: 'grok-4.3', to: 'grok-4.3', color: 'bg-slate-100 text-slate-700 hover:bg-slate-200 dark:bg-slate-800/50 dark:text-slate-300' },
|
||||
{ label: 'Grok Latest', from: 'grok-latest', to: 'grok-4.3', color: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400' },
|
||||
{ label: 'Grok Latest', from: 'grok-latest', to: 'grok-4.5', color: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400' },
|
||||
{ label: '4.5 Latest', from: 'grok-4.5-latest', to: 'grok-4.5', color: 'bg-lime-100 text-lime-700 hover:bg-lime-200 dark:bg-lime-900/30 dark:text-lime-400' },
|
||||
{ label: 'Build 0.1', from: 'grok-build', to: 'grok-build-0.1', color: 'bg-cyan-100 text-cyan-700 hover:bg-cyan-200 dark:bg-cyan-900/30 dark:text-cyan-400' },
|
||||
{ label: 'Build Latest', from: 'grok-build-latest', to: 'grok-4.5', color: 'bg-teal-100 text-teal-700 hover:bg-teal-200 dark:bg-teal-900/30 dark:text-teal-400' },
|
||||
{ label: 'Composer 2.5', from: 'grok-composer', to: 'grok-composer-2.5-fast', color: 'bg-teal-100 text-teal-700 hover:bg-teal-200 dark:bg-teal-900/30 dark:text-teal-400' },
|
||||
{ label: '4.20 Reasoning', from: 'grok-4.20-reasoning', to: 'grok-4.20-0309-reasoning', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' },
|
||||
{ label: '4.20 Non Reasoning', from: 'grok-4.20-non-reasoning', to: 'grok-4.20-0309-non-reasoning', color: 'bg-violet-100 text-violet-700 hover:bg-violet-200 dark:bg-violet-900/30 dark:text-violet-400' },
|
||||
|
||||
@@ -26,6 +26,7 @@ export default {
|
||||
billingModeToken: 'Per Token',
|
||||
billingModePerRequest: 'Per Request',
|
||||
billingModeImage: 'Per Image',
|
||||
billingModeVideo: 'Per Video',
|
||||
inputPrice: 'Input',
|
||||
outputPrice: 'Output',
|
||||
cacheWritePrice: 'Cache Write',
|
||||
|
||||
@@ -841,6 +841,17 @@ export default {
|
||||
finalPricePreview: 'Final per-image price preview',
|
||||
notConfigured: 'Not configured'
|
||||
},
|
||||
videoPricing: {
|
||||
title: 'Video Generation Pricing',
|
||||
description:
|
||||
'Configure Grok video generation prices in USD per second of output video. Leave empty to use the default per-second rates (grok-imagine-video: $0.05/s 480p, $0.07/s 720p; video-1.5: $0.08/s 480p, $0.14/s 720p, $0.25/s 1080p).',
|
||||
independentMultiplier: 'Use independent video multiplier',
|
||||
videoMultiplier: 'Video multiplier',
|
||||
modeHint:
|
||||
'Videos are billed per second: per-second price × duration (1-15s, default 8s). By default the current effective group multiplier applies; independent mode uses the video multiplier instead.',
|
||||
finalPricePreview: 'Final per-second price preview',
|
||||
notConfigured: 'Not configured'
|
||||
},
|
||||
peakRate: {
|
||||
enable: 'Enable peak rate multiplier',
|
||||
peakStart: 'Peak start',
|
||||
|
||||
@@ -477,6 +477,7 @@ export default {
|
||||
billingModeToken: 'Token',
|
||||
billingModePerRequest: 'Per Request',
|
||||
billingModeImage: 'Image',
|
||||
billingModeVideo: 'Video',
|
||||
allBillingModes: 'All Billing Modes',
|
||||
ipAddress: 'IP',
|
||||
clickToViewBalance: 'Click to view balance history',
|
||||
|
||||
@@ -124,6 +124,7 @@ export default {
|
||||
total: 'Last 30d',
|
||||
quota: 'Quota',
|
||||
lastUsedAt: 'Last Used',
|
||||
lastUsedIP: 'Last Used IP',
|
||||
useKey: 'Use Key',
|
||||
useKeyModal: {
|
||||
title: 'Use API Key',
|
||||
@@ -477,6 +478,7 @@ export default {
|
||||
billingModeToken: 'Per Token',
|
||||
billingModePerRequest: 'Per Request',
|
||||
billingModeImage: 'Per Image',
|
||||
billingModeVideo: 'Per Video',
|
||||
inputPrice: 'Input',
|
||||
outputPrice: 'Output',
|
||||
cacheWritePrice: 'Cache Write',
|
||||
|
||||
@@ -26,6 +26,7 @@ export default {
|
||||
billingModeToken: '按 Token',
|
||||
billingModePerRequest: '按次',
|
||||
billingModeImage: '按图片',
|
||||
billingModeVideo: '按视频',
|
||||
inputPrice: '输入',
|
||||
outputPrice: '输出',
|
||||
cacheWritePrice: '缓存写入',
|
||||
|
||||
@@ -919,6 +919,17 @@ export default {
|
||||
finalPricePreview: '最终单张价格预览',
|
||||
notConfigured: '未配置'
|
||||
},
|
||||
videoPricing: {
|
||||
title: '视频生成计费',
|
||||
description:
|
||||
'配置 Grok 视频生成的每秒单价(USD/秒),留空则使用默认每秒价(grok-imagine-video:480p $0.05/s、720p $0.07/s;video-1.5:480p $0.08/s、720p $0.14/s、1080p $0.25/s)',
|
||||
independentMultiplier: '视频倍率独立',
|
||||
videoMultiplier: '视频独立倍率',
|
||||
modeHint:
|
||||
'视频按秒计费:费用 = 每秒价格 × 时长(1-15 秒,未指定默认 8 秒)。默认叠加当前分组有效倍率;开启独立倍率后改用视频独立倍率。',
|
||||
finalPricePreview: '最终每秒价格预览',
|
||||
notConfigured: '未配置'
|
||||
},
|
||||
peakRate: {
|
||||
enable: '启用高峰倍率',
|
||||
peakStart: '高峰开始',
|
||||
|
||||
@@ -538,6 +538,7 @@ export default {
|
||||
billingModeToken: '按量',
|
||||
billingModePerRequest: '按次',
|
||||
billingModeImage: '按次(图片)',
|
||||
billingModeVideo: '按次(视频)',
|
||||
allBillingModes: '全部计费模式',
|
||||
ipAddress: 'IP',
|
||||
clickToViewBalance: '点击查看充值记录',
|
||||
|
||||
@@ -124,6 +124,7 @@ export default {
|
||||
total: '近30天',
|
||||
quota: '额度',
|
||||
lastUsedAt: '上次使用时间',
|
||||
lastUsedIP: '最近使用 IP',
|
||||
useKey: '使用密钥',
|
||||
useKeyModal: {
|
||||
title: '使用 API 密钥',
|
||||
@@ -482,6 +483,7 @@ export default {
|
||||
billingModeToken: '按 Token',
|
||||
billingModePerRequest: '按次',
|
||||
billingModeImage: '按图片',
|
||||
billingModeVideo: '按视频',
|
||||
inputPrice: '输入',
|
||||
outputPrice: '输出',
|
||||
cacheWritePrice: '缓存写入',
|
||||
|
||||
@@ -826,6 +826,17 @@ router.beforeEach(async (to, _from, next) => {
|
||||
}
|
||||
|
||||
|
||||
// 公共设置可能尚未加载(App.vue 的 onMounted 异步拉取晚于首次导航,且纯静态部署
|
||||
// 无 __APP_CONFIG__ 注入)。此时 cachedPublicSettings 为空会把 payment/risk_control
|
||||
// 误判为“未启用”而错误拦截,故这里先确保设置加载完成。
|
||||
if ((to.meta.requiresPayment || to.meta.requiresRiskControl) && !appStore.publicSettingsLoaded) {
|
||||
try {
|
||||
await appStore.fetchPublicSettings()
|
||||
} catch (error) {
|
||||
console.warn('Failed to load public settings in route guard', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Check payment requirement (internal payment system only)
|
||||
if (to.meta.requiresPayment) {
|
||||
const paymentEnabled = appStore.cachedPublicSettings?.payment_enabled
|
||||
|
||||
@@ -397,11 +397,16 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
* Clears all authentication state and persisted data
|
||||
*/
|
||||
async function logout(): Promise<void> {
|
||||
// Call API logout (revokes refresh token on server)
|
||||
await authAPI.logout()
|
||||
|
||||
// Clear state
|
||||
clearAuth()
|
||||
try {
|
||||
// Call API logout (revokes refresh token on server)
|
||||
await authAPI.logout()
|
||||
} catch (err) {
|
||||
// 服务端吊销失败(网络/5xx/超时)不应阻止本地登出,否则用户点了退出仍处于登录态。
|
||||
console.warn('Logout API call failed, clearing local session anyway', err)
|
||||
} finally {
|
||||
// Always clear local state (tokens, user data, refresh timers)
|
||||
clearAuth()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -526,6 +526,11 @@ export interface Group {
|
||||
image_price_1k: number | null
|
||||
image_price_2k: number | null
|
||||
image_price_4k: number | null
|
||||
video_rate_independent: boolean
|
||||
video_rate_multiplier: number
|
||||
video_price_480p: number | null
|
||||
video_price_720p: number | null
|
||||
video_price_1080p: number | null
|
||||
// 高峰时段倍率配置
|
||||
peak_rate_enabled: boolean
|
||||
peak_start: string
|
||||
@@ -585,6 +590,7 @@ export interface ApiKey {
|
||||
ip_whitelist: string[]
|
||||
ip_blacklist: string[]
|
||||
last_used_at: string | null
|
||||
last_used_ip: string | null
|
||||
quota: number // Quota limit in USD (0 = unlimited)
|
||||
quota_used: number // Used quota amount in USD
|
||||
expires_at: string | null // Expiration time (null = never expires)
|
||||
@@ -653,6 +659,11 @@ export interface CreateGroupRequest {
|
||||
image_price_1k?: number | null
|
||||
image_price_2k?: number | null
|
||||
image_price_4k?: number | null
|
||||
video_rate_independent?: boolean
|
||||
video_rate_multiplier?: number
|
||||
video_price_480p?: number | null
|
||||
video_price_720p?: number | null
|
||||
video_price_1080p?: number | null
|
||||
peak_rate_enabled?: boolean
|
||||
peak_start?: string
|
||||
peak_end?: string
|
||||
@@ -695,6 +706,11 @@ export interface UpdateGroupRequest {
|
||||
image_price_1k?: number | null
|
||||
image_price_2k?: number | null
|
||||
image_price_4k?: number | null
|
||||
video_rate_independent?: boolean
|
||||
video_rate_multiplier?: number
|
||||
video_price_480p?: number | null
|
||||
video_price_720p?: number | null
|
||||
video_price_1080p?: number | null
|
||||
peak_rate_enabled?: boolean
|
||||
peak_start?: string
|
||||
peak_end?: string
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
export const BILLING_MODE_TOKEN = 'token'
|
||||
export const BILLING_MODE_PER_REQUEST = 'per_request'
|
||||
export const BILLING_MODE_IMAGE = 'image'
|
||||
export const BILLING_MODE_VIDEO = 'video'
|
||||
|
||||
export function getBillingModeLabel(mode: string | null | undefined, t: (key: string) => string): string {
|
||||
switch (mode) {
|
||||
case BILLING_MODE_PER_REQUEST: return t('admin.usage.billingModePerRequest')
|
||||
case BILLING_MODE_IMAGE: return t('admin.usage.billingModeImage')
|
||||
case BILLING_MODE_VIDEO: return t('admin.usage.billingModeVideo')
|
||||
default: return t('admin.usage.billingModeToken')
|
||||
}
|
||||
}
|
||||
@@ -14,6 +16,7 @@ export function getBillingModeBadgeClass(mode: string | null | undefined): strin
|
||||
switch (mode) {
|
||||
case BILLING_MODE_PER_REQUEST: return 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300'
|
||||
case BILLING_MODE_IMAGE: return 'bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-300'
|
||||
case BILLING_MODE_VIDEO: return 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300'
|
||||
default: return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300'
|
||||
}
|
||||
}
|
||||
@@ -25,7 +28,7 @@ interface ImageBillingRow {
|
||||
}
|
||||
|
||||
export function isImageUsage(row: Pick<ImageBillingRow, 'image_count' | 'billing_mode'> | null | undefined): boolean {
|
||||
return (row?.image_count ?? 0) > 0 && row?.billing_mode !== BILLING_MODE_TOKEN
|
||||
return (row?.image_count ?? 0) > 0 && row?.billing_mode !== BILLING_MODE_TOKEN && row?.billing_mode !== BILLING_MODE_VIDEO
|
||||
}
|
||||
|
||||
export function getDisplayBillingMode(row: Pick<ImageBillingRow, 'billing_mode' | 'image_count'> | null | undefined): string | null | undefined {
|
||||
|
||||
@@ -410,6 +410,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useAuthStore, useAppStore } from '@/stores'
|
||||
import LocaleSwitcher from '@/components/common/LocaleSwitcher.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { sanitizeUrl } from '@/utils/url'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -420,7 +421,7 @@ const appStore = useAppStore()
|
||||
const siteName = computed(() => appStore.cachedPublicSettings?.site_name || appStore.siteName || 'Sub2API')
|
||||
const siteLogo = computed(() => appStore.cachedPublicSettings?.site_logo || appStore.siteLogo || '')
|
||||
const siteSubtitle = computed(() => appStore.cachedPublicSettings?.site_subtitle || 'AI API Gateway Platform')
|
||||
const docUrl = computed(() => appStore.cachedPublicSettings?.doc_url || appStore.docUrl || '')
|
||||
const docUrl = computed(() => sanitizeUrl(appStore.cachedPublicSettings?.doc_url || appStore.docUrl || ''))
|
||||
const homeContent = computed(() => appStore.cachedPublicSettings?.home_content || '')
|
||||
|
||||
// Check if homeContent is a URL (for iframe display)
|
||||
|
||||
@@ -423,6 +423,7 @@ import { useAppStore } from '@/stores'
|
||||
import LocaleSwitcher from '@/components/common/LocaleSwitcher.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { buildGatewayUrl } from '@/api/client'
|
||||
import { sanitizeUrl } from '@/utils/url'
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
@@ -431,7 +432,7 @@ const appStore = useAppStore()
|
||||
|
||||
const siteName = computed(() => appStore.cachedPublicSettings?.site_name || appStore.siteName || 'Sub2API')
|
||||
const siteLogo = computed(() => appStore.cachedPublicSettings?.site_logo || appStore.siteLogo || '')
|
||||
const docUrl = computed(() => appStore.cachedPublicSettings?.doc_url || appStore.docUrl || '')
|
||||
const docUrl = computed(() => sanitizeUrl(appStore.cachedPublicSettings?.doc_url || appStore.docUrl || ''))
|
||||
const githubUrl = 'https://github.com/Wei-Shaw/sub2api'
|
||||
|
||||
// ==================== Theme (same as HomeView) ====================
|
||||
|
||||
@@ -168,20 +168,40 @@
|
||||
<!-- Subscription Limits - compact single line -->
|
||||
<div
|
||||
v-if="row.subscription_type === 'subscription'"
|
||||
class="text-xs text-gray-500 dark:text-gray-400"
|
||||
class="space-y-0.5 text-xs text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<template
|
||||
<div
|
||||
v-if="
|
||||
row.daily_limit_usd ||
|
||||
row.weekly_limit_usd ||
|
||||
row.monthly_limit_usd
|
||||
"
|
||||
class="flex flex-wrap items-center gap-x-1 gap-y-0.5"
|
||||
>
|
||||
<span v-if="row.daily_limit_usd"
|
||||
>${{ row.daily_limit_usd }}/{{
|
||||
t("admin.groups.limitDay")
|
||||
}}</span
|
||||
>
|
||||
<span v-if="row.daily_limit_usd" class="whitespace-nowrap">
|
||||
<span
|
||||
v-if="usageLoading"
|
||||
class="font-medium text-gray-400 dark:text-gray-500"
|
||||
>—</span
|
||||
>
|
||||
<span
|
||||
v-else
|
||||
:class="
|
||||
getQuotaUsageClass(
|
||||
usageMap.get(row.id)?.today_cost ?? 0,
|
||||
row.daily_limit_usd
|
||||
)
|
||||
"
|
||||
>{{
|
||||
formatUsd(usageMap.get(row.id)?.today_cost ?? 0)
|
||||
}}</span
|
||||
>
|
||||
<span class="text-gray-400 dark:text-gray-500">
|
||||
/ {{ formatUsd(row.daily_limit_usd) }}/{{
|
||||
t("admin.groups.limitDay")
|
||||
}}</span
|
||||
>
|
||||
</span>
|
||||
<span
|
||||
v-if="
|
||||
row.daily_limit_usd &&
|
||||
@@ -190,8 +210,8 @@
|
||||
class="mx-1 text-gray-300 dark:text-gray-600"
|
||||
>·</span
|
||||
>
|
||||
<span v-if="row.weekly_limit_usd"
|
||||
>${{ row.weekly_limit_usd }}/{{
|
||||
<span v-if="row.weekly_limit_usd" class="whitespace-nowrap"
|
||||
>{{ formatUsd(row.weekly_limit_usd) }}/{{
|
||||
t("admin.groups.limitWeek")
|
||||
}}</span
|
||||
>
|
||||
@@ -200,15 +220,25 @@
|
||||
class="mx-1 text-gray-300 dark:text-gray-600"
|
||||
>·</span
|
||||
>
|
||||
<span v-if="row.monthly_limit_usd"
|
||||
>${{ row.monthly_limit_usd }}/{{
|
||||
<span v-if="row.monthly_limit_usd" class="whitespace-nowrap"
|
||||
>{{ formatUsd(row.monthly_limit_usd) }}/{{
|
||||
t("admin.groups.limitMonth")
|
||||
}}</span
|
||||
>
|
||||
</template>
|
||||
</div>
|
||||
<span v-else class="text-gray-400 dark:text-gray-500">{{
|
||||
t("admin.groups.subscription.noLimit")
|
||||
}}</span>
|
||||
<div class="text-gray-400 dark:text-gray-500">
|
||||
{{ t("admin.groups.usageTotal") }}
|
||||
<span class="ml-1 font-medium text-gray-600 dark:text-gray-300"
|
||||
>{{
|
||||
usageLoading
|
||||
? "—"
|
||||
: formatUsd(usageMap.get(row.id)?.total_cost ?? 0)
|
||||
}}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -795,10 +825,10 @@
|
||||
<label
|
||||
class="block mb-2 font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{{ t("admin.groups.imagePricing.title") }}
|
||||
{{ t(imagePricingI18nKey(createForm.platform, "title")) }}
|
||||
</label>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||
{{ t("admin.groups.imagePricing.description") }}
|
||||
{{ t(imagePricingI18nKey(createForm.platform, "description")) }}
|
||||
</p>
|
||||
<div class="mb-4 grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
@@ -807,7 +837,7 @@
|
||||
type="checkbox"
|
||||
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
{{ t("admin.groups.imagePricing.allowImageGeneration") }}
|
||||
{{ t(imagePricingI18nKey(createForm.platform, "allowImageGeneration")) }}
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<input
|
||||
@@ -815,7 +845,7 @@
|
||||
type="checkbox"
|
||||
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
{{ t("admin.groups.imagePricing.independentMultiplier") }}
|
||||
{{ t(imagePricingI18nKey(createForm.platform, "independentMultiplier")) }}
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
@@ -823,7 +853,7 @@
|
||||
class="mb-4"
|
||||
>
|
||||
<label class="input-label">{{
|
||||
t("admin.groups.imagePricing.imageMultiplier")
|
||||
t(imagePricingI18nKey(createForm.platform, "imageMultiplier"))
|
||||
}}</label>
|
||||
<input
|
||||
v-model.number="createForm.image_rate_multiplier"
|
||||
@@ -843,7 +873,7 @@
|
||||
step="0.001"
|
||||
min="0"
|
||||
class="input"
|
||||
placeholder="0.134"
|
||||
:placeholder="getImagePricePlaceholder(createForm.platform, 'image_price_1k')"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -854,7 +884,7 @@
|
||||
step="0.001"
|
||||
min="0"
|
||||
class="input"
|
||||
placeholder="0.201"
|
||||
:placeholder="getImagePricePlaceholder(createForm.platform, 'image_price_2k')"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -865,16 +895,16 @@
|
||||
step="0.001"
|
||||
min="0"
|
||||
class="input"
|
||||
placeholder="0.268"
|
||||
:placeholder="getImagePricePlaceholder(createForm.platform, 'image_price_4k')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-3 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t("admin.groups.imagePricing.modeHint") }}
|
||||
{{ t(imagePricingI18nKey(createForm.platform, "modeHint")) }}
|
||||
</p>
|
||||
<div class="mt-2 rounded-lg bg-gray-50 p-3 text-xs text-gray-700 dark:bg-gray-800 dark:text-gray-300">
|
||||
<div class="mb-1 font-medium">
|
||||
{{ t("admin.groups.imagePricing.finalPricePreview") }}
|
||||
{{ t(imagePricingI18nKey(createForm.platform, "finalPricePreview")) }}
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<div
|
||||
@@ -939,6 +969,98 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 视频生成计费配置(仅 Grok 平台) -->
|
||||
<div
|
||||
v-if="supportsVideoPricingPlatform(createForm.platform)"
|
||||
class="border-t pt-4"
|
||||
>
|
||||
<label
|
||||
class="block mb-2 font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{{ t(videoPricingI18nKey("title")) }}
|
||||
</label>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||
{{ t(videoPricingI18nKey("description")) }}
|
||||
</p>
|
||||
<div class="mb-4">
|
||||
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<input
|
||||
v-model="createForm.video_rate_independent"
|
||||
type="checkbox"
|
||||
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
{{ t(videoPricingI18nKey("independentMultiplier")) }}
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
v-if="createForm.video_rate_independent"
|
||||
class="mb-4"
|
||||
>
|
||||
<label class="input-label">{{
|
||||
t(videoPricingI18nKey("videoMultiplier"))
|
||||
}}</label>
|
||||
<input
|
||||
v-model.number="createForm.video_rate_multiplier"
|
||||
type="number"
|
||||
step="0.0001"
|
||||
min="0"
|
||||
class="input"
|
||||
placeholder="1"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label class="input-label">480p ($/s)</label>
|
||||
<input
|
||||
v-model.number="createForm.video_price_480p"
|
||||
type="number"
|
||||
step="0.001"
|
||||
min="0"
|
||||
class="input"
|
||||
:placeholder="getVideoPricePlaceholder(createForm.platform, 'video_price_480p')"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">720p ($/s)</label>
|
||||
<input
|
||||
v-model.number="createForm.video_price_720p"
|
||||
type="number"
|
||||
step="0.001"
|
||||
min="0"
|
||||
class="input"
|
||||
:placeholder="getVideoPricePlaceholder(createForm.platform, 'video_price_720p')"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">1080p ($/s)</label>
|
||||
<input
|
||||
v-model.number="createForm.video_price_1080p"
|
||||
type="number"
|
||||
step="0.001"
|
||||
min="0"
|
||||
class="input"
|
||||
:placeholder="getVideoPricePlaceholder(createForm.platform, 'video_price_1080p')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-3 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t(videoPricingI18nKey("modeHint")) }}
|
||||
</p>
|
||||
<div class="mt-2 rounded-lg bg-gray-50 p-3 text-xs text-gray-700 dark:bg-gray-800 dark:text-gray-300">
|
||||
<div class="mb-1 font-medium">
|
||||
{{ t(videoPricingI18nKey("finalPricePreview")) }}
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<div
|
||||
v-for="item in createVideoFinalPricePreview"
|
||||
:key="item.label"
|
||||
>
|
||||
{{ item.label }}: {{ item.value }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 高峰时段倍率配置(仅订阅类型分组) -->
|
||||
<div v-if="createForm.subscription_type === 'subscription'" class="border-t pt-4">
|
||||
<div class="mb-4 grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
@@ -2182,10 +2304,10 @@
|
||||
<label
|
||||
class="block mb-2 font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{{ t("admin.groups.imagePricing.title") }}
|
||||
{{ t(imagePricingI18nKey(editForm.platform, "title")) }}
|
||||
</label>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||
{{ t("admin.groups.imagePricing.description") }}
|
||||
{{ t(imagePricingI18nKey(editForm.platform, "description")) }}
|
||||
</p>
|
||||
<div class="mb-4 grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
@@ -2194,7 +2316,7 @@
|
||||
type="checkbox"
|
||||
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
{{ t("admin.groups.imagePricing.allowImageGeneration") }}
|
||||
{{ t(imagePricingI18nKey(editForm.platform, "allowImageGeneration")) }}
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<input
|
||||
@@ -2202,7 +2324,7 @@
|
||||
type="checkbox"
|
||||
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
{{ t("admin.groups.imagePricing.independentMultiplier") }}
|
||||
{{ t(imagePricingI18nKey(editForm.platform, "independentMultiplier")) }}
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
@@ -2210,7 +2332,7 @@
|
||||
class="mb-4"
|
||||
>
|
||||
<label class="input-label">{{
|
||||
t("admin.groups.imagePricing.imageMultiplier")
|
||||
t(imagePricingI18nKey(editForm.platform, "imageMultiplier"))
|
||||
}}</label>
|
||||
<input
|
||||
v-model.number="editForm.image_rate_multiplier"
|
||||
@@ -2230,7 +2352,7 @@
|
||||
step="0.001"
|
||||
min="0"
|
||||
class="input"
|
||||
placeholder="0.134"
|
||||
:placeholder="getImagePricePlaceholder(editForm.platform, 'image_price_1k')"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -2241,7 +2363,7 @@
|
||||
step="0.001"
|
||||
min="0"
|
||||
class="input"
|
||||
placeholder="0.201"
|
||||
:placeholder="getImagePricePlaceholder(editForm.platform, 'image_price_2k')"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -2252,16 +2374,16 @@
|
||||
step="0.001"
|
||||
min="0"
|
||||
class="input"
|
||||
placeholder="0.268"
|
||||
:placeholder="getImagePricePlaceholder(editForm.platform, 'image_price_4k')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-3 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t("admin.groups.imagePricing.modeHint") }}
|
||||
{{ t(imagePricingI18nKey(editForm.platform, "modeHint")) }}
|
||||
</p>
|
||||
<div class="mt-2 rounded-lg bg-gray-50 p-3 text-xs text-gray-700 dark:bg-gray-800 dark:text-gray-300">
|
||||
<div class="mb-1 font-medium">
|
||||
{{ t("admin.groups.imagePricing.finalPricePreview") }}
|
||||
{{ t(imagePricingI18nKey(editForm.platform, "finalPricePreview")) }}
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<div
|
||||
@@ -2326,6 +2448,98 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 视频生成计费配置(仅 Grok 平台) -->
|
||||
<div
|
||||
v-if="supportsVideoPricingPlatform(editForm.platform)"
|
||||
class="border-t pt-4"
|
||||
>
|
||||
<label
|
||||
class="block mb-2 font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{{ t(videoPricingI18nKey("title")) }}
|
||||
</label>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||
{{ t(videoPricingI18nKey("description")) }}
|
||||
</p>
|
||||
<div class="mb-4">
|
||||
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<input
|
||||
v-model="editForm.video_rate_independent"
|
||||
type="checkbox"
|
||||
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
{{ t(videoPricingI18nKey("independentMultiplier")) }}
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
v-if="editForm.video_rate_independent"
|
||||
class="mb-4"
|
||||
>
|
||||
<label class="input-label">{{
|
||||
t(videoPricingI18nKey("videoMultiplier"))
|
||||
}}</label>
|
||||
<input
|
||||
v-model.number="editForm.video_rate_multiplier"
|
||||
type="number"
|
||||
step="0.0001"
|
||||
min="0"
|
||||
class="input"
|
||||
placeholder="1"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label class="input-label">480p ($/s)</label>
|
||||
<input
|
||||
v-model.number="editForm.video_price_480p"
|
||||
type="number"
|
||||
step="0.001"
|
||||
min="0"
|
||||
class="input"
|
||||
:placeholder="getVideoPricePlaceholder(editForm.platform, 'video_price_480p')"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">720p ($/s)</label>
|
||||
<input
|
||||
v-model.number="editForm.video_price_720p"
|
||||
type="number"
|
||||
step="0.001"
|
||||
min="0"
|
||||
class="input"
|
||||
:placeholder="getVideoPricePlaceholder(editForm.platform, 'video_price_720p')"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">1080p ($/s)</label>
|
||||
<input
|
||||
v-model.number="editForm.video_price_1080p"
|
||||
type="number"
|
||||
step="0.001"
|
||||
min="0"
|
||||
class="input"
|
||||
:placeholder="getVideoPricePlaceholder(editForm.platform, 'video_price_1080p')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-3 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t(videoPricingI18nKey("modeHint")) }}
|
||||
</p>
|
||||
<div class="mt-2 rounded-lg bg-gray-50 p-3 text-xs text-gray-700 dark:bg-gray-800 dark:text-gray-300">
|
||||
<div class="mb-1 font-medium">
|
||||
{{ t(videoPricingI18nKey("finalPricePreview")) }}
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<div
|
||||
v-for="item in editVideoFinalPricePreview"
|
||||
:key="item.label"
|
||||
>
|
||||
{{ item.label }}: {{ item.value }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 高峰时段倍率配置(仅订阅类型分组) -->
|
||||
<div v-if="editForm.subscription_type === 'subscription'" class="border-t pt-4">
|
||||
<div class="mb-4 grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
@@ -3312,7 +3526,16 @@ import {
|
||||
} from "./groupsModelsList";
|
||||
import { createModelsListCandidatesTracker } from "./groupsModelsListCandidates";
|
||||
import { normalizeSupportedModelScopesForPlatform } from "./groupsSupportedModelScopes";
|
||||
import { supportsImagePricingPlatform } from "./groupsImagePricing";
|
||||
import {
|
||||
getDefaultImagePreviewPrice,
|
||||
getDefaultVideoPreviewPrice,
|
||||
getImagePricePlaceholder,
|
||||
getVideoPricePlaceholder,
|
||||
imagePricingI18nKey,
|
||||
supportsImagePricingPlatform,
|
||||
supportsVideoPricingPlatform,
|
||||
videoPricingI18nKey,
|
||||
} from "./groupsImagePricing";
|
||||
|
||||
const { t } = useI18n();
|
||||
const appStore = useAppStore();
|
||||
@@ -3396,7 +3619,9 @@ const saveColumnsToStorage = () => {
|
||||
};
|
||||
|
||||
const isColumnVisible = (key: string) => !hiddenColumns.has(key);
|
||||
const hasVisibleUsageColumn = computed(() => isColumnVisible("usage"));
|
||||
const hasVisibleUsageSummaryConsumer = computed(
|
||||
() => isColumnVisible("usage") || isColumnVisible("billing_type"),
|
||||
);
|
||||
const hasVisibleCapacityColumn = computed(() => isColumnVisible("capacity"));
|
||||
|
||||
const toggleColumn = (key: string) => {
|
||||
@@ -3411,7 +3636,7 @@ const toggleColumn = (key: string) => {
|
||||
}
|
||||
saveColumnsToStorage();
|
||||
|
||||
if (wasHidden && key === "usage") {
|
||||
if (wasHidden && (key === "usage" || key === "billing_type")) {
|
||||
loadUsageSummary();
|
||||
}
|
||||
if (wasHidden && key === "capacity") {
|
||||
@@ -3571,9 +3796,12 @@ const copyAccountsGroupOptionsForEdit = computed(() => {
|
||||
|
||||
const groups = ref<AdminGroup[]>([]);
|
||||
const loading = ref(false);
|
||||
const usageMap = ref<Map<number, { today_cost: number; total_cost: number }>>(
|
||||
new Map(),
|
||||
);
|
||||
type GroupUsageSummary = {
|
||||
today_cost: number;
|
||||
total_cost: number;
|
||||
};
|
||||
|
||||
const usageMap = ref<Map<number, GroupUsageSummary>>(new Map());
|
||||
const usageLoading = ref(false);
|
||||
const capacityMap = ref<
|
||||
Map<
|
||||
@@ -3654,6 +3882,12 @@ const createForm = reactive({
|
||||
image_price_1k: null as number | null,
|
||||
image_price_2k: null as number | null,
|
||||
image_price_4k: null as number | null,
|
||||
// 视频生成计费配置(仅 Grok 平台)
|
||||
video_rate_independent: false,
|
||||
video_rate_multiplier: 1,
|
||||
video_price_480p: null as number | null,
|
||||
video_price_720p: null as number | null,
|
||||
video_price_1080p: null as number | null,
|
||||
// 高峰时段倍率配置
|
||||
peak_rate_enabled: false,
|
||||
peak_start: "",
|
||||
@@ -3993,6 +4227,12 @@ const editForm = reactive({
|
||||
image_price_1k: null as number | null,
|
||||
image_price_2k: null as number | null,
|
||||
image_price_4k: null as number | null,
|
||||
// 视频生成计费配置(仅 Grok 平台)
|
||||
video_rate_independent: false,
|
||||
video_rate_multiplier: 1,
|
||||
video_price_480p: null as number | null,
|
||||
video_price_720p: null as number | null,
|
||||
video_price_1080p: null as number | null,
|
||||
// 高峰时段倍率配置
|
||||
peak_rate_enabled: false,
|
||||
peak_start: "",
|
||||
@@ -4042,12 +4282,28 @@ type ImagePricingFormState = {
|
||||
peak_rate_multiplier: number;
|
||||
};
|
||||
|
||||
type VideoPricingFormState = {
|
||||
platform: GroupPlatform;
|
||||
rate_multiplier: number;
|
||||
video_rate_independent: boolean;
|
||||
video_rate_multiplier: number;
|
||||
video_price_480p: number | string | null;
|
||||
video_price_720p: number | string | null;
|
||||
video_price_1080p: number | string | null;
|
||||
};
|
||||
|
||||
const imagePricingTiers = [
|
||||
{ key: "image_price_1k", label: "1K" },
|
||||
{ key: "image_price_2k", label: "2K" },
|
||||
{ key: "image_price_4k", label: "4K" },
|
||||
] as const;
|
||||
|
||||
const videoPricingTiers = [
|
||||
{ key: "video_price_480p", label: "480p" },
|
||||
{ key: "video_price_720p", label: "720p" },
|
||||
{ key: "video_price_1080p", label: "1080p" },
|
||||
] as const;
|
||||
|
||||
const normalizePreviewNumber = (value: number | string | null | undefined, fallback = 0) => {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return fallback;
|
||||
@@ -4056,6 +4312,14 @@ const normalizePreviewNumber = (value: number | string | null | undefined, fallb
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
};
|
||||
|
||||
const parsePreviewPrice = (value: number | string | null | undefined) => {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return null;
|
||||
}
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
|
||||
};
|
||||
|
||||
const formatImagePricePreview = (value: number | string | null | undefined) => {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return t("admin.groups.imagePricing.notConfigured");
|
||||
@@ -4067,28 +4331,64 @@ const formatImagePricePreview = (value: number | string | null | undefined) => {
|
||||
return `$${price.toFixed(6).replace(/0+$/, "").replace(/\.$/, "")}`;
|
||||
};
|
||||
|
||||
const formatVideoPricePreview = (value: number | string | null | undefined) => {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return t("admin.groups.videoPricing.notConfigured");
|
||||
}
|
||||
const price = Number(value);
|
||||
if (!Number.isFinite(price) || price < 0) {
|
||||
return t("admin.groups.videoPricing.notConfigured");
|
||||
}
|
||||
return `$${price.toFixed(6).replace(/0+$/, "").replace(/\.$/, "")}`;
|
||||
};
|
||||
|
||||
const buildImageFinalPricePreview = (form: ImagePricingFormState) => {
|
||||
const imageMultiplier = form.image_rate_independent
|
||||
? normalizePreviewNumber(form.image_rate_multiplier, 1)
|
||||
: normalizePreviewNumber(form.rate_multiplier, 1);
|
||||
const multiplier = imageMultiplier;
|
||||
return imagePricingTiers.map((tier) => {
|
||||
const basePrice = normalizePreviewNumber(form[tier.key]);
|
||||
const basePrice =
|
||||
parsePreviewPrice(form[tier.key]) ??
|
||||
getDefaultImagePreviewPrice(form.platform, tier.key);
|
||||
return {
|
||||
label: tier.label,
|
||||
value: basePrice > 0
|
||||
value: basePrice !== null
|
||||
? formatImagePricePreview(basePrice * multiplier)
|
||||
: t("admin.groups.imagePricing.notConfigured"),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const buildVideoFinalPricePreview = (form: VideoPricingFormState) => {
|
||||
const multiplier = form.video_rate_independent
|
||||
? normalizePreviewNumber(form.video_rate_multiplier, 1)
|
||||
: normalizePreviewNumber(form.rate_multiplier, 1);
|
||||
return videoPricingTiers.map((tier) => {
|
||||
const basePrice =
|
||||
parsePreviewPrice(form[tier.key]) ??
|
||||
getDefaultVideoPreviewPrice(form.platform, tier.key);
|
||||
return {
|
||||
label: tier.label,
|
||||
value: basePrice !== null
|
||||
? formatVideoPricePreview(basePrice * multiplier)
|
||||
: t("admin.groups.videoPricing.notConfigured"),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const createImageFinalPricePreview = computed(() =>
|
||||
buildImageFinalPricePreview(createForm),
|
||||
);
|
||||
const editImageFinalPricePreview = computed(() =>
|
||||
buildImageFinalPricePreview(editForm),
|
||||
);
|
||||
const createVideoFinalPricePreview = computed(() =>
|
||||
buildVideoFinalPricePreview(createForm),
|
||||
);
|
||||
const editVideoFinalPricePreview = computed(() =>
|
||||
buildVideoFinalPricePreview(editForm),
|
||||
);
|
||||
|
||||
const resetDisabledBatchImagePricing = (
|
||||
form: Pick<
|
||||
@@ -4146,7 +4446,7 @@ const loadGroups = async () => {
|
||||
groups.value = response.items;
|
||||
pagination.total = response.total;
|
||||
pagination.pages = response.pages;
|
||||
if (hasVisibleUsageColumn.value) {
|
||||
if (hasVisibleUsageSummaryConsumer.value) {
|
||||
loadUsageSummary();
|
||||
} else {
|
||||
usageLoading.value = false;
|
||||
@@ -4177,8 +4477,28 @@ const formatCost = (cost: number): string => {
|
||||
return cost.toFixed(2);
|
||||
};
|
||||
|
||||
const formatUsd = (cost: number | null | undefined): string =>
|
||||
`$${formatCost(cost ?? 0)}`;
|
||||
|
||||
const getQuotaUsageClass = (
|
||||
used: number,
|
||||
limit: number | null | undefined,
|
||||
): string => {
|
||||
if (!limit || limit <= 0) {
|
||||
return "font-medium text-gray-700 dark:text-gray-300";
|
||||
}
|
||||
const ratio = used / limit;
|
||||
if (ratio >= 1) {
|
||||
return "font-semibold text-red-600 dark:text-red-400";
|
||||
}
|
||||
if (ratio >= 0.8) {
|
||||
return "font-semibold text-amber-600 dark:text-amber-400";
|
||||
}
|
||||
return "font-medium text-gray-700 dark:text-gray-300";
|
||||
};
|
||||
|
||||
const loadUsageSummary = async () => {
|
||||
if (!hasVisibleUsageColumn.value) {
|
||||
if (!hasVisibleUsageSummaryConsumer.value) {
|
||||
usageLoading.value = false;
|
||||
return;
|
||||
}
|
||||
@@ -4186,7 +4506,7 @@ const loadUsageSummary = async () => {
|
||||
try {
|
||||
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
const data = await adminAPI.groups.getUsageSummary(tz);
|
||||
const map = new Map<number, { today_cost: number; total_cost: number }>();
|
||||
const map = new Map<number, GroupUsageSummary>();
|
||||
for (const item of data) {
|
||||
map.set(item.group_id, {
|
||||
today_cost: item.today_cost,
|
||||
@@ -4290,6 +4610,11 @@ const closeCreateModal = () => {
|
||||
createForm.image_price_1k = null;
|
||||
createForm.image_price_2k = null;
|
||||
createForm.image_price_4k = null;
|
||||
createForm.video_rate_independent = false;
|
||||
createForm.video_rate_multiplier = 1;
|
||||
createForm.video_price_480p = null;
|
||||
createForm.video_price_720p = null;
|
||||
createForm.video_price_1080p = null;
|
||||
createForm.peak_rate_enabled = false;
|
||||
createForm.peak_start = "";
|
||||
createForm.peak_end = "";
|
||||
@@ -4390,6 +4715,17 @@ const handleCreateGroup = async () => {
|
||||
requestData.batch_image_hold_multiplier = normalizeRateMultiplier(
|
||||
requestData.batch_image_hold_multiplier,
|
||||
);
|
||||
requestData.video_rate_multiplier = normalizeRateMultiplier(
|
||||
requestData.video_rate_multiplier,
|
||||
);
|
||||
// 媒体价格输入清空时 v-model.number 产生 "",直接提交会被后端 *float64 反序列化拒绝(400),
|
||||
// 创建时按"未配置"(null)处理。
|
||||
requestData.image_price_1k = emptyToNull(requestData.image_price_1k);
|
||||
requestData.image_price_2k = emptyToNull(requestData.image_price_2k);
|
||||
requestData.image_price_4k = emptyToNull(requestData.image_price_4k);
|
||||
requestData.video_price_480p = emptyToNull(requestData.video_price_480p);
|
||||
requestData.video_price_720p = emptyToNull(requestData.video_price_720p);
|
||||
requestData.video_price_1080p = emptyToNull(requestData.video_price_1080p);
|
||||
requestData.peak_rate_enabled = createForm.peak_rate_enabled;
|
||||
requestData.peak_start = createForm.peak_start;
|
||||
requestData.peak_end = createForm.peak_end;
|
||||
@@ -4438,6 +4774,11 @@ const handleEdit = async (group: AdminGroup) => {
|
||||
editForm.image_price_1k = group.image_price_1k;
|
||||
editForm.image_price_2k = group.image_price_2k;
|
||||
editForm.image_price_4k = group.image_price_4k;
|
||||
editForm.video_rate_independent = group.video_rate_independent ?? false;
|
||||
editForm.video_rate_multiplier = group.video_rate_multiplier ?? 1;
|
||||
editForm.video_price_480p = group.video_price_480p;
|
||||
editForm.video_price_720p = group.video_price_720p;
|
||||
editForm.video_price_1080p = group.video_price_1080p;
|
||||
editForm.peak_rate_enabled = group.peak_rate_enabled ?? false;
|
||||
editForm.peak_start = group.peak_start ?? "";
|
||||
editForm.peak_end = group.peak_end ?? "";
|
||||
@@ -4490,6 +4831,11 @@ const closeEditModal = () => {
|
||||
editForm.peak_start = "";
|
||||
editForm.peak_end = "";
|
||||
editForm.peak_rate_multiplier = 1.0;
|
||||
editForm.video_rate_independent = false;
|
||||
editForm.video_rate_multiplier = 1;
|
||||
editForm.video_price_480p = null;
|
||||
editForm.video_price_720p = null;
|
||||
editForm.video_price_1080p = null;
|
||||
resetMessagesDispatchFormState(editForm);
|
||||
resetModelsListState(editModelsListState);
|
||||
};
|
||||
@@ -4555,6 +4901,19 @@ const handleUpdateGroup = async () => {
|
||||
payload.batch_image_hold_multiplier = normalizeRateMultiplier(
|
||||
payload.batch_image_hold_multiplier,
|
||||
);
|
||||
payload.video_rate_multiplier = normalizeRateMultiplier(
|
||||
payload.video_rate_multiplier,
|
||||
);
|
||||
// 媒体价格输入清空时 v-model.number 产生 "",直接提交会被后端 *float64 反序列化拒绝(400)。
|
||||
// 更新语义中 null 表示"不修改",因此清空后的字段发送 -1:后端 normalizePrice 将负价归一为
|
||||
// NULL,从而真正清除已配置的价格。
|
||||
const emptyPriceToClear = (v: any) => (v === "" || v === null ? -1 : v);
|
||||
payload.image_price_1k = emptyPriceToClear(payload.image_price_1k);
|
||||
payload.image_price_2k = emptyPriceToClear(payload.image_price_2k);
|
||||
payload.image_price_4k = emptyPriceToClear(payload.image_price_4k);
|
||||
payload.video_price_480p = emptyPriceToClear(payload.video_price_480p);
|
||||
payload.video_price_720p = emptyPriceToClear(payload.video_price_720p);
|
||||
payload.video_price_1080p = emptyPriceToClear(payload.video_price_1080p);
|
||||
payload.peak_rate_enabled = editForm.peak_rate_enabled;
|
||||
payload.peak_start = editForm.peak_start;
|
||||
payload.peak_end = editForm.peak_end;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
|
||||
import type { DashboardStats } from '@/types'
|
||||
import DashboardView from '../DashboardView.vue'
|
||||
@@ -87,6 +88,8 @@ const createDashboardStats = (): DashboardStats => ({
|
||||
|
||||
describe('admin DashboardView', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
|
||||
getSnapshotV2.mockReset()
|
||||
getUserUsageTrend.mockReset()
|
||||
getUserSpendingRanking.mockReset()
|
||||
|
||||
@@ -308,8 +308,11 @@ describe('admin GroupsView column settings', () => {
|
||||
expect(localStorage.getItem('group-hidden-columns')).toBe(JSON.stringify(['usage']))
|
||||
})
|
||||
|
||||
it('skips hidden usage and capacity fetches until those columns are shown', async () => {
|
||||
localStorage.setItem('group-hidden-columns', JSON.stringify(['usage', 'capacity']))
|
||||
it('skips usage and capacity fetches until consuming columns are shown', async () => {
|
||||
localStorage.setItem(
|
||||
'group-hidden-columns',
|
||||
JSON.stringify(['billing_type', 'usage', 'capacity']),
|
||||
)
|
||||
|
||||
const wrapper = await mountView()
|
||||
|
||||
|
||||
@@ -1,17 +1,52 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
getDefaultImagePreviewPrice,
|
||||
getDefaultVideoPreviewPrice,
|
||||
getImagePricePlaceholder,
|
||||
getVideoPricePlaceholder,
|
||||
imagePricingPlatforms,
|
||||
imagePricingI18nKey,
|
||||
supportsImagePricingPlatform,
|
||||
supportsVideoPricingPlatform,
|
||||
videoPricingI18nKey,
|
||||
} from "../groupsImagePricing";
|
||||
|
||||
describe("groups image pricing platform support", () => {
|
||||
it("includes Grok media groups", () => {
|
||||
it("includes Grok image groups", () => {
|
||||
expect(supportsImagePricingPlatform("grok")).toBe(true);
|
||||
expect(imagePricingPlatforms.has("grok")).toBe(true);
|
||||
});
|
||||
|
||||
it("enables video pricing controls for Grok only", () => {
|
||||
expect(supportsVideoPricingPlatform("grok")).toBe(true);
|
||||
expect(supportsVideoPricingPlatform("openai")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps non-media group platforms out of the image pricing controls", () => {
|
||||
expect(supportsImagePricingPlatform("anthropic")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps image and video pricing copy separate", () => {
|
||||
expect(imagePricingI18nKey("grok", "title")).toBe(
|
||||
"admin.groups.imagePricing.title",
|
||||
);
|
||||
expect(videoPricingI18nKey("title")).toBe("admin.groups.videoPricing.title");
|
||||
});
|
||||
|
||||
it("uses Grok media defaults instead of generic image fallback placeholders", () => {
|
||||
expect(getImagePricePlaceholder("grok", "image_price_1k")).toBe("0.02");
|
||||
expect(getImagePricePlaceholder("grok", "image_price_2k")).toBe("0.02");
|
||||
// 视频 placeholder 为每秒单价:480p/720p 取 grok-imagine-video 官方每秒价,
|
||||
// 1080p 仅 video-1.5 支持、取 1.5 每秒价。
|
||||
expect(getVideoPricePlaceholder("grok", "video_price_480p")).toBe("0.05");
|
||||
expect(getVideoPricePlaceholder("grok", "video_price_720p")).toBe("0.07");
|
||||
expect(getVideoPricePlaceholder("grok", "video_price_1080p")).toBe("0.25");
|
||||
});
|
||||
|
||||
it("keeps non-Grok image placeholders on the generic image card", () => {
|
||||
expect(getImagePricePlaceholder("openai", "image_price_1k")).toBe("0.134");
|
||||
expect(getDefaultImagePreviewPrice("openai", "image_price_2k")).toBe(0.201);
|
||||
expect(getDefaultVideoPreviewPrice("openai", "video_price_480p")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,3 +7,87 @@ export const imagePricingPlatforms = new Set([
|
||||
|
||||
export const supportsImagePricingPlatform = (platform: string): boolean =>
|
||||
imagePricingPlatforms.has(platform);
|
||||
|
||||
export const supportsVideoPricingPlatform = (platform: string): boolean =>
|
||||
platform === "grok";
|
||||
|
||||
export const imagePricingI18nKey = (_platform: string, key: string): string =>
|
||||
`admin.groups.imagePricing.${key}`;
|
||||
|
||||
export const videoPricingI18nKey = (key: string): string =>
|
||||
`admin.groups.videoPricing.${key}`;
|
||||
|
||||
type ImagePricingTierKey = "image_price_1k" | "image_price_2k" | "image_price_4k";
|
||||
type VideoPricingTierKey =
|
||||
| "video_price_480p"
|
||||
| "video_price_720p"
|
||||
| "video_price_1080p";
|
||||
|
||||
const defaultImagePricePlaceholders: Record<
|
||||
string,
|
||||
Record<ImagePricingTierKey, string>
|
||||
> = {
|
||||
default: {
|
||||
image_price_1k: "0.134",
|
||||
image_price_2k: "0.201",
|
||||
image_price_4k: "0.268",
|
||||
},
|
||||
grok: {
|
||||
image_price_1k: "0.02",
|
||||
image_price_2k: "0.02",
|
||||
image_price_4k: "0.02",
|
||||
},
|
||||
};
|
||||
|
||||
// 视频价为每秒单价(USD/s)。480p/720p 取 grok-imagine-video(文生视频实际走该模型)的
|
||||
// 官方每秒价;1080p 仅 grok-imagine-video-1.5 图生视频支持,取 1.5 的每秒价。
|
||||
const defaultVideoPricePlaceholders: Record<
|
||||
string,
|
||||
Record<VideoPricingTierKey, string>
|
||||
> = {
|
||||
grok: {
|
||||
video_price_480p: "0.05",
|
||||
video_price_720p: "0.07",
|
||||
video_price_1080p: "0.25",
|
||||
},
|
||||
};
|
||||
|
||||
export const getImagePricePlaceholder = (
|
||||
platform: string,
|
||||
tier: ImagePricingTierKey,
|
||||
): string => {
|
||||
const card = defaultImagePricePlaceholders[platform] ?? defaultImagePricePlaceholders.default;
|
||||
return card[tier];
|
||||
};
|
||||
|
||||
export const getVideoPricePlaceholder = (
|
||||
platform: string,
|
||||
tier: VideoPricingTierKey,
|
||||
): string => {
|
||||
const card = defaultVideoPricePlaceholders[platform];
|
||||
return card?.[tier] ?? "";
|
||||
};
|
||||
|
||||
export const getDefaultImagePreviewPrice = (
|
||||
platform: string,
|
||||
tier: ImagePricingTierKey,
|
||||
): number | null => {
|
||||
const placeholder = getImagePricePlaceholder(platform, tier);
|
||||
if (placeholder === "") {
|
||||
return null;
|
||||
}
|
||||
const value = Number(placeholder);
|
||||
return Number.isFinite(value) ? value : null;
|
||||
};
|
||||
|
||||
export const getDefaultVideoPreviewPrice = (
|
||||
platform: string,
|
||||
tier: VideoPricingTierKey,
|
||||
): number | null => {
|
||||
const placeholder = getVideoPricePlaceholder(platform, tier);
|
||||
if (placeholder === "") {
|
||||
return null;
|
||||
}
|
||||
const value = Number(placeholder);
|
||||
return Number.isFinite(value) ? value : null;
|
||||
};
|
||||
|
||||
@@ -354,6 +354,13 @@
|
||||
<span v-else class="text-sm text-gray-400 dark:text-dark-500">-</span>
|
||||
</template>
|
||||
|
||||
<template #cell-last_used_ip="{ value }">
|
||||
<span v-if="value" class="text-sm text-gray-500 dark:text-dark-400">
|
||||
{{ value }}
|
||||
</span>
|
||||
<span v-else class="text-sm text-gray-400 dark:text-dark-500">-</span>
|
||||
</template>
|
||||
|
||||
<template #cell-created_at="{ value }">
|
||||
<span class="text-sm text-gray-500 dark:text-dark-400">{{ formatDateTime(value) }}</span>
|
||||
</template>
|
||||
@@ -1174,15 +1181,19 @@ const allColumns = computed<Column[]>(() => [
|
||||
{ key: 'expires_at', label: t('keys.expiresAt'), sortable: true },
|
||||
{ key: 'status', label: t('common.status'), sortable: true },
|
||||
{ key: 'last_used_at', label: t('keys.lastUsedAt'), sortable: true },
|
||||
{ key: 'last_used_ip', label: t('keys.lastUsedIP'), sortable: false },
|
||||
{ key: 'created_at', label: t('keys.created'), sortable: true },
|
||||
{ key: 'actions', label: t('common.actions'), sortable: false }
|
||||
])
|
||||
|
||||
const ALWAYS_VISIBLE_COLUMNS = new Set(['name', 'actions'])
|
||||
const DEFAULT_HIDDEN_COLUMNS = ['rate_limit', 'last_used_at']
|
||||
const DEFAULT_HIDDEN_COLUMNS = ['rate_limit', 'last_used_at', 'last_used_ip']
|
||||
const HIDDEN_COLUMNS_KEY = 'api-key-hidden-columns'
|
||||
const COLUMN_SETTINGS_VERSION_KEY = 'api-key-column-settings-version'
|
||||
const COLUMN_SETTINGS_VERSION = 1
|
||||
const COLUMN_SETTINGS_VERSION = 2
|
||||
const VERSION_NEW_HIDDEN_COLUMNS: Record<number, string[]> = {
|
||||
2: ['last_used_ip']
|
||||
}
|
||||
|
||||
const toggleableColumns = computed(() =>
|
||||
allColumns.value.filter((col) => !ALWAYS_VISIBLE_COLUMNS.has(col.key))
|
||||
@@ -1213,10 +1224,23 @@ const loadSavedColumns = () => {
|
||||
!ALWAYS_VISIBLE_COLUMNS.has(key)
|
||||
)
|
||||
.forEach((key) => hiddenColumns.add(key))
|
||||
const storedVersion = Number(localStorage.getItem(COLUMN_SETTINGS_VERSION_KEY) ?? '1')
|
||||
if (storedVersion < COLUMN_SETTINGS_VERSION) {
|
||||
for (let v = storedVersion + 1; v <= COLUMN_SETTINGS_VERSION; v++) {
|
||||
for (const key of VERSION_NEW_HIDDEN_COLUMNS[v] ?? []) {
|
||||
if (validColumnKeys.has(key) && !ALWAYS_VISIBLE_COLUMNS.has(key)) {
|
||||
hiddenColumns.add(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
saveColumnsToStorage()
|
||||
} else {
|
||||
localStorage.setItem(COLUMN_SETTINGS_VERSION_KEY, String(COLUMN_SETTINGS_VERSION))
|
||||
}
|
||||
} else {
|
||||
DEFAULT_HIDDEN_COLUMNS.forEach((key) => hiddenColumns.add(key))
|
||||
localStorage.setItem(COLUMN_SETTINGS_VERSION_KEY, String(COLUMN_SETTINGS_VERSION))
|
||||
}
|
||||
localStorage.setItem(COLUMN_SETTINGS_VERSION_KEY, String(COLUMN_SETTINGS_VERSION))
|
||||
} catch (error) {
|
||||
console.error('Failed to load API key table columns:', error)
|
||||
DEFAULT_HIDDEN_COLUMNS.forEach((key) => hiddenColumns.add(key))
|
||||
|
||||
@@ -132,16 +132,26 @@ async function renderQR() {
|
||||
}
|
||||
}
|
||||
|
||||
let pollInFlight = false
|
||||
async function pollStatus() {
|
||||
if (!orderId.value) return
|
||||
const order = await paymentStore.pollOrderStatus(orderId.value)
|
||||
if (!order) return
|
||||
if (order.status === 'COMPLETED' || order.status === 'PAID') {
|
||||
cleanup()
|
||||
router.push({ path: '/payment/result', query: { order_id: String(orderId.value), status: 'success' } })
|
||||
} else if (order.status === 'EXPIRED' || order.status === 'CANCELLED' || order.status === 'FAILED') {
|
||||
cleanup()
|
||||
expired.value = true
|
||||
// 防重入:接口响应慢于 3 秒轮询间隔时避免并发重叠请求与重复跳转。
|
||||
if (pollInFlight) return
|
||||
pollInFlight = true
|
||||
try {
|
||||
const order = await paymentStore.pollOrderStatus(orderId.value)
|
||||
if (!order) return
|
||||
// 定时器已被 cleanup 清除时不再执行终态跳转(响应可能在 cleanup 后才回来)。
|
||||
if (!pollTimer) return
|
||||
if (order.status === 'COMPLETED' || order.status === 'PAID') {
|
||||
cleanup()
|
||||
router.push({ path: '/payment/result', query: { order_id: String(orderId.value), status: 'success' } })
|
||||
} else if (order.status === 'EXPIRED' || order.status === 'CANCELLED' || order.status === 'FAILED') {
|
||||
cleanup()
|
||||
expired.value = true
|
||||
}
|
||||
} finally {
|
||||
pollInFlight = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -84,23 +84,38 @@ const success = ref(false)
|
||||
const hint = ref(t('payment.stripePopup.redirecting'))
|
||||
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
let initTimeoutTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let messageHandler: ((event: MessageEvent) => void) | null = null
|
||||
|
||||
function closeWindow() { window.close() }
|
||||
|
||||
function clearInitTimeout() {
|
||||
if (initTimeoutTimer) {
|
||||
clearTimeout(initTimeoutTimer)
|
||||
initTimeoutTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const handler = (event: MessageEvent) => {
|
||||
messageHandler = (event: MessageEvent) => {
|
||||
if (event.origin !== window.location.origin) return
|
||||
if (event.data?.type !== 'STRIPE_POPUP_INIT') return
|
||||
window.removeEventListener('message', handler)
|
||||
// INIT 已到达,取消兜底超时,避免长时间的扫码支付被误判为超时。
|
||||
clearInitTimeout()
|
||||
if (messageHandler) {
|
||||
window.removeEventListener('message', messageHandler)
|
||||
messageHandler = null
|
||||
}
|
||||
initStripe(event.data.clientSecret, event.data.publishableKey)
|
||||
}
|
||||
window.addEventListener('message', handler)
|
||||
window.addEventListener('message', messageHandler)
|
||||
|
||||
if (window.opener) {
|
||||
window.opener.postMessage({ type: 'STRIPE_POPUP_READY' }, window.location.origin)
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
// 仅兜底“父窗口始终未发 STRIPE_POPUP_INIT”的场景。
|
||||
initTimeoutTimer = setTimeout(() => {
|
||||
if (!error.value && !success.value) {
|
||||
error.value = t('payment.stripePopup.timeout')
|
||||
}
|
||||
@@ -108,7 +123,12 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (pollTimer) clearInterval(pollTimer)
|
||||
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
|
||||
clearInitTimeout()
|
||||
if (messageHandler) {
|
||||
window.removeEventListener('message', messageHandler)
|
||||
messageHandler = null
|
||||
}
|
||||
})
|
||||
|
||||
async function initStripe(clientSecret: string, publishableKey: string) {
|
||||
@@ -149,10 +169,15 @@ async function initStripe(clientSecret: string, publishableKey: string) {
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
let inFlight = false
|
||||
pollTimer = setInterval(async () => {
|
||||
// 防重入:接口响应慢于轮询间隔时避免并发重叠请求。
|
||||
if (inFlight) return
|
||||
inFlight = true
|
||||
try {
|
||||
const token = document.cookie.split('; ').find(c => c.startsWith('token='))?.split('=')[1]
|
||||
|| localStorage.getItem('token') || ''
|
||||
// access token 存储在 localStorage 的 'auth_token' 键下(见 api/client.ts),
|
||||
// 之前误读 'token' 导致轮询请求不带认证、永远 401,支付成功无法被检测到。
|
||||
const token = localStorage.getItem('auth_token') || ''
|
||||
const res = await fetch(buildApiUrl(`/payment/orders/${orderId}`), {
|
||||
headers: token ? { Authorization: 'Bearer ' + token } : {},
|
||||
credentials: 'include',
|
||||
@@ -165,7 +190,9 @@ function startPolling() {
|
||||
success.value = true
|
||||
setTimeout(closeWindow, 2000)
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
} catch { /* ignore */ } finally {
|
||||
inFlight = false
|
||||
}
|
||||
}, 3000)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -389,6 +389,7 @@ const billingModeOptions = computed<SelectOption[]>(() => [
|
||||
{ value: 'token', label: t('admin.usage.billingModeToken') },
|
||||
{ value: 'per_request', label: t('admin.usage.billingModePerRequest') },
|
||||
{ value: 'image', label: t('admin.usage.billingModeImage') },
|
||||
{ value: 'video', label: t('admin.usage.billingModeVideo') },
|
||||
])
|
||||
|
||||
const apiKeys = ref<ApiKey[]>([])
|
||||
|
||||
@@ -44,6 +44,7 @@ const messages: Record<string, string> = {
|
||||
'keys.group': 'Group',
|
||||
'keys.currentConcurrency': 'Current Concurrency',
|
||||
'keys.lastUsedAt': 'Last Used',
|
||||
'keys.lastUsedIP': 'Last Used IP',
|
||||
'keys.rateLimitColumn': 'Rate Limit',
|
||||
'keys.searchPlaceholder': 'Search name or key...',
|
||||
'keys.status.active': 'Active',
|
||||
@@ -113,6 +114,7 @@ const createApiKey = (): ApiKey => ({
|
||||
ip_whitelist: [],
|
||||
ip_blacklist: [],
|
||||
last_used_at: null,
|
||||
last_used_ip: null,
|
||||
quota: 0,
|
||||
quota_used: 0,
|
||||
expires_at: null,
|
||||
@@ -164,6 +166,12 @@ const DataTableStub = {
|
||||
<div data-test="current-concurrency">
|
||||
<slot name="cell-current_concurrency" :value="row.current_concurrency" :row="row" />
|
||||
</div>
|
||||
<div
|
||||
v-if="columns.some((col) => col.key === 'last_used_ip')"
|
||||
data-test="last-used-ip"
|
||||
>
|
||||
<slot name="cell-last_used_ip" :value="row.last_used_ip" :row="row" />
|
||||
</div>
|
||||
</div>
|
||||
<slot name="empty" />
|
||||
</div>
|
||||
@@ -286,6 +294,7 @@ describe('user KeysView column settings', () => {
|
||||
])
|
||||
expect(visibleColumnKeys(wrapper)).not.toContain('rate_limit')
|
||||
expect(visibleColumnKeys(wrapper)).not.toContain('last_used_at')
|
||||
expect(visibleColumnKeys(wrapper)).not.toContain('last_used_ip')
|
||||
})
|
||||
|
||||
it('shows a hidden column when toggled and persists the preference', async () => {
|
||||
@@ -296,8 +305,28 @@ describe('user KeysView column settings', () => {
|
||||
await nextTick()
|
||||
|
||||
expect(visibleColumnKeys(wrapper)).toContain('rate_limit')
|
||||
expect(localStorage.getItem('api-key-hidden-columns')).toBe(JSON.stringify(['last_used_at']))
|
||||
expect(localStorage.getItem('api-key-column-settings-version')).toBe('1')
|
||||
expect(localStorage.getItem('api-key-hidden-columns')).toBe(
|
||||
JSON.stringify(['last_used_at', 'last_used_ip'])
|
||||
)
|
||||
expect(localStorage.getItem('api-key-column-settings-version')).toBe('2')
|
||||
})
|
||||
|
||||
it('shows the last used IP column when toggled', async () => {
|
||||
listKeys.mockResolvedValueOnce({
|
||||
items: [{ ...createApiKey(), last_used_ip: '203.0.113.10' }],
|
||||
total: 1,
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
pages: 1,
|
||||
})
|
||||
const wrapper = await mountView()
|
||||
|
||||
await wrapper.get('button[title="Column Settings"]').trigger('click')
|
||||
await getButtonByText(wrapper, 'Last Used IP').trigger('click')
|
||||
await nextTick()
|
||||
|
||||
expect(visibleColumnKeys(wrapper)).toContain('last_used_ip')
|
||||
expect(wrapper.get('[data-test="last-used-ip"]').text()).toBe('203.0.113.10')
|
||||
})
|
||||
|
||||
it('restores column preferences from localStorage on mount', async () => {
|
||||
@@ -317,6 +346,10 @@ describe('user KeysView column settings', () => {
|
||||
'last_used_at',
|
||||
'actions',
|
||||
])
|
||||
expect(localStorage.getItem('api-key-hidden-columns')).toBe(
|
||||
JSON.stringify(['group', 'created_at', 'last_used_ip'])
|
||||
)
|
||||
expect(localStorage.getItem('api-key-column-settings-version')).toBe('2')
|
||||
})
|
||||
|
||||
it('does not include always-visible columns in the toggleable menu', async () => {
|
||||
@@ -329,6 +362,7 @@ describe('user KeysView column settings', () => {
|
||||
expect(columnMenuText).toContain('API Key')
|
||||
expect(columnMenuText).toContain('Current Concurrency')
|
||||
expect(columnMenuText).toContain('Rate Limit')
|
||||
expect(columnMenuText).toContain('Last Used IP')
|
||||
expect(columnMenuText).not.toContain('Name')
|
||||
expect(columnMenuText).not.toContain('Actions')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user