mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-04-12 02:54:44 +08:00
Merge tag 'v0.1.90' into merge/upstream-v0.1.90
注册邮箱域名白名单策略上线,后台大数据场景性能大幅优化。 - 注册邮箱域名白名单:支持管理员配置允许注册的邮箱域名策略 - Keys 页面表单筛选:用户 /keys 页面支持按条件筛选 API Key - Settings 页面分 Tab 拆分:管理后台设置页面按功能模块分 Tab 展示 - 后台大数据场景加载性能优化:仪表盘/用户/账号/Ops 页面大数据集加载显著提速 - Usage 大表分页优化:默认避免全量 COUNT(*),大幅降低分页查询耗时 - 消除重复的 normalizeAccountIDList,补充新增组件的单元测试 - 清理无用文件和过时文档,精简项目结构 - EmailVerifyView 硬编码英文字符串替换为 i18n 调用 - 修复 Anthropic 平台无限流重置时间的 429 误标记账号限流问题 - 修复自定义菜单页面管理员视角菜单不生效问题 - 修复 Ops 错误详情弹窗未展示真实上游 payload 的问题 - 修复充值/订阅菜单 icon 显示问题 # Conflicts: # .gitignore # backend/cmd/server/VERSION # backend/ent/group.go # backend/ent/runtime/runtime.go # backend/ent/schema/group.go # backend/go.sum # backend/internal/handler/admin/account_handler.go # backend/internal/handler/admin/dashboard_handler.go # backend/internal/pkg/usagestats/usage_log_types.go # backend/internal/repository/group_repo.go # backend/internal/repository/usage_log_repo.go # backend/internal/server/middleware/security_headers.go # backend/internal/server/router.go # backend/internal/service/account_usage_service.go # backend/internal/service/admin_service_bulk_update_test.go # backend/internal/service/dashboard_service.go # backend/internal/service/gateway_service.go # frontend/src/api/admin/dashboard.ts # frontend/src/components/account/BulkEditAccountModal.vue # frontend/src/components/charts/GroupDistributionChart.vue # frontend/src/components/layout/AppSidebar.vue # frontend/src/i18n/locales/en.ts # frontend/src/i18n/locales/zh.ts # frontend/src/views/admin/GroupsView.vue # frontend/src/views/admin/SettingsView.vue # frontend/src/views/admin/UsageView.vue # frontend/src/views/user/PurchaseSubscriptionView.vue
This commit is contained in:
@@ -52,6 +52,25 @@
|
||||
<span class="font-mono">{{ account.max_sessions }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- RPM 限制(仅 Anthropic OAuth/SetupToken 且启用时显示) -->
|
||||
<div v-if="showRpmLimit" class="flex items-center gap-1">
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-medium',
|
||||
rpmClass
|
||||
]"
|
||||
:title="rpmTooltip"
|
||||
>
|
||||
<svg class="h-2.5 w-2.5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
||||
</svg>
|
||||
<span class="font-mono">{{ currentRPM }}</span>
|
||||
<span class="text-gray-400 dark:text-gray-500">/</span>
|
||||
<span class="font-mono">{{ account.base_rpm }}</span>
|
||||
<span class="text-[9px] opacity-60">{{ rpmStrategyTag }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -125,19 +144,15 @@ const windowCostClass = computed(() => {
|
||||
const limit = props.account.window_cost_limit || 0
|
||||
const reserve = props.account.window_cost_sticky_reserve || 10
|
||||
|
||||
// >= 阈值+预留: 完全不可调度 (红色)
|
||||
if (current >= limit + reserve) {
|
||||
return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
|
||||
}
|
||||
// >= 阈值: 仅粘性会话 (橙色)
|
||||
if (current >= limit) {
|
||||
return 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400'
|
||||
}
|
||||
// >= 80% 阈值: 警告 (黄色)
|
||||
if (current >= limit * 0.8) {
|
||||
return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
|
||||
}
|
||||
// 正常 (绿色)
|
||||
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
|
||||
})
|
||||
|
||||
@@ -165,15 +180,12 @@ const sessionLimitClass = computed(() => {
|
||||
const current = activeSessions.value
|
||||
const max = props.account.max_sessions || 0
|
||||
|
||||
// >= 最大: 完全占满 (红色)
|
||||
if (current >= max) {
|
||||
return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
|
||||
}
|
||||
// >= 80%: 警告 (黄色)
|
||||
if (current >= max * 0.8) {
|
||||
return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
|
||||
}
|
||||
// 正常 (绿色)
|
||||
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
|
||||
})
|
||||
|
||||
@@ -191,6 +203,89 @@ const sessionLimitTooltip = computed(() => {
|
||||
return t('admin.accounts.capacity.sessions.normal', { idle })
|
||||
})
|
||||
|
||||
// 是否显示 RPM 限制
|
||||
const showRpmLimit = computed(() => {
|
||||
return (
|
||||
isAnthropicOAuthOrSetupToken.value &&
|
||||
props.account.base_rpm !== undefined &&
|
||||
props.account.base_rpm !== null &&
|
||||
props.account.base_rpm > 0
|
||||
)
|
||||
})
|
||||
|
||||
// 当前 RPM 计数
|
||||
const currentRPM = computed(() => props.account.current_rpm ?? 0)
|
||||
|
||||
// RPM 策略
|
||||
const rpmStrategy = computed(() => props.account.rpm_strategy || 'tiered')
|
||||
|
||||
// RPM 策略标签
|
||||
const rpmStrategyTag = computed(() => {
|
||||
return rpmStrategy.value === 'sticky_exempt' ? '[S]' : '[T]'
|
||||
})
|
||||
|
||||
// RPM buffer 计算(与后端一致:base <= 0 时 buffer 为 0)
|
||||
const rpmBuffer = computed(() => {
|
||||
const base = props.account.base_rpm || 0
|
||||
return props.account.rpm_sticky_buffer ?? (base > 0 ? Math.max(1, Math.floor(base / 5)) : 0)
|
||||
})
|
||||
|
||||
// RPM 状态样式
|
||||
const rpmClass = computed(() => {
|
||||
if (!showRpmLimit.value) return ''
|
||||
|
||||
const current = currentRPM.value
|
||||
const base = props.account.base_rpm ?? 0
|
||||
const buffer = rpmBuffer.value
|
||||
|
||||
if (rpmStrategy.value === 'tiered') {
|
||||
if (current >= base + buffer) {
|
||||
return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
|
||||
}
|
||||
if (current >= base) {
|
||||
return 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400'
|
||||
}
|
||||
} else {
|
||||
if (current >= base) {
|
||||
return 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400'
|
||||
}
|
||||
}
|
||||
if (current >= base * 0.8) {
|
||||
return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
|
||||
}
|
||||
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
|
||||
})
|
||||
|
||||
// RPM 提示文字(增强版:显示策略、区域、缓冲区)
|
||||
const rpmTooltip = computed(() => {
|
||||
if (!showRpmLimit.value) return ''
|
||||
|
||||
const current = currentRPM.value
|
||||
const base = props.account.base_rpm ?? 0
|
||||
const buffer = rpmBuffer.value
|
||||
|
||||
if (rpmStrategy.value === 'tiered') {
|
||||
if (current >= base + buffer) {
|
||||
return t('admin.accounts.capacity.rpm.tieredBlocked', { buffer })
|
||||
}
|
||||
if (current >= base) {
|
||||
return t('admin.accounts.capacity.rpm.tieredStickyOnly', { buffer })
|
||||
}
|
||||
if (current >= base * 0.8) {
|
||||
return t('admin.accounts.capacity.rpm.tieredWarning')
|
||||
}
|
||||
return t('admin.accounts.capacity.rpm.tieredNormal')
|
||||
} else {
|
||||
if (current >= base) {
|
||||
return t('admin.accounts.capacity.rpm.stickyExemptOver')
|
||||
}
|
||||
if (current >= base * 0.8) {
|
||||
return t('admin.accounts.capacity.rpm.stickyExemptWarning')
|
||||
}
|
||||
return t('admin.accounts.capacity.rpm.stickyExemptNormal')
|
||||
}
|
||||
})
|
||||
|
||||
// 格式化费用显示
|
||||
const formatCost = (value: number | null | undefined) => {
|
||||
if (value === null || value === undefined) return '0'
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="space-y-0.5">
|
||||
<div v-if="props.loading && !props.stats" class="space-y-0.5">
|
||||
<div class="h-3 w-12 animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div class="h-3 w-16 animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div class="h-3 w-10 animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<div v-else-if="error" class="text-xs text-red-500">
|
||||
{{ error }}
|
||||
<div v-else-if="props.error && !props.stats" class="text-xs text-red-500">
|
||||
{{ props.error }}
|
||||
</div>
|
||||
|
||||
<!-- Stats data -->
|
||||
<div v-else-if="stats" class="space-y-0.5 text-xs">
|
||||
<div v-else-if="props.stats" class="space-y-0.5 text-xs">
|
||||
<!-- Requests -->
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-gray-500 dark:text-gray-400"
|
||||
>{{ t('admin.accounts.stats.requests') }}:</span
|
||||
>
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">{{
|
||||
formatNumber(stats.requests)
|
||||
formatNumber(props.stats.requests)
|
||||
}}</span>
|
||||
</div>
|
||||
<!-- Tokens -->
|
||||
@@ -29,21 +29,21 @@
|
||||
>{{ t('admin.accounts.stats.tokens') }}:</span
|
||||
>
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">{{
|
||||
formatTokens(stats.tokens)
|
||||
formatTokens(props.stats.tokens)
|
||||
}}</span>
|
||||
</div>
|
||||
<!-- Cost (Account) -->
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('usage.accountBilled') }}:</span>
|
||||
<span class="font-medium text-emerald-600 dark:text-emerald-400">{{
|
||||
formatCurrency(stats.cost)
|
||||
formatCurrency(props.stats.cost)
|
||||
}}</span>
|
||||
</div>
|
||||
<!-- Cost (User/API Key) -->
|
||||
<div v-if="stats.user_cost != null" class="flex items-center gap-1">
|
||||
<div v-if="props.stats.user_cost != null" class="flex items-center gap-1">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('usage.userBilled') }}:</span>
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">{{
|
||||
formatCurrency(stats.user_cost)
|
||||
formatCurrency(props.stats.user_cost)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -54,22 +54,25 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { Account, WindowStats } from '@/types'
|
||||
import type { WindowStats } from '@/types'
|
||||
import { formatNumber, formatCurrency } from '@/utils/format'
|
||||
|
||||
const props = defineProps<{
|
||||
account: Account
|
||||
}>()
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
stats?: WindowStats | null
|
||||
loading?: boolean
|
||||
error?: string | null
|
||||
}>(),
|
||||
{
|
||||
stats: null,
|
||||
loading: false,
|
||||
error: null
|
||||
}
|
||||
)
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const stats = ref<WindowStats | null>(null)
|
||||
|
||||
// Format large token numbers (e.g., 1234567 -> 1.23M)
|
||||
const formatTokens = (tokens: number): string => {
|
||||
if (tokens >= 1000000) {
|
||||
@@ -79,22 +82,4 @@ const formatTokens = (tokens: number): string => {
|
||||
}
|
||||
return tokens.toString()
|
||||
}
|
||||
|
||||
const loadStats = async () => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
stats.value = await adminAPI.accounts.getTodayStats(props.account.id)
|
||||
} catch (e: any) {
|
||||
error.value = 'Failed'
|
||||
console.error('Failed to load today stats:', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadStats()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -398,7 +398,9 @@ const antigravity3ProUsageFromAPI = computed(() =>
|
||||
const antigravity3FlashUsageFromAPI = computed(() => getAntigravityUsageFromAPI(['gemini-3-flash']))
|
||||
|
||||
// Gemini Image from API
|
||||
const antigravity3ImageUsageFromAPI = computed(() => getAntigravityUsageFromAPI(['gemini-3.1-flash-image']))
|
||||
const antigravity3ImageUsageFromAPI = computed(() =>
|
||||
getAntigravityUsageFromAPI(['gemini-3.1-flash-image', 'gemini-3-pro-image'])
|
||||
)
|
||||
|
||||
// Claude from API (all Claude model variants)
|
||||
const antigravityClaudeUsageFromAPI = computed(() =>
|
||||
|
||||
@@ -585,6 +585,132 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RPM Limit (仅全部为 Anthropic OAuth/SetupToken 时显示) -->
|
||||
<div v-if="allAnthropicOAuthOrSetupToken" class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<label
|
||||
id="bulk-edit-rpm-limit-label"
|
||||
class="input-label mb-0"
|
||||
for="bulk-edit-rpm-limit-enabled"
|
||||
>
|
||||
{{ t('admin.accounts.quotaControl.rpmLimit.label') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="enableRpmLimit"
|
||||
id="bulk-edit-rpm-limit-enabled"
|
||||
type="checkbox"
|
||||
aria-controls="bulk-edit-rpm-limit-body"
|
||||
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="bulk-edit-rpm-limit-body"
|
||||
:class="!enableRpmLimit && 'pointer-events-none opacity-50'"
|
||||
role="group"
|
||||
aria-labelledby="bulk-edit-rpm-limit-label"
|
||||
>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{ t('admin.accounts.quotaControl.rpmLimit.hint') }}</span>
|
||||
<button
|
||||
type="button"
|
||||
@click="rpmLimitEnabled = !rpmLimitEnabled"
|
||||
:class="[
|
||||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||||
rpmLimitEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||
rpmLimitEnabled ? 'translate-x-5' : 'translate-x-0'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="rpmLimitEnabled" class="space-y-3">
|
||||
<div>
|
||||
<label class="input-label text-xs">{{ t('admin.accounts.quotaControl.rpmLimit.baseRpm') }}</label>
|
||||
<input
|
||||
v-model.number="bulkBaseRpm"
|
||||
type="number"
|
||||
min="1"
|
||||
max="1000"
|
||||
step="1"
|
||||
class="input"
|
||||
:placeholder="t('admin.accounts.quotaControl.rpmLimit.baseRpmPlaceholder')"
|
||||
/>
|
||||
<p class="input-hint">{{ t('admin.accounts.quotaControl.rpmLimit.baseRpmHint') }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label text-xs">{{ t('admin.accounts.quotaControl.rpmLimit.strategy') }}</label>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@click="bulkRpmStrategy = 'tiered'"
|
||||
:class="[
|
||||
'flex-1 rounded-lg px-3 py-2 text-sm font-medium transition-all',
|
||||
bulkRpmStrategy === 'tiered'
|
||||
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
|
||||
]"
|
||||
>
|
||||
{{ t('admin.accounts.quotaControl.rpmLimit.strategyTiered') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="bulkRpmStrategy = 'sticky_exempt'"
|
||||
:class="[
|
||||
'flex-1 rounded-lg px-3 py-2 text-sm font-medium transition-all',
|
||||
bulkRpmStrategy === 'sticky_exempt'
|
||||
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
|
||||
]"
|
||||
>
|
||||
{{ t('admin.accounts.quotaControl.rpmLimit.strategyStickyExempt') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="bulkRpmStrategy === 'tiered'">
|
||||
<label class="input-label text-xs">{{ t('admin.accounts.quotaControl.rpmLimit.stickyBuffer') }}</label>
|
||||
<input
|
||||
v-model.number="bulkRpmStickyBuffer"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
class="input"
|
||||
:placeholder="t('admin.accounts.quotaControl.rpmLimit.stickyBufferPlaceholder')"
|
||||
/>
|
||||
<p class="input-hint">{{ t('admin.accounts.quotaControl.rpmLimit.stickyBufferHint') }}</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 用户消息限速模式(独立于 RPM 开关,始终可见) -->
|
||||
<div class="mt-4">
|
||||
<label class="input-label">{{ t('admin.accounts.quotaControl.rpmLimit.userMsgQueue') }}</label>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400 mb-2">
|
||||
{{ t('admin.accounts.quotaControl.rpmLimit.userMsgQueueHint') }}
|
||||
</p>
|
||||
<div class="flex space-x-2">
|
||||
<button type="button" v-for="opt in umqModeOptions" :key="opt.value"
|
||||
@click="userMsgQueueMode = userMsgQueueMode === opt.value ? null : opt.value"
|
||||
:class="[
|
||||
'px-3 py-1.5 text-sm rounded-md border transition-colors',
|
||||
userMsgQueueMode === opt.value
|
||||
? 'bg-primary-600 text-white border-primary-600'
|
||||
: 'bg-white dark:bg-dark-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-dark-500 hover:bg-gray-50 dark:hover:bg-dark-600'
|
||||
]">
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Groups -->
|
||||
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
@@ -669,7 +795,7 @@ import { ref, watch, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { Proxy as ProxyConfig, AdminGroup, AccountPlatform } from '@/types'
|
||||
import type { Proxy as ProxyConfig, AdminGroup, AccountPlatform, AccountType } from '@/types'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
@@ -682,6 +808,7 @@ interface Props {
|
||||
show: boolean
|
||||
accountIds: number[]
|
||||
selectedPlatforms: AccountPlatform[]
|
||||
selectedTypes: AccountType[]
|
||||
proxies: ProxyConfig[]
|
||||
groups: AdminGroup[]
|
||||
}
|
||||
@@ -698,9 +825,18 @@ const appStore = useAppStore()
|
||||
// Platform awareness
|
||||
const isMixedPlatform = computed(() => props.selectedPlatforms.length > 1)
|
||||
|
||||
// 是否全部为 Anthropic OAuth/SetupToken(RPM 配置仅在此条件下显示)
|
||||
const allAnthropicOAuthOrSetupToken = computed(() => {
|
||||
return (
|
||||
props.selectedPlatforms.length === 1 &&
|
||||
props.selectedPlatforms[0] === 'anthropic' &&
|
||||
props.selectedTypes.every(t => t === 'oauth' || t === 'setup-token')
|
||||
)
|
||||
})
|
||||
|
||||
const platformModelPrefix: Record<string, string[]> = {
|
||||
anthropic: ['claude-'],
|
||||
antigravity: ['claude-'],
|
||||
antigravity: ['claude-', 'gemini-', 'gpt-oss-', 'tab_'],
|
||||
openai: ['gpt-'],
|
||||
gemini: ['gemini-'],
|
||||
sora: []
|
||||
@@ -737,6 +873,7 @@ const enablePriority = ref(false)
|
||||
const enableRateMultiplier = ref(false)
|
||||
const enableStatus = ref(false)
|
||||
const enableGroups = ref(false)
|
||||
const enableRpmLimit = ref(false)
|
||||
|
||||
// State - field values
|
||||
const submitting = ref(false)
|
||||
@@ -756,6 +893,16 @@ const priority = ref(1)
|
||||
const rateMultiplier = ref(1)
|
||||
const status = ref<'active' | 'inactive'>('active')
|
||||
const groupIds = ref<number[]>([])
|
||||
const rpmLimitEnabled = ref(false)
|
||||
const bulkBaseRpm = ref<number | null>(null)
|
||||
const bulkRpmStrategy = ref<'tiered' | 'sticky_exempt'>('tiered')
|
||||
const bulkRpmStickyBuffer = ref<number | null>(null)
|
||||
const userMsgQueueMode = ref<string | null>(null)
|
||||
const umqModeOptions = computed(() => [
|
||||
{ value: '', label: t('admin.accounts.quotaControl.rpmLimit.umqModeOff') },
|
||||
{ value: 'throttle', label: t('admin.accounts.quotaControl.rpmLimit.umqModeThrottle') },
|
||||
{ value: 'serialize', label: t('admin.accounts.quotaControl.rpmLimit.umqModeSerialize') },
|
||||
])
|
||||
|
||||
// All models list (combined Anthropic + OpenAI + Gemini)
|
||||
const allModels = [
|
||||
@@ -781,6 +928,8 @@ const allModels = [
|
||||
{ value: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash' },
|
||||
{ value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' },
|
||||
{ value: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro' },
|
||||
{ value: 'gemini-3.1-flash-image', label: 'Gemini 3.1 Flash Image' },
|
||||
{ value: 'gemini-3-pro-image', label: 'Gemini 3 Pro Image (Legacy)' },
|
||||
{ value: 'gemini-3-flash-preview', label: 'Gemini 3 Flash Preview' },
|
||||
{ value: 'gemini-3-pro-preview', label: 'Gemini 3 Pro Preview' }
|
||||
]
|
||||
@@ -859,6 +1008,18 @@ const presetMappings = [
|
||||
to: 'claude-sonnet-4-5-20250929',
|
||||
color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400'
|
||||
},
|
||||
{
|
||||
label: 'Gemini 3.1 Image',
|
||||
from: 'gemini-3.1-flash-image',
|
||||
to: 'gemini-3.1-flash-image',
|
||||
color: 'bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-400'
|
||||
},
|
||||
{
|
||||
label: 'G3 Image→3.1',
|
||||
from: 'gemini-3-pro-image',
|
||||
to: 'gemini-3.1-flash-image',
|
||||
color: 'bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-400'
|
||||
},
|
||||
{
|
||||
label: 'GPT-5.3 Codex',
|
||||
from: 'gpt-5.3-codex',
|
||||
@@ -1095,6 +1256,34 @@ const buildUpdatePayload = (): Record<string, unknown> | null => {
|
||||
updates.credentials = credentials
|
||||
}
|
||||
|
||||
// RPM limit settings (写入 extra 字段)
|
||||
if (enableRpmLimit.value) {
|
||||
const extra: Record<string, unknown> = {}
|
||||
if (rpmLimitEnabled.value && bulkBaseRpm.value != null && bulkBaseRpm.value > 0) {
|
||||
extra.base_rpm = bulkBaseRpm.value
|
||||
extra.rpm_strategy = bulkRpmStrategy.value
|
||||
if (bulkRpmStickyBuffer.value != null && bulkRpmStickyBuffer.value > 0) {
|
||||
extra.rpm_sticky_buffer = bulkRpmStickyBuffer.value
|
||||
}
|
||||
} else {
|
||||
// 关闭 RPM 限制 - 设置 base_rpm 为 0,并用空值覆盖关联字段
|
||||
// 后端使用 JSONB || merge 语义,不会删除已有 key,
|
||||
// 所以必须显式发送空值来重置(后端读取时会 fallback 到默认值)
|
||||
extra.base_rpm = 0
|
||||
extra.rpm_strategy = ''
|
||||
extra.rpm_sticky_buffer = 0
|
||||
}
|
||||
updates.extra = extra
|
||||
}
|
||||
|
||||
// UMQ mode(独立于 RPM 保存)
|
||||
if (userMsgQueueMode.value !== null) {
|
||||
if (!updates.extra) updates.extra = {}
|
||||
const umqExtra = updates.extra as Record<string, unknown>
|
||||
umqExtra.user_msg_queue_mode = userMsgQueueMode.value // '' = 清除账号级覆盖
|
||||
umqExtra.user_msg_queue_enabled = false // 清理旧字段(JSONB merge)
|
||||
}
|
||||
|
||||
return Object.keys(updates).length > 0 ? updates : null
|
||||
}
|
||||
|
||||
@@ -1129,11 +1318,7 @@ const preCheckMixedChannelRisk = async (built: Record<string, unknown>): Promise
|
||||
if (!result.has_risk) return true
|
||||
|
||||
pendingUpdatesForConfirm.value = built
|
||||
mixedChannelWarningMessage.value = t('admin.accounts.mixedChannelWarning', {
|
||||
groupName: result.details?.group_name,
|
||||
currentPlatform: result.details?.current_platform,
|
||||
otherPlatform: result.details?.other_platform
|
||||
})
|
||||
mixedChannelWarningMessage.value = result.message || t('admin.accounts.bulkEdit.failed')
|
||||
showMixedChannelWarning.value = true
|
||||
return false
|
||||
} catch (error: any) {
|
||||
@@ -1158,7 +1343,9 @@ const handleSubmit = async () => {
|
||||
enablePriority.value ||
|
||||
enableRateMultiplier.value ||
|
||||
enableStatus.value ||
|
||||
enableGroups.value
|
||||
enableGroups.value ||
|
||||
enableRpmLimit.value ||
|
||||
userMsgQueueMode.value !== null
|
||||
|
||||
if (!hasAnyFieldEnabled) {
|
||||
appStore.showError(t('admin.accounts.bulkEdit.noFieldsSelected'))
|
||||
@@ -1207,11 +1394,7 @@ const submitBulkUpdate = async (baseUpdates: Record<string, unknown>) => {
|
||||
// 兜底:多平台混合场景下,预检查跳过,由后端 409 触发确认框
|
||||
if (error.status === 409 && error.error === 'mixed_channel_warning') {
|
||||
pendingUpdatesForConfirm.value = baseUpdates
|
||||
mixedChannelWarningMessage.value = t('admin.accounts.mixedChannelWarning', {
|
||||
groupName: error.details?.group_name,
|
||||
currentPlatform: error.details?.current_platform,
|
||||
otherPlatform: error.details?.other_platform
|
||||
})
|
||||
mixedChannelWarningMessage.value = error.message
|
||||
showMixedChannelWarning.value = true
|
||||
} else {
|
||||
appStore.showError(error.message || t('admin.accounts.bulkEdit.failed'))
|
||||
@@ -1251,6 +1434,7 @@ watch(
|
||||
enableRateMultiplier.value = false
|
||||
enableStatus.value = false
|
||||
enableGroups.value = false
|
||||
enableRpmLimit.value = false
|
||||
|
||||
// Reset all values
|
||||
baseUrl.value = ''
|
||||
@@ -1266,6 +1450,11 @@ watch(
|
||||
rateMultiplier.value = 1
|
||||
status.value = 'active'
|
||||
groupIds.value = []
|
||||
rpmLimitEnabled.value = false
|
||||
bulkBaseRpm.value = null
|
||||
bulkRpmStrategy.value = 'tiered'
|
||||
bulkRpmStickyBuffer.value = null
|
||||
userMsgQueueMode.value = null
|
||||
|
||||
// Reset mixed channel warning state
|
||||
showMixedChannelWarning.value = false
|
||||
|
||||
@@ -175,13 +175,13 @@
|
||||
<!-- Account Type Selection (Sora) -->
|
||||
<div v-if="form.platform === 'sora'">
|
||||
<label class="input-label">{{ t('admin.accounts.accountType') }}</label>
|
||||
<div class="mt-2 grid grid-cols-1 gap-3" data-tour="account-form-type">
|
||||
<div class="mt-2 grid grid-cols-2 gap-3" data-tour="account-form-type">
|
||||
<button
|
||||
type="button"
|
||||
@click="accountCategory = 'oauth-based'"
|
||||
@click="soraAccountType = 'oauth'; accountCategory = 'oauth-based'; addMethod = 'oauth'"
|
||||
:class="[
|
||||
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
|
||||
accountCategory === 'oauth-based'
|
||||
soraAccountType === 'oauth'
|
||||
? 'border-rose-500 bg-rose-50 dark:bg-rose-900/20'
|
||||
: 'border-gray-200 hover:border-rose-300 dark:border-dark-600 dark:hover:border-rose-700'
|
||||
]"
|
||||
@@ -189,7 +189,7 @@
|
||||
<div
|
||||
:class="[
|
||||
'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg',
|
||||
accountCategory === 'oauth-based'
|
||||
soraAccountType === 'oauth'
|
||||
? 'bg-rose-500 text-white'
|
||||
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
|
||||
]"
|
||||
@@ -201,6 +201,31 @@
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.types.chatgptOauth') }}</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="soraAccountType = 'apikey'; accountCategory = 'apikey'"
|
||||
:class="[
|
||||
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
|
||||
soraAccountType === 'apikey'
|
||||
? 'border-rose-500 bg-rose-50 dark:bg-rose-900/20'
|
||||
: 'border-gray-200 hover:border-rose-300 dark:border-dark-600 dark:hover:border-rose-700'
|
||||
]"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg',
|
||||
soraAccountType === 'apikey'
|
||||
? 'bg-rose-500 text-white'
|
||||
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
|
||||
]"
|
||||
>
|
||||
<Icon name="link" size="sm" />
|
||||
</div>
|
||||
<div>
|
||||
<span class="block text-sm font-medium text-gray-900 dark:text-white">{{ t('admin.accounts.types.soraApiKey') }}</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.types.soraApiKeyHint') }}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -879,14 +904,14 @@
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="
|
||||
form.platform === 'openai'
|
||||
form.platform === 'openai' || form.platform === 'sora'
|
||||
? 'https://api.openai.com'
|
||||
: form.platform === 'gemini'
|
||||
? 'https://generativelanguage.googleapis.com'
|
||||
: 'https://api.anthropic.com'
|
||||
"
|
||||
/>
|
||||
<p class="input-hint">{{ baseUrlHint }}</p>
|
||||
<p class="input-hint">{{ form.platform === 'sora' ? t('admin.accounts.soraUpstreamBaseUrlHint') : baseUrlHint }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.apiKeyRequired') }}</label>
|
||||
@@ -1511,6 +1536,119 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RPM Limit -->
|
||||
<div class="rounded-lg border border-gray-200 p-4 dark:border-dark-600">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<label class="input-label mb-0">{{ t('admin.accounts.quotaControl.rpmLimit.label') }}</label>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.quotaControl.rpmLimit.hint') }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="rpmLimitEnabled = !rpmLimitEnabled"
|
||||
:class="[
|
||||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||||
rpmLimitEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||
rpmLimitEnabled ? 'translate-x-5' : 'translate-x-0'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="rpmLimitEnabled" class="space-y-4">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.quotaControl.rpmLimit.baseRpm') }}</label>
|
||||
<input
|
||||
v-model.number="baseRpm"
|
||||
type="number"
|
||||
min="1"
|
||||
max="1000"
|
||||
step="1"
|
||||
class="input"
|
||||
:placeholder="t('admin.accounts.quotaControl.rpmLimit.baseRpmPlaceholder')"
|
||||
/>
|
||||
<p class="input-hint">{{ t('admin.accounts.quotaControl.rpmLimit.baseRpmHint') }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.quotaControl.rpmLimit.strategy') }}</label>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@click="rpmStrategy = 'tiered'"
|
||||
:class="[
|
||||
'flex-1 rounded-lg px-3 py-2 text-sm font-medium transition-all',
|
||||
rpmStrategy === 'tiered'
|
||||
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
|
||||
]"
|
||||
>
|
||||
<div class="text-center">
|
||||
<div>{{ t('admin.accounts.quotaControl.rpmLimit.strategyTiered') }}</div>
|
||||
<div class="mt-0.5 text-[10px] opacity-70">{{ t('admin.accounts.quotaControl.rpmLimit.strategyTieredHint') }}</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="rpmStrategy = 'sticky_exempt'"
|
||||
:class="[
|
||||
'flex-1 rounded-lg px-3 py-2 text-sm font-medium transition-all',
|
||||
rpmStrategy === 'sticky_exempt'
|
||||
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
|
||||
]"
|
||||
>
|
||||
<div class="text-center">
|
||||
<div>{{ t('admin.accounts.quotaControl.rpmLimit.strategyStickyExempt') }}</div>
|
||||
<div class="mt-0.5 text-[10px] opacity-70">{{ t('admin.accounts.quotaControl.rpmLimit.strategyStickyExemptHint') }}</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="rpmStrategy === 'tiered'">
|
||||
<label class="input-label">{{ t('admin.accounts.quotaControl.rpmLimit.stickyBuffer') }}</label>
|
||||
<input
|
||||
v-model.number="rpmStickyBuffer"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
class="input"
|
||||
:placeholder="t('admin.accounts.quotaControl.rpmLimit.stickyBufferPlaceholder')"
|
||||
/>
|
||||
<p class="input-hint">{{ t('admin.accounts.quotaControl.rpmLimit.stickyBufferHint') }}</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 用户消息限速模式(独立于 RPM 开关,始终可见) -->
|
||||
<div class="mt-4">
|
||||
<label class="input-label">{{ t('admin.accounts.quotaControl.rpmLimit.userMsgQueue') }}</label>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400 mb-2">
|
||||
{{ t('admin.accounts.quotaControl.rpmLimit.userMsgQueueHint') }}
|
||||
</p>
|
||||
<div class="flex space-x-2">
|
||||
<button type="button" v-for="opt in umqModeOptions" :key="opt.value"
|
||||
@click="userMsgQueueMode = opt.value"
|
||||
:class="[
|
||||
'px-3 py-1.5 text-sm rounded-md border transition-colors',
|
||||
userMsgQueueMode === opt.value
|
||||
? 'bg-primary-600 text-white border-primary-600'
|
||||
: 'bg-white dark:bg-dark-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-dark-500 hover:bg-gray-50 dark:hover:bg-dark-600'
|
||||
]">
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TLS Fingerprint -->
|
||||
<div class="rounded-lg border border-gray-200 p-4 dark:border-dark-600">
|
||||
<div class="flex items-center justify-between">
|
||||
@@ -1669,6 +1807,27 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OpenAI WS Mode 三态(off/shared/dedicated) -->
|
||||
<div
|
||||
v-if="form.platform === 'openai' && (accountCategory === 'oauth-based' || accountCategory === 'apikey')"
|
||||
class="border-t border-gray-200 pt-4 dark:border-dark-600"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="input-label mb-0">{{ t('admin.accounts.openai.wsMode') }}</label>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.openai.wsModeDesc') }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.openai.wsModeConcurrencyHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="w-52">
|
||||
<Select v-model="openaiResponsesWebSocketV2Mode" :options="openAIWSModeOptions" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Anthropic API Key 自动透传开关 -->
|
||||
<div
|
||||
v-if="form.platform === 'anthropic' && accountCategory === 'apikey'"
|
||||
@@ -2173,6 +2332,7 @@ import type {
|
||||
} from '@/types'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import ProxySelector from '@/components/common/ProxySelector.vue'
|
||||
import GroupSelector from '@/components/common/GroupSelector.vue'
|
||||
@@ -2180,6 +2340,13 @@ import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.
|
||||
import { applyInterceptWarmup } from '@/components/account/credentialsBuilder'
|
||||
import { formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format'
|
||||
import { createStableObjectKeyResolver } from '@/utils/stableObjectKey'
|
||||
import {
|
||||
OPENAI_WS_MODE_DEDICATED,
|
||||
OPENAI_WS_MODE_OFF,
|
||||
OPENAI_WS_MODE_SHARED,
|
||||
isOpenAIWSModeEnabled,
|
||||
type OpenAIWSMode
|
||||
} from '@/utils/openaiWsMode'
|
||||
import OAuthAuthorizationFlow from './OAuthAuthorizationFlow.vue'
|
||||
|
||||
// Type for exposed OAuthAuthorizationFlow component
|
||||
@@ -2301,10 +2468,13 @@ const customErrorCodeInput = ref<number | null>(null)
|
||||
const interceptWarmupRequests = ref(false)
|
||||
const autoPauseOnExpired = ref(true)
|
||||
const openaiPassthroughEnabled = ref(false)
|
||||
const openaiOAuthResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
|
||||
const openaiAPIKeyResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
|
||||
const codexCLIOnlyEnabled = ref(false)
|
||||
const anthropicPassthroughEnabled = ref(false)
|
||||
const mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling
|
||||
const antigravityAccountType = ref<'oauth' | 'upstream'>('oauth') // For antigravity: oauth or upstream
|
||||
const soraAccountType = ref<'oauth' | 'apikey'>('oauth') // For sora: oauth or apikey (upstream)
|
||||
const upstreamBaseUrl = ref('') // For upstream type: base URL
|
||||
const upstreamApiKey = ref('') // For upstream type: API key
|
||||
const antigravityModelRestrictionMode = ref<'whitelist' | 'mapping'>('whitelist')
|
||||
@@ -2336,6 +2506,16 @@ const windowCostStickyReserve = ref<number | null>(null)
|
||||
const sessionLimitEnabled = ref(false)
|
||||
const maxSessions = ref<number | null>(null)
|
||||
const sessionIdleTimeout = ref<number | null>(null)
|
||||
const rpmLimitEnabled = ref(false)
|
||||
const baseRpm = ref<number | null>(null)
|
||||
const rpmStrategy = ref<'tiered' | 'sticky_exempt'>('tiered')
|
||||
const rpmStickyBuffer = ref<number | null>(null)
|
||||
const userMsgQueueMode = ref('')
|
||||
const umqModeOptions = computed(() => [
|
||||
{ value: '', label: t('admin.accounts.quotaControl.rpmLimit.umqModeOff') },
|
||||
{ value: 'throttle', label: t('admin.accounts.quotaControl.rpmLimit.umqModeThrottle') },
|
||||
{ value: 'serialize', label: t('admin.accounts.quotaControl.rpmLimit.umqModeSerialize') },
|
||||
])
|
||||
const tlsFingerprintEnabled = ref(false)
|
||||
const sessionIdMaskingEnabled = ref(false)
|
||||
const cacheTTLOverrideEnabled = ref(false)
|
||||
@@ -2359,6 +2539,28 @@ const geminiSelectedTier = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const openAIWSModeOptions = computed(() => [
|
||||
{ value: OPENAI_WS_MODE_OFF, label: t('admin.accounts.openai.wsModeOff') },
|
||||
{ value: OPENAI_WS_MODE_SHARED, label: t('admin.accounts.openai.wsModeShared') },
|
||||
{ value: OPENAI_WS_MODE_DEDICATED, label: t('admin.accounts.openai.wsModeDedicated') }
|
||||
])
|
||||
|
||||
const openaiResponsesWebSocketV2Mode = computed({
|
||||
get: () => {
|
||||
if (form.platform === 'openai' && accountCategory.value === 'apikey') {
|
||||
return openaiAPIKeyResponsesWebSocketV2Mode.value
|
||||
}
|
||||
return openaiOAuthResponsesWebSocketV2Mode.value
|
||||
},
|
||||
set: (mode: OpenAIWSMode) => {
|
||||
if (form.platform === 'openai' && accountCategory.value === 'apikey') {
|
||||
openaiAPIKeyResponsesWebSocketV2Mode.value = mode
|
||||
return
|
||||
}
|
||||
openaiOAuthResponsesWebSocketV2Mode.value = mode
|
||||
}
|
||||
})
|
||||
|
||||
const isOpenAIModelRestrictionDisabled = computed(() =>
|
||||
form.platform === 'openai' && openaiPassthroughEnabled.value
|
||||
)
|
||||
@@ -2490,15 +2692,20 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
// Sync form.type based on accountCategory, addMethod, and antigravityAccountType
|
||||
// Sync form.type based on accountCategory, addMethod, and platform-specific type
|
||||
watch(
|
||||
[accountCategory, addMethod, antigravityAccountType],
|
||||
([category, method, agType]) => {
|
||||
[accountCategory, addMethod, antigravityAccountType, soraAccountType],
|
||||
([category, method, agType, soraType]) => {
|
||||
// Antigravity upstream 类型(实际创建为 apikey)
|
||||
if (form.platform === 'antigravity' && agType === 'upstream') {
|
||||
form.type = 'apikey'
|
||||
return
|
||||
}
|
||||
// Sora apikey 类型(上游透传)
|
||||
if (form.platform === 'sora' && soraType === 'apikey') {
|
||||
form.type = 'apikey'
|
||||
return
|
||||
}
|
||||
if (category === 'oauth-based') {
|
||||
form.type = method as AccountType // 'oauth' or 'setup-token'
|
||||
} else {
|
||||
@@ -2541,12 +2748,16 @@ watch(
|
||||
interceptWarmupRequests.value = false
|
||||
}
|
||||
if (newPlatform === 'sora') {
|
||||
// 默认 OAuth,但允许用户选择 API Key
|
||||
accountCategory.value = 'oauth-based'
|
||||
addMethod.value = 'oauth'
|
||||
form.type = 'oauth'
|
||||
soraAccountType.value = 'oauth'
|
||||
}
|
||||
if (newPlatform !== 'openai') {
|
||||
openaiPassthroughEnabled.value = false
|
||||
openaiOAuthResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
|
||||
openaiAPIKeyResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
|
||||
codexCLIOnlyEnabled.value = false
|
||||
}
|
||||
if (newPlatform !== 'anthropic') {
|
||||
@@ -2918,6 +3129,8 @@ const resetForm = () => {
|
||||
interceptWarmupRequests.value = false
|
||||
autoPauseOnExpired.value = true
|
||||
openaiPassthroughEnabled.value = false
|
||||
openaiOAuthResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
|
||||
openaiAPIKeyResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
|
||||
codexCLIOnlyEnabled.value = false
|
||||
anthropicPassthroughEnabled.value = false
|
||||
// Reset quota control state
|
||||
@@ -2927,6 +3140,11 @@ const resetForm = () => {
|
||||
sessionLimitEnabled.value = false
|
||||
maxSessions.value = null
|
||||
sessionIdleTimeout.value = null
|
||||
rpmLimitEnabled.value = false
|
||||
baseRpm.value = null
|
||||
rpmStrategy.value = 'tiered'
|
||||
rpmStickyBuffer.value = null
|
||||
userMsgQueueMode.value = ''
|
||||
tlsFingerprintEnabled.value = false
|
||||
sessionIdMaskingEnabled.value = false
|
||||
cacheTTLOverrideEnabled.value = false
|
||||
@@ -2962,6 +3180,13 @@ const buildOpenAIExtra = (base?: Record<string, unknown>): Record<string, unknow
|
||||
}
|
||||
|
||||
const extra: Record<string, unknown> = { ...(base || {}) }
|
||||
extra.openai_oauth_responses_websockets_v2_mode = openaiOAuthResponsesWebSocketV2Mode.value
|
||||
extra.openai_apikey_responses_websockets_v2_mode = openaiAPIKeyResponsesWebSocketV2Mode.value
|
||||
extra.openai_oauth_responses_websockets_v2_enabled = isOpenAIWSModeEnabled(openaiOAuthResponsesWebSocketV2Mode.value)
|
||||
extra.openai_apikey_responses_websockets_v2_enabled = isOpenAIWSModeEnabled(openaiAPIKeyResponsesWebSocketV2Mode.value)
|
||||
// 清理兼容旧键,统一改用分类型开关。
|
||||
delete extra.responses_websockets_v2_enabled
|
||||
delete extra.openai_ws_enabled
|
||||
if (openaiPassthroughEnabled.value) {
|
||||
extra.openai_passthrough = true
|
||||
} else {
|
||||
@@ -3007,6 +3232,12 @@ const buildSoraExtra = (
|
||||
delete extra.openai_passthrough
|
||||
delete extra.openai_oauth_passthrough
|
||||
delete extra.codex_cli_only
|
||||
delete extra.openai_oauth_responses_websockets_v2_mode
|
||||
delete extra.openai_apikey_responses_websockets_v2_mode
|
||||
delete extra.openai_oauth_responses_websockets_v2_enabled
|
||||
delete extra.openai_apikey_responses_websockets_v2_enabled
|
||||
delete extra.responses_websockets_v2_enabled
|
||||
delete extra.openai_ws_enabled
|
||||
return Object.keys(extra).length > 0 ? extra : undefined
|
||||
}
|
||||
|
||||
@@ -3102,9 +3333,22 @@ const handleSubmit = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
// Sora apikey 账号 base_url 必填 + scheme 校验
|
||||
if (form.platform === 'sora') {
|
||||
const soraBaseUrl = apiKeyBaseUrl.value.trim()
|
||||
if (!soraBaseUrl) {
|
||||
appStore.showError(t('admin.accounts.soraBaseUrlRequired'))
|
||||
return
|
||||
}
|
||||
if (!soraBaseUrl.startsWith('http://') && !soraBaseUrl.startsWith('https://')) {
|
||||
appStore.showError(t('admin.accounts.soraBaseUrlInvalidScheme'))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Determine default base URL based on platform
|
||||
const defaultBaseUrl =
|
||||
(form.platform === 'openai' || form.platform === 'sora')
|
||||
form.platform === 'openai'
|
||||
? 'https://api.openai.com'
|
||||
: form.platform === 'gemini'
|
||||
? 'https://generativelanguage.googleapis.com'
|
||||
@@ -3358,6 +3602,7 @@ const handleOpenAIExchange = async (authCode: string) => {
|
||||
const soraCredentials = {
|
||||
access_token: credentials.access_token,
|
||||
refresh_token: credentials.refresh_token,
|
||||
client_id: credentials.client_id,
|
||||
expires_at: credentials.expires_at
|
||||
}
|
||||
|
||||
@@ -3462,6 +3707,7 @@ const handleOpenAIValidateRT = async (refreshTokenInput: string) => {
|
||||
const soraCredentials = {
|
||||
access_token: credentials.access_token,
|
||||
refresh_token: credentials.refresh_token,
|
||||
client_id: credentials.client_id,
|
||||
expires_at: credentials.expires_at
|
||||
}
|
||||
const soraName = shouldCreateOpenAI ? `${accountName} (Sora)` : accountName
|
||||
@@ -3808,6 +4054,20 @@ const handleAnthropicExchange = async (authCode: string) => {
|
||||
extra.session_idle_timeout_minutes = sessionIdleTimeout.value ?? 5
|
||||
}
|
||||
|
||||
// Add RPM limit settings
|
||||
if (rpmLimitEnabled.value && baseRpm.value != null && baseRpm.value > 0) {
|
||||
extra.base_rpm = baseRpm.value
|
||||
extra.rpm_strategy = rpmStrategy.value
|
||||
if (rpmStickyBuffer.value != null && rpmStickyBuffer.value > 0) {
|
||||
extra.rpm_sticky_buffer = rpmStickyBuffer.value
|
||||
}
|
||||
}
|
||||
|
||||
// UMQ mode(独立于 RPM)
|
||||
if (userMsgQueueMode.value) {
|
||||
extra.user_msg_queue_mode = userMsgQueueMode.value
|
||||
}
|
||||
|
||||
// Add TLS fingerprint settings
|
||||
if (tlsFingerprintEnabled.value) {
|
||||
extra.enable_tls_fingerprint = true
|
||||
@@ -3906,6 +4166,20 @@ const handleCookieAuth = async (sessionKey: string) => {
|
||||
extra.session_idle_timeout_minutes = sessionIdleTimeout.value ?? 5
|
||||
}
|
||||
|
||||
// Add RPM limit settings
|
||||
if (rpmLimitEnabled.value && baseRpm.value != null && baseRpm.value > 0) {
|
||||
extra.base_rpm = baseRpm.value
|
||||
extra.rpm_strategy = rpmStrategy.value
|
||||
if (rpmStickyBuffer.value != null && rpmStickyBuffer.value > 0) {
|
||||
extra.rpm_sticky_buffer = rpmStickyBuffer.value
|
||||
}
|
||||
}
|
||||
|
||||
// UMQ mode(独立于 RPM)
|
||||
if (userMsgQueueMode.value) {
|
||||
extra.user_msg_queue_mode = userMsgQueueMode.value
|
||||
}
|
||||
|
||||
// Add TLS fingerprint settings
|
||||
if (tlsFingerprintEnabled.value) {
|
||||
extra.enable_tls_fingerprint = true
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="
|
||||
account.platform === 'openai'
|
||||
account.platform === 'openai' || account.platform === 'sora'
|
||||
? 'https://api.openai.com'
|
||||
: account.platform === 'gemini'
|
||||
? 'https://generativelanguage.googleapis.com'
|
||||
@@ -53,7 +53,7 @@
|
||||
type="password"
|
||||
class="input font-mono"
|
||||
:placeholder="
|
||||
account.platform === 'openai'
|
||||
account.platform === 'openai' || account.platform === 'sora'
|
||||
? 'sk-proj-...'
|
||||
: account.platform === 'gemini'
|
||||
? 'AIza...'
|
||||
@@ -708,6 +708,27 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OpenAI WS Mode 三态(off/shared/dedicated) -->
|
||||
<div
|
||||
v-if="account?.platform === 'openai' && (account?.type === 'oauth' || account?.type === 'apikey')"
|
||||
class="border-t border-gray-200 pt-4 dark:border-dark-600"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="input-label mb-0">{{ t('admin.accounts.openai.wsMode') }}</label>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.openai.wsModeDesc') }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.openai.wsModeConcurrencyHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="w-52">
|
||||
<Select v-model="openaiResponsesWebSocketV2Mode" :options="openAIWSModeOptions" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Anthropic API Key 自动透传开关 -->
|
||||
<div
|
||||
v-if="account?.platform === 'anthropic' && account?.type === 'apikey'"
|
||||
@@ -925,6 +946,119 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RPM Limit -->
|
||||
<div class="rounded-lg border border-gray-200 p-4 dark:border-dark-600">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<label class="input-label mb-0">{{ t('admin.accounts.quotaControl.rpmLimit.label') }}</label>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.quotaControl.rpmLimit.hint') }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="rpmLimitEnabled = !rpmLimitEnabled"
|
||||
:class="[
|
||||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||||
rpmLimitEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||
rpmLimitEnabled ? 'translate-x-5' : 'translate-x-0'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="rpmLimitEnabled" class="space-y-4">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.quotaControl.rpmLimit.baseRpm') }}</label>
|
||||
<input
|
||||
v-model.number="baseRpm"
|
||||
type="number"
|
||||
min="1"
|
||||
max="1000"
|
||||
step="1"
|
||||
class="input"
|
||||
:placeholder="t('admin.accounts.quotaControl.rpmLimit.baseRpmPlaceholder')"
|
||||
/>
|
||||
<p class="input-hint">{{ t('admin.accounts.quotaControl.rpmLimit.baseRpmHint') }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.quotaControl.rpmLimit.strategy') }}</label>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@click="rpmStrategy = 'tiered'"
|
||||
:class="[
|
||||
'flex-1 rounded-lg px-3 py-2 text-sm font-medium transition-all',
|
||||
rpmStrategy === 'tiered'
|
||||
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
|
||||
]"
|
||||
>
|
||||
<div class="text-center">
|
||||
<div>{{ t('admin.accounts.quotaControl.rpmLimit.strategyTiered') }}</div>
|
||||
<div class="mt-0.5 text-[10px] opacity-70">{{ t('admin.accounts.quotaControl.rpmLimit.strategyTieredHint') }}</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="rpmStrategy = 'sticky_exempt'"
|
||||
:class="[
|
||||
'flex-1 rounded-lg px-3 py-2 text-sm font-medium transition-all',
|
||||
rpmStrategy === 'sticky_exempt'
|
||||
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
|
||||
]"
|
||||
>
|
||||
<div class="text-center">
|
||||
<div>{{ t('admin.accounts.quotaControl.rpmLimit.strategyStickyExempt') }}</div>
|
||||
<div class="mt-0.5 text-[10px] opacity-70">{{ t('admin.accounts.quotaControl.rpmLimit.strategyStickyExemptHint') }}</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="rpmStrategy === 'tiered'">
|
||||
<label class="input-label">{{ t('admin.accounts.quotaControl.rpmLimit.stickyBuffer') }}</label>
|
||||
<input
|
||||
v-model.number="rpmStickyBuffer"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
class="input"
|
||||
:placeholder="t('admin.accounts.quotaControl.rpmLimit.stickyBufferPlaceholder')"
|
||||
/>
|
||||
<p class="input-hint">{{ t('admin.accounts.quotaControl.rpmLimit.stickyBufferHint') }}</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 用户消息限速模式(独立于 RPM 开关,始终可见) -->
|
||||
<div class="mt-4">
|
||||
<label class="input-label">{{ t('admin.accounts.quotaControl.rpmLimit.userMsgQueue') }}</label>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400 mb-2">
|
||||
{{ t('admin.accounts.quotaControl.rpmLimit.userMsgQueueHint') }}
|
||||
</p>
|
||||
<div class="flex space-x-2">
|
||||
<button type="button" v-for="opt in umqModeOptions" :key="opt.value"
|
||||
@click="userMsgQueueMode = opt.value"
|
||||
:class="[
|
||||
'px-3 py-1.5 text-sm rounded-md border transition-colors',
|
||||
userMsgQueueMode === opt.value
|
||||
? 'bg-primary-600 text-white border-primary-600'
|
||||
: 'bg-white dark:bg-dark-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-dark-500 hover:bg-gray-50 dark:hover:bg-dark-600'
|
||||
]">
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TLS Fingerprint -->
|
||||
<div class="rounded-lg border border-gray-200 p-4 dark:border-dark-600">
|
||||
<div class="flex items-center justify-between">
|
||||
@@ -1138,6 +1272,14 @@ import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.
|
||||
import { applyInterceptWarmup } from '@/components/account/credentialsBuilder'
|
||||
import { formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format'
|
||||
import { createStableObjectKeyResolver } from '@/utils/stableObjectKey'
|
||||
import {
|
||||
OPENAI_WS_MODE_DEDICATED,
|
||||
OPENAI_WS_MODE_OFF,
|
||||
OPENAI_WS_MODE_SHARED,
|
||||
isOpenAIWSModeEnabled,
|
||||
type OpenAIWSMode,
|
||||
resolveOpenAIWSModeFromExtra
|
||||
} from '@/utils/openaiWsMode'
|
||||
import {
|
||||
getPresetMappingsByPlatform,
|
||||
commonErrorCodes,
|
||||
@@ -1222,6 +1364,16 @@ const windowCostStickyReserve = ref<number | null>(null)
|
||||
const sessionLimitEnabled = ref(false)
|
||||
const maxSessions = ref<number | null>(null)
|
||||
const sessionIdleTimeout = ref<number | null>(null)
|
||||
const rpmLimitEnabled = ref(false)
|
||||
const baseRpm = ref<number | null>(null)
|
||||
const rpmStrategy = ref<'tiered' | 'sticky_exempt'>('tiered')
|
||||
const rpmStickyBuffer = ref<number | null>(null)
|
||||
const userMsgQueueMode = ref('')
|
||||
const umqModeOptions = computed(() => [
|
||||
{ value: '', label: t('admin.accounts.quotaControl.rpmLimit.umqModeOff') },
|
||||
{ value: 'throttle', label: t('admin.accounts.quotaControl.rpmLimit.umqModeThrottle') },
|
||||
{ value: 'serialize', label: t('admin.accounts.quotaControl.rpmLimit.umqModeSerialize') },
|
||||
])
|
||||
const tlsFingerprintEnabled = ref(false)
|
||||
const sessionIdMaskingEnabled = ref(false)
|
||||
const cacheTTLOverrideEnabled = ref(false)
|
||||
@@ -1229,8 +1381,30 @@ const cacheTTLOverrideTarget = ref<string>('5m')
|
||||
|
||||
// OpenAI 自动透传开关(OAuth/API Key)
|
||||
const openaiPassthroughEnabled = ref(false)
|
||||
const openaiOAuthResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
|
||||
const openaiAPIKeyResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
|
||||
const codexCLIOnlyEnabled = ref(false)
|
||||
const anthropicPassthroughEnabled = ref(false)
|
||||
const openAIWSModeOptions = computed(() => [
|
||||
{ value: OPENAI_WS_MODE_OFF, label: t('admin.accounts.openai.wsModeOff') },
|
||||
{ value: OPENAI_WS_MODE_SHARED, label: t('admin.accounts.openai.wsModeShared') },
|
||||
{ value: OPENAI_WS_MODE_DEDICATED, label: t('admin.accounts.openai.wsModeDedicated') }
|
||||
])
|
||||
const openaiResponsesWebSocketV2Mode = computed({
|
||||
get: () => {
|
||||
if (props.account?.type === 'apikey') {
|
||||
return openaiAPIKeyResponsesWebSocketV2Mode.value
|
||||
}
|
||||
return openaiOAuthResponsesWebSocketV2Mode.value
|
||||
},
|
||||
set: (mode: OpenAIWSMode) => {
|
||||
if (props.account?.type === 'apikey') {
|
||||
openaiAPIKeyResponsesWebSocketV2Mode.value = mode
|
||||
return
|
||||
}
|
||||
openaiOAuthResponsesWebSocketV2Mode.value = mode
|
||||
}
|
||||
})
|
||||
const isOpenAIModelRestrictionDisabled = computed(() =>
|
||||
props.account?.platform === 'openai' && openaiPassthroughEnabled.value
|
||||
)
|
||||
@@ -1269,7 +1443,7 @@ const tempUnschedPresets = computed(() => [
|
||||
|
||||
// Computed: default base URL based on platform
|
||||
const defaultBaseUrl = computed(() => {
|
||||
if (props.account?.platform === 'openai') return 'https://api.openai.com'
|
||||
if (props.account?.platform === 'openai' || props.account?.platform === 'sora') return 'https://api.openai.com'
|
||||
if (props.account?.platform === 'gemini') return 'https://generativelanguage.googleapis.com'
|
||||
return 'https://api.anthropic.com'
|
||||
})
|
||||
@@ -1336,10 +1510,24 @@ watch(
|
||||
|
||||
// Load OpenAI passthrough toggle (OpenAI OAuth/API Key)
|
||||
openaiPassthroughEnabled.value = false
|
||||
openaiOAuthResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
|
||||
openaiAPIKeyResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
|
||||
codexCLIOnlyEnabled.value = false
|
||||
anthropicPassthroughEnabled.value = false
|
||||
if (newAccount.platform === 'openai' && (newAccount.type === 'oauth' || newAccount.type === 'apikey')) {
|
||||
openaiPassthroughEnabled.value = extra?.openai_passthrough === true || extra?.openai_oauth_passthrough === true
|
||||
openaiOAuthResponsesWebSocketV2Mode.value = resolveOpenAIWSModeFromExtra(extra, {
|
||||
modeKey: 'openai_oauth_responses_websockets_v2_mode',
|
||||
enabledKey: 'openai_oauth_responses_websockets_v2_enabled',
|
||||
fallbackEnabledKeys: ['responses_websockets_v2_enabled', 'openai_ws_enabled'],
|
||||
defaultMode: OPENAI_WS_MODE_OFF
|
||||
})
|
||||
openaiAPIKeyResponsesWebSocketV2Mode.value = resolveOpenAIWSModeFromExtra(extra, {
|
||||
modeKey: 'openai_apikey_responses_websockets_v2_mode',
|
||||
enabledKey: 'openai_apikey_responses_websockets_v2_enabled',
|
||||
fallbackEnabledKeys: ['responses_websockets_v2_enabled', 'openai_ws_enabled'],
|
||||
defaultMode: OPENAI_WS_MODE_OFF
|
||||
})
|
||||
if (newAccount.type === 'oauth') {
|
||||
codexCLIOnlyEnabled.value = extra?.codex_cli_only === true
|
||||
}
|
||||
@@ -1389,7 +1577,7 @@ watch(
|
||||
if (newAccount.type === 'apikey' && newAccount.credentials) {
|
||||
const credentials = newAccount.credentials as Record<string, unknown>
|
||||
const platformDefaultUrl =
|
||||
newAccount.platform === 'openai'
|
||||
newAccount.platform === 'openai' || newAccount.platform === 'sora'
|
||||
? 'https://api.openai.com'
|
||||
: newAccount.platform === 'gemini'
|
||||
? 'https://generativelanguage.googleapis.com'
|
||||
@@ -1435,7 +1623,7 @@ watch(
|
||||
editBaseUrl.value = (credentials.base_url as string) || ''
|
||||
} else {
|
||||
const platformDefaultUrl =
|
||||
newAccount.platform === 'openai'
|
||||
newAccount.platform === 'openai' || newAccount.platform === 'sora'
|
||||
? 'https://api.openai.com'
|
||||
: newAccount.platform === 'gemini'
|
||||
? 'https://generativelanguage.googleapis.com'
|
||||
@@ -1645,6 +1833,11 @@ function loadQuotaControlSettings(account: Account) {
|
||||
sessionLimitEnabled.value = false
|
||||
maxSessions.value = null
|
||||
sessionIdleTimeout.value = null
|
||||
rpmLimitEnabled.value = false
|
||||
baseRpm.value = null
|
||||
rpmStrategy.value = 'tiered'
|
||||
rpmStickyBuffer.value = null
|
||||
userMsgQueueMode.value = ''
|
||||
tlsFingerprintEnabled.value = false
|
||||
sessionIdMaskingEnabled.value = false
|
||||
cacheTTLOverrideEnabled.value = false
|
||||
@@ -1668,6 +1861,17 @@ function loadQuotaControlSettings(account: Account) {
|
||||
sessionIdleTimeout.value = account.session_idle_timeout_minutes ?? 5
|
||||
}
|
||||
|
||||
// RPM limit
|
||||
if (account.base_rpm != null && account.base_rpm > 0) {
|
||||
rpmLimitEnabled.value = true
|
||||
baseRpm.value = account.base_rpm
|
||||
rpmStrategy.value = (account.rpm_strategy as 'tiered' | 'sticky_exempt') || 'tiered'
|
||||
rpmStickyBuffer.value = account.rpm_sticky_buffer ?? null
|
||||
}
|
||||
|
||||
// UMQ mode(独立于 RPM 加载,防止编辑无 RPM 账号时丢失已有配置)
|
||||
userMsgQueueMode.value = account.user_msg_queue_mode ?? ''
|
||||
|
||||
// Load TLS fingerprint setting
|
||||
if (account.enable_tls_fingerprint === true) {
|
||||
tlsFingerprintEnabled.value = true
|
||||
@@ -1978,6 +2182,29 @@ const handleSubmit = async () => {
|
||||
delete newExtra.session_idle_timeout_minutes
|
||||
}
|
||||
|
||||
// RPM limit settings
|
||||
if (rpmLimitEnabled.value && baseRpm.value != null && baseRpm.value > 0) {
|
||||
newExtra.base_rpm = baseRpm.value
|
||||
newExtra.rpm_strategy = rpmStrategy.value
|
||||
if (rpmStickyBuffer.value != null && rpmStickyBuffer.value > 0) {
|
||||
newExtra.rpm_sticky_buffer = rpmStickyBuffer.value
|
||||
} else {
|
||||
delete newExtra.rpm_sticky_buffer
|
||||
}
|
||||
} else {
|
||||
delete newExtra.base_rpm
|
||||
delete newExtra.rpm_strategy
|
||||
delete newExtra.rpm_sticky_buffer
|
||||
}
|
||||
|
||||
// UMQ mode(独立于 RPM 保存)
|
||||
if (userMsgQueueMode.value) {
|
||||
newExtra.user_msg_queue_mode = userMsgQueueMode.value
|
||||
} else {
|
||||
delete newExtra.user_msg_queue_mode
|
||||
}
|
||||
delete newExtra.user_msg_queue_enabled // 清理旧字段
|
||||
|
||||
// TLS fingerprint setting
|
||||
if (tlsFingerprintEnabled.value) {
|
||||
newExtra.enable_tls_fingerprint = true
|
||||
@@ -2021,6 +2248,12 @@ const handleSubmit = async () => {
|
||||
const currentExtra = (props.account.extra as Record<string, unknown>) || {}
|
||||
const newExtra: Record<string, unknown> = { ...currentExtra }
|
||||
const hadCodexCLIOnlyEnabled = currentExtra.codex_cli_only === true
|
||||
newExtra.openai_oauth_responses_websockets_v2_mode = openaiOAuthResponsesWebSocketV2Mode.value
|
||||
newExtra.openai_apikey_responses_websockets_v2_mode = openaiAPIKeyResponsesWebSocketV2Mode.value
|
||||
newExtra.openai_oauth_responses_websockets_v2_enabled = isOpenAIWSModeEnabled(openaiOAuthResponsesWebSocketV2Mode.value)
|
||||
newExtra.openai_apikey_responses_websockets_v2_enabled = isOpenAIWSModeEnabled(openaiAPIKeyResponsesWebSocketV2Mode.value)
|
||||
delete newExtra.responses_websockets_v2_enabled
|
||||
delete newExtra.openai_ws_enabled
|
||||
if (openaiPassthroughEnabled.value) {
|
||||
newExtra.openai_passthrough = true
|
||||
} else {
|
||||
|
||||
@@ -171,7 +171,7 @@
|
||||
class="mb-2 flex items-center gap-2 text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<Icon name="key" size="sm" class="text-blue-500" />
|
||||
Session Token
|
||||
{{ t(getOAuthKey('sessionTokenRawLabel')) }}
|
||||
<span
|
||||
v-if="parsedSessionTokenCount > 1"
|
||||
class="rounded-full bg-blue-500 px-2 py-0.5 text-xs text-white"
|
||||
@@ -183,8 +183,33 @@
|
||||
v-model="sessionTokenInput"
|
||||
rows="3"
|
||||
class="input w-full resize-y font-mono text-sm"
|
||||
:placeholder="t(getOAuthKey('sessionTokenPlaceholder'))"
|
||||
:placeholder="t(getOAuthKey('sessionTokenRawPlaceholder'))"
|
||||
></textarea>
|
||||
<p class="mt-1 text-xs text-blue-600 dark:text-blue-400">
|
||||
{{ t(getOAuthKey('sessionTokenRawHint')) }}
|
||||
</p>
|
||||
<div class="mt-2 flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary px-2 py-1 text-xs"
|
||||
@click="handleOpenSoraSessionUrl"
|
||||
>
|
||||
{{ t(getOAuthKey('openSessionUrl')) }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary px-2 py-1 text-xs"
|
||||
@click="handleCopySoraSessionUrl"
|
||||
>
|
||||
{{ t(getOAuthKey('copySessionUrl')) }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-1 break-all text-xs text-blue-600 dark:text-blue-400">
|
||||
{{ soraSessionUrl }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-amber-600 dark:text-amber-400">
|
||||
{{ t(getOAuthKey('sessionUrlHint')) }}
|
||||
</p>
|
||||
<p
|
||||
v-if="parsedSessionTokenCount > 1"
|
||||
class="mt-1 text-xs text-blue-600 dark:text-blue-400"
|
||||
@@ -193,6 +218,54 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="sessionTokenInput.trim()" class="mb-4 space-y-3">
|
||||
<div>
|
||||
<label
|
||||
class="mb-2 flex items-center gap-2 text-xs font-semibold text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{{ t(getOAuthKey('parsedSessionTokensLabel')) }}
|
||||
<span
|
||||
v-if="parsedSessionTokenCount > 0"
|
||||
class="rounded-full bg-emerald-500 px-2 py-0.5 text-[10px] text-white"
|
||||
>
|
||||
{{ parsedSessionTokenCount }}
|
||||
</span>
|
||||
</label>
|
||||
<textarea
|
||||
:value="parsedSessionTokensText"
|
||||
rows="2"
|
||||
readonly
|
||||
class="input w-full resize-y bg-gray-50 font-mono text-xs dark:bg-gray-700"
|
||||
></textarea>
|
||||
<p
|
||||
v-if="parsedSessionTokenCount === 0"
|
||||
class="mt-1 text-xs text-amber-600 dark:text-amber-400"
|
||||
>
|
||||
{{ t(getOAuthKey('parsedSessionTokensEmpty')) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
class="mb-2 flex items-center gap-2 text-xs font-semibold text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{{ t(getOAuthKey('parsedAccessTokensLabel')) }}
|
||||
<span
|
||||
v-if="parsedAccessTokenFromSessionInputCount > 0"
|
||||
class="rounded-full bg-emerald-500 px-2 py-0.5 text-[10px] text-white"
|
||||
>
|
||||
{{ parsedAccessTokenFromSessionInputCount }}
|
||||
</span>
|
||||
</label>
|
||||
<textarea
|
||||
:value="parsedAccessTokensText"
|
||||
rows="2"
|
||||
readonly
|
||||
class="input w-full resize-y bg-gray-50 font-mono text-xs dark:bg-gray-700"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="error"
|
||||
class="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-700 dark:bg-red-900/30"
|
||||
@@ -205,7 +278,7 @@
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary w-full"
|
||||
:disabled="loading || !sessionTokenInput.trim()"
|
||||
:disabled="loading || parsedSessionTokenCount === 0"
|
||||
@click="handleValidateSessionToken"
|
||||
>
|
||||
<svg
|
||||
@@ -669,6 +742,7 @@
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import { parseSoraRawTokens } from '@/utils/soraTokenParser'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import type { AddMethod, AuthInputMethod } from '@/composables/useAccountOAuth'
|
||||
import type { AccountPlatform } from '@/types'
|
||||
@@ -781,13 +855,26 @@ const parsedRefreshTokenCount = computed(() => {
|
||||
.filter((rt) => rt).length
|
||||
})
|
||||
|
||||
const parsedSoraRawTokens = computed(() => parseSoraRawTokens(sessionTokenInput.value))
|
||||
|
||||
const parsedSessionTokenCount = computed(() => {
|
||||
return sessionTokenInput.value
|
||||
.split('\n')
|
||||
.map((st) => st.trim())
|
||||
.filter((st) => st).length
|
||||
return parsedSoraRawTokens.value.sessionTokens.length
|
||||
})
|
||||
|
||||
const parsedSessionTokensText = computed(() => {
|
||||
return parsedSoraRawTokens.value.sessionTokens.join('\n')
|
||||
})
|
||||
|
||||
const parsedAccessTokenFromSessionInputCount = computed(() => {
|
||||
return parsedSoraRawTokens.value.accessTokens.length
|
||||
})
|
||||
|
||||
const parsedAccessTokensText = computed(() => {
|
||||
return parsedSoraRawTokens.value.accessTokens.join('\n')
|
||||
})
|
||||
|
||||
const soraSessionUrl = 'https://sora.chatgpt.com/api/auth/session'
|
||||
|
||||
const parsedAccessTokenCount = computed(() => {
|
||||
return accessTokenInput.value
|
||||
.split('\n')
|
||||
@@ -863,11 +950,19 @@ const handleValidateRefreshToken = () => {
|
||||
}
|
||||
|
||||
const handleValidateSessionToken = () => {
|
||||
if (sessionTokenInput.value.trim()) {
|
||||
emit('validate-session-token', sessionTokenInput.value.trim())
|
||||
if (parsedSessionTokenCount.value > 0) {
|
||||
emit('validate-session-token', parsedSessionTokensText.value)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenSoraSessionUrl = () => {
|
||||
window.open(soraSessionUrl, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
const handleCopySoraSessionUrl = () => {
|
||||
copyToClipboard(soraSessionUrl, 'URL copied to clipboard')
|
||||
}
|
||||
|
||||
const handleImportAccessToken = () => {
|
||||
if (accessTokenInput.value.trim()) {
|
||||
emit('import-access-token', accessTokenInput.value.trim())
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import AccountUsageCell from '../AccountUsageCell.vue'
|
||||
|
||||
const { getUsage } = vi.hoisted(() => ({
|
||||
getUsage: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/api/admin', () => ({
|
||||
adminAPI: {
|
||||
accounts: {
|
||||
getUsage
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', async () => {
|
||||
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('AccountUsageCell', () => {
|
||||
beforeEach(() => {
|
||||
getUsage.mockReset()
|
||||
})
|
||||
|
||||
it('Antigravity 图片用量会聚合新旧 image 模型', async () => {
|
||||
getUsage.mockResolvedValue({
|
||||
antigravity_quota: {
|
||||
'gemini-3.1-flash-image': {
|
||||
utilization: 20,
|
||||
reset_time: '2026-03-01T10:00:00Z'
|
||||
},
|
||||
'gemini-3-pro-image': {
|
||||
utilization: 70,
|
||||
reset_time: '2026-03-01T09:00:00Z'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const wrapper = mount(AccountUsageCell, {
|
||||
props: {
|
||||
account: {
|
||||
id: 1001,
|
||||
platform: 'antigravity',
|
||||
type: 'oauth',
|
||||
extra: {}
|
||||
} as any
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
UsageProgressBar: {
|
||||
props: ['label', 'utilization', 'resetsAt', 'color'],
|
||||
template: '<div class="usage-bar">{{ label }}|{{ utilization }}|{{ resetsAt }}</div>'
|
||||
},
|
||||
AccountQuotaInfo: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('admin.accounts.usageWindow.gemini3Image|70|2026-03-01T09:00:00Z')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import BulkEditAccountModal from '../BulkEditAccountModal.vue'
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({
|
||||
showError: vi.fn(),
|
||||
showSuccess: vi.fn(),
|
||||
showInfo: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/api/admin', () => ({
|
||||
adminAPI: {
|
||||
accounts: {
|
||||
bulkEdit: vi.fn()
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', async () => {
|
||||
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
function mountModal() {
|
||||
return mount(BulkEditAccountModal, {
|
||||
props: {
|
||||
show: true,
|
||||
accountIds: [1, 2],
|
||||
selectedPlatforms: ['antigravity'],
|
||||
proxies: [],
|
||||
groups: []
|
||||
} as any,
|
||||
global: {
|
||||
stubs: {
|
||||
BaseDialog: { template: '<div><slot /><slot name="footer" /></div>' },
|
||||
Select: true,
|
||||
ProxySelector: true,
|
||||
GroupSelector: true,
|
||||
Icon: true
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('BulkEditAccountModal', () => {
|
||||
it('antigravity 白名单包含 Gemini 图片模型且过滤掉普通 GPT 模型', () => {
|
||||
const wrapper = mountModal()
|
||||
|
||||
expect(wrapper.text()).toContain('Gemini 3.1 Flash Image')
|
||||
expect(wrapper.text()).toContain('Gemini 3 Pro Image (Legacy)')
|
||||
expect(wrapper.text()).not.toContain('GPT-5.3 Codex')
|
||||
})
|
||||
|
||||
it('antigravity 映射预设包含图片映射并过滤 OpenAI 预设', async () => {
|
||||
const wrapper = mountModal()
|
||||
|
||||
const mappingTab = wrapper.findAll('button').find((btn) => btn.text().includes('admin.accounts.modelMapping'))
|
||||
expect(mappingTab).toBeTruthy()
|
||||
await mappingTab!.trigger('click')
|
||||
|
||||
expect(wrapper.text()).toContain('Gemini 3.1 Image')
|
||||
expect(wrapper.text()).toContain('G3 Image→3.1')
|
||||
expect(wrapper.text()).not.toContain('GPT-5.3 Codex')
|
||||
})
|
||||
})
|
||||
@@ -125,6 +125,7 @@ import Pagination from '@/components/common/Pagination.vue'
|
||||
import UsageFilters from '@/components/admin/usage/UsageFilters.vue'
|
||||
import { adminUsageAPI } from '@/api/admin/usage'
|
||||
import type { AdminUsageQueryParams, UsageCleanupTask, CreateUsageCleanupTaskRequest } from '@/api/admin/usage'
|
||||
import { requestTypeToLegacyStream } from '@/utils/usageRequestType'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
@@ -310,7 +311,13 @@ const buildPayload = (): CreateUsageCleanupTaskRequest | null => {
|
||||
if (localFilters.value.model) {
|
||||
payload.model = localFilters.value.model
|
||||
}
|
||||
if (localFilters.value.stream !== null && localFilters.value.stream !== undefined) {
|
||||
if (localFilters.value.request_type) {
|
||||
payload.request_type = localFilters.value.request_type
|
||||
const legacyStream = requestTypeToLegacyStream(localFilters.value.request_type)
|
||||
if (legacyStream !== null && legacyStream !== undefined) {
|
||||
payload.stream = legacyStream
|
||||
}
|
||||
} else if (localFilters.value.stream !== null && localFilters.value.stream !== undefined) {
|
||||
payload.stream = localFilters.value.stream
|
||||
}
|
||||
if (localFilters.value.billing_type !== null && localFilters.value.billing_type !== undefined) {
|
||||
|
||||
@@ -121,10 +121,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stream Type Filter -->
|
||||
<!-- Request Type Filter -->
|
||||
<div class="w-full sm:w-auto sm:min-w-[180px]">
|
||||
<label class="input-label">{{ t('usage.type') }}</label>
|
||||
<Select v-model="filters.stream" :options="streamTypeOptions" @change="emitChange" />
|
||||
<Select v-model="filters.request_type" :options="requestTypeOptions" @change="emitChange" />
|
||||
</div>
|
||||
|
||||
<!-- Billing Type Filter -->
|
||||
@@ -233,10 +233,11 @@ let accountSearchTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
const modelOptions = ref<SelectOption[]>([{ value: null, label: t('admin.usage.allModels') }])
|
||||
const groupOptions = ref<SelectOption[]>([{ value: null, label: t('admin.usage.allGroups') }])
|
||||
|
||||
const streamTypeOptions = ref<SelectOption[]>([
|
||||
const requestTypeOptions = ref<SelectOption[]>([
|
||||
{ value: null, label: t('admin.usage.allTypes') },
|
||||
{ value: true, label: t('usage.stream') },
|
||||
{ value: false, label: t('usage.sync') }
|
||||
{ value: 'ws_v2', label: t('usage.ws') },
|
||||
{ value: 'stream', label: t('usage.stream') },
|
||||
{ value: 'sync', label: t('usage.sync') }
|
||||
])
|
||||
|
||||
const billingTypeOptions = ref<SelectOption[]>([
|
||||
|
||||
@@ -35,8 +35,8 @@
|
||||
</template>
|
||||
|
||||
<template #cell-stream="{ row }">
|
||||
<span class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium" :class="row.stream ? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200' : 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200'">
|
||||
{{ row.stream ? t('usage.stream') : t('usage.sync') }}
|
||||
<span class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium" :class="getRequestTypeBadgeClass(row)">
|
||||
{{ getRequestTypeLabel(row) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
@@ -271,6 +271,7 @@
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { formatDateTime, formatReasoningEffort } from '@/utils/format'
|
||||
import { resolveUsageRequestType } from '@/utils/usageRequestType'
|
||||
import DataTable from '@/components/common/DataTable.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
@@ -289,6 +290,21 @@ const tokenTooltipVisible = ref(false)
|
||||
const tokenTooltipPosition = ref({ x: 0, y: 0 })
|
||||
const tokenTooltipData = ref<AdminUsageLog | null>(null)
|
||||
|
||||
const getRequestTypeLabel = (row: AdminUsageLog): string => {
|
||||
const requestType = resolveUsageRequestType(row)
|
||||
if (requestType === 'ws_v2') return t('usage.ws')
|
||||
if (requestType === 'stream') return t('usage.stream')
|
||||
if (requestType === 'sync') return t('usage.sync')
|
||||
return t('usage.unknown')
|
||||
}
|
||||
|
||||
const getRequestTypeBadgeClass = (row: AdminUsageLog): string => {
|
||||
const requestType = resolveUsageRequestType(row)
|
||||
if (requestType === 'ws_v2') return 'bg-violet-100 text-violet-800 dark:bg-violet-900 dark:text-violet-200'
|
||||
if (requestType === 'stream') return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
|
||||
if (requestType === 'sync') return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200'
|
||||
return 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200'
|
||||
}
|
||||
const formatCacheTokens = (tokens: number): string => {
|
||||
if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(1)}M`
|
||||
if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}K`
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<BaseDialog :show="show" :title="t('admin.users.userApiKeys')" width="wide" @close="$emit('close')">
|
||||
<BaseDialog :show="show" :title="t('admin.users.userApiKeys')" width="wide" @close="handleClose">
|
||||
<div v-if="user" class="space-y-4">
|
||||
<div class="flex items-center gap-3 rounded-xl bg-gray-50 p-4 dark:bg-dark-700">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-primary-100 dark:bg-primary-900/30">
|
||||
@@ -9,7 +9,7 @@
|
||||
</div>
|
||||
<div v-if="loading" class="flex justify-center py-8"><svg class="h-8 w-8 animate-spin text-primary-500" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg></div>
|
||||
<div v-else-if="apiKeys.length === 0" class="py-8 text-center"><p class="text-sm text-gray-500">{{ t('admin.users.noApiKeys') }}</p></div>
|
||||
<div v-else class="max-h-96 space-y-3 overflow-y-auto">
|
||||
<div v-else ref="scrollContainerRef" class="max-h-96 space-y-3 overflow-y-auto" @scroll="closeGroupSelector">
|
||||
<div v-for="key in apiKeys" :key="key.id" class="rounded-xl border border-gray-200 bg-white p-4 dark:border-dark-600 dark:bg-dark-800">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
@@ -18,30 +18,237 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 flex flex-wrap gap-4 text-xs text-gray-500">
|
||||
<div class="flex items-center gap-1"><span>{{ t('admin.users.group') }}: {{ key.group?.name || t('admin.users.none') }}</span></div>
|
||||
<div class="flex items-center gap-1">
|
||||
<span>{{ t('admin.users.group') }}:</span>
|
||||
<button
|
||||
:ref="(el) => setGroupButtonRef(key.id, el)"
|
||||
@click="openGroupSelector(key)"
|
||||
class="-mx-1 -my-0.5 flex cursor-pointer items-center gap-1 rounded-md px-1 py-0.5 transition-colors hover:bg-gray-100 dark:hover:bg-dark-700"
|
||||
:disabled="updatingKeyIds.has(key.id)"
|
||||
>
|
||||
<GroupBadge
|
||||
v-if="key.group_id && key.group"
|
||||
:name="key.group.name"
|
||||
:platform="key.group.platform"
|
||||
:subscription-type="key.group.subscription_type"
|
||||
:rate-multiplier="key.group.rate_multiplier"
|
||||
/>
|
||||
<span v-else class="text-gray-400 italic">{{ t('admin.users.none') }}</span>
|
||||
<svg v-if="updatingKeyIds.has(key.id)" class="h-3 w-3 animate-spin text-primary-500" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>
|
||||
<svg v-else class="h-3 w-3 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M8.25 15L12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-1"><span>{{ t('admin.users.columns.created') }}: {{ formatDateTime(key.created_at) }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BaseDialog>
|
||||
|
||||
<!-- Group Selector Dropdown -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="groupSelectorKeyId !== null && dropdownPosition"
|
||||
ref="dropdownRef"
|
||||
class="animate-in fade-in slide-in-from-top-2 fixed z-[100000020] w-64 overflow-hidden rounded-xl bg-white shadow-lg ring-1 ring-black/5 duration-200 dark:bg-dark-800 dark:ring-white/10"
|
||||
:style="{ top: dropdownPosition.top + 'px', left: dropdownPosition.left + 'px' }"
|
||||
>
|
||||
<div class="max-h-64 overflow-y-auto p-1.5">
|
||||
<!-- Unbind option -->
|
||||
<button
|
||||
@click="changeGroup(selectedKeyForGroup!, null)"
|
||||
:class="[
|
||||
'flex w-full items-center rounded-lg px-3 py-2 text-sm transition-colors',
|
||||
!selectedKeyForGroup?.group_id
|
||||
? 'bg-primary-50 dark:bg-primary-900/20'
|
||||
: 'hover:bg-gray-100 dark:hover:bg-dark-700'
|
||||
]"
|
||||
>
|
||||
<span class="text-gray-500 italic">{{ t('admin.users.none') }}</span>
|
||||
<svg
|
||||
v-if="!selectedKeyForGroup?.group_id"
|
||||
class="ml-auto h-4 w-4 shrink-0 text-primary-600 dark:text-primary-400"
|
||||
fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"
|
||||
><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" /></svg>
|
||||
</button>
|
||||
<!-- Group options -->
|
||||
<button
|
||||
v-for="group in allGroups"
|
||||
:key="group.id"
|
||||
@click="changeGroup(selectedKeyForGroup!, group.id)"
|
||||
:class="[
|
||||
'flex w-full items-center justify-between rounded-lg px-3 py-2 text-sm transition-colors',
|
||||
selectedKeyForGroup?.group_id === group.id
|
||||
? 'bg-primary-50 dark:bg-primary-900/20'
|
||||
: 'hover:bg-gray-100 dark:hover:bg-dark-700'
|
||||
]"
|
||||
>
|
||||
<GroupOptionItem
|
||||
:name="group.name"
|
||||
:platform="group.platform"
|
||||
:subscription-type="group.subscription_type"
|
||||
:rate-multiplier="group.rate_multiplier"
|
||||
:description="group.description"
|
||||
:selected="selectedKeyForGroup?.group_id === group.id"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { ref, computed, watch, onMounted, onUnmounted, type ComponentPublicInstance } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
import type { AdminUser, ApiKey } from '@/types'
|
||||
import type { AdminUser, AdminGroup, ApiKey } from '@/types'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import GroupBadge from '@/components/common/GroupBadge.vue'
|
||||
import GroupOptionItem from '@/components/common/GroupOptionItem.vue'
|
||||
|
||||
const props = defineProps<{ show: boolean, user: AdminUser | null }>()
|
||||
defineEmits(['close']); const { t } = useI18n()
|
||||
const apiKeys = ref<ApiKey[]>([]); const loading = ref(false)
|
||||
const props = defineProps<{ show: boolean; user: AdminUser | null }>()
|
||||
const emit = defineEmits(['close'])
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
watch(() => props.show, (v) => { if (v && props.user) load() })
|
||||
const load = async () => {
|
||||
if (!props.user) return; loading.value = true
|
||||
try { const res = await adminAPI.users.getUserApiKeys(props.user.id); apiKeys.value = res.items || [] } catch (error) { console.error('Failed to load API keys:', error) } finally { loading.value = false }
|
||||
const apiKeys = ref<ApiKey[]>([])
|
||||
const allGroups = ref<AdminGroup[]>([])
|
||||
const loading = ref(false)
|
||||
const updatingKeyIds = ref(new Set<number>())
|
||||
const groupSelectorKeyId = ref<number | null>(null)
|
||||
const dropdownPosition = ref<{ top: number; left: number } | null>(null)
|
||||
const dropdownRef = ref<HTMLElement | null>(null)
|
||||
const scrollContainerRef = ref<HTMLElement | null>(null)
|
||||
const groupButtonRefs = ref<Map<number, HTMLElement>>(new Map())
|
||||
|
||||
const selectedKeyForGroup = computed(() => {
|
||||
if (groupSelectorKeyId.value === null) return null
|
||||
return apiKeys.value.find((k) => k.id === groupSelectorKeyId.value) || null
|
||||
})
|
||||
|
||||
const setGroupButtonRef = (keyId: number, el: Element | ComponentPublicInstance | null) => {
|
||||
if (el instanceof HTMLElement) {
|
||||
groupButtonRefs.value.set(keyId, el)
|
||||
} else {
|
||||
groupButtonRefs.value.delete(keyId)
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.show, (v) => {
|
||||
if (v && props.user) {
|
||||
load()
|
||||
loadGroups()
|
||||
} else {
|
||||
closeGroupSelector()
|
||||
}
|
||||
})
|
||||
|
||||
const load = async () => {
|
||||
if (!props.user) return
|
||||
loading.value = true
|
||||
groupButtonRefs.value.clear()
|
||||
try {
|
||||
const res = await adminAPI.users.getUserApiKeys(props.user.id)
|
||||
apiKeys.value = res.items || []
|
||||
} catch (error) {
|
||||
console.error('Failed to load API keys:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadGroups = async () => {
|
||||
try {
|
||||
const groups = await adminAPI.groups.getAll()
|
||||
// 过滤掉订阅类型分组(需通过订阅管理流程绑定)
|
||||
allGroups.value = groups.filter((g) => g.subscription_type !== 'subscription')
|
||||
} catch (error) {
|
||||
console.error('Failed to load groups:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const DROPDOWN_HEIGHT = 272 // max-h-64 = 16rem = 256px + padding
|
||||
const DROPDOWN_GAP = 4
|
||||
|
||||
const openGroupSelector = (key: ApiKey) => {
|
||||
if (groupSelectorKeyId.value === key.id) {
|
||||
closeGroupSelector()
|
||||
} else {
|
||||
const buttonEl = groupButtonRefs.value.get(key.id)
|
||||
if (buttonEl) {
|
||||
const rect = buttonEl.getBoundingClientRect()
|
||||
const spaceBelow = window.innerHeight - rect.bottom
|
||||
const openUpward = spaceBelow < DROPDOWN_HEIGHT && rect.top > spaceBelow
|
||||
dropdownPosition.value = {
|
||||
top: openUpward ? rect.top - DROPDOWN_HEIGHT - DROPDOWN_GAP : rect.bottom + DROPDOWN_GAP,
|
||||
left: rect.left
|
||||
}
|
||||
}
|
||||
groupSelectorKeyId.value = key.id
|
||||
}
|
||||
}
|
||||
|
||||
const closeGroupSelector = () => {
|
||||
groupSelectorKeyId.value = null
|
||||
dropdownPosition.value = null
|
||||
}
|
||||
|
||||
const changeGroup = async (key: ApiKey, newGroupId: number | null) => {
|
||||
closeGroupSelector()
|
||||
if (key.group_id === newGroupId || (!key.group_id && newGroupId === null)) return
|
||||
|
||||
updatingKeyIds.value.add(key.id)
|
||||
try {
|
||||
const result = await adminAPI.apiKeys.updateApiKeyGroup(key.id, newGroupId)
|
||||
// Update local data
|
||||
const idx = apiKeys.value.findIndex((k) => k.id === key.id)
|
||||
if (idx !== -1) {
|
||||
apiKeys.value[idx] = result.api_key
|
||||
}
|
||||
if (result.auto_granted_group_access && result.granted_group_name) {
|
||||
appStore.showSuccess(t('admin.users.groupChangedWithGrant', { group: result.granted_group_name }))
|
||||
} else {
|
||||
appStore.showSuccess(t('admin.users.groupChangedSuccess'))
|
||||
}
|
||||
} catch (error: any) {
|
||||
appStore.showError(error?.message || t('admin.users.groupChangeFailed'))
|
||||
} finally {
|
||||
updatingKeyIds.value.delete(key.id)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && groupSelectorKeyId.value !== null) {
|
||||
event.stopPropagation()
|
||||
closeGroupSelector()
|
||||
}
|
||||
}
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement
|
||||
if (dropdownRef.value && !dropdownRef.value.contains(target)) {
|
||||
// Check if the click is on one of the group trigger buttons
|
||||
for (const el of groupButtonRefs.value.values()) {
|
||||
if (el.contains(target)) return
|
||||
}
|
||||
closeGroupSelector()
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
closeGroupSelector()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
document.addEventListener('keydown', handleKeyDown, true)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
document.removeEventListener('keydown', handleKeyDown, true)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -37,6 +37,14 @@
|
||||
<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>
|
||||
<div class="flex items-center gap-2">
|
||||
<input v-model.number="form.sora_storage_quota_gb" type="number" min="0" step="0.1" class="input" placeholder="0" />
|
||||
<span class="shrink-0 text-sm text-gray-500">GB</span>
|
||||
</div>
|
||||
<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 +74,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, sora_storage_quota_gb: 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, sora_storage_quota_gb: Number(((u.sora_storage_quota_bytes || 0) / (1024 * 1024 * 1024)).toFixed(2)), customAttributes: {} })
|
||||
passwordCopied.value = false
|
||||
}
|
||||
}, { immediate: true })
|
||||
@@ -97,7 +105,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.sora_storage_quota_gb || 0) * 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)
|
||||
|
||||
@@ -29,9 +29,9 @@
|
||||
>
|
||||
<td
|
||||
class="max-w-[100px] truncate py-1.5 font-medium text-gray-900 dark:text-white"
|
||||
:title="group.group_name"
|
||||
:title="group.group_name || String(group.group_id)"
|
||||
>
|
||||
{{ group.group_name }}
|
||||
{{ group.group_name || t('admin.dashboard.noGroup') }}
|
||||
</td>
|
||||
<td class="py-1.5 text-right text-gray-600 dark:text-gray-400">
|
||||
{{ formatNumber(group.requests) }}
|
||||
@@ -93,7 +93,7 @@ const chartData = computed(() => {
|
||||
if (!props.groupStats?.length) return null
|
||||
|
||||
return {
|
||||
labels: props.groupStats.map((g) => g.group_name),
|
||||
labels: props.groupStats.map((g) => g.group_name || String(g.group_id)),
|
||||
datasets: [
|
||||
{
|
||||
data: props.groupStats.map((g) => g.total_tokens),
|
||||
|
||||
146
frontend/src/components/common/ImageUpload.vue
Normal file
146
frontend/src/components/common/ImageUpload.vue
Normal file
@@ -0,0 +1,146 @@
|
||||
<template>
|
||||
<div class="flex items-start gap-4">
|
||||
<!-- Preview Box -->
|
||||
<div class="flex-shrink-0">
|
||||
<div
|
||||
class="flex items-center justify-center overflow-hidden rounded-xl border-2 border-dashed border-gray-300 bg-gray-50 dark:border-dark-600 dark:bg-dark-800"
|
||||
:class="[previewSizeClass, { 'border-solid': !!modelValue }]"
|
||||
>
|
||||
<!-- SVG mode: render inline -->
|
||||
<span
|
||||
v-if="mode === 'svg' && modelValue"
|
||||
class="text-gray-600 dark:text-gray-300 [&>svg]:h-full [&>svg]:w-full"
|
||||
:class="innerSizeClass"
|
||||
v-html="sanitizedValue"
|
||||
></span>
|
||||
<!-- Image mode: show as img -->
|
||||
<img
|
||||
v-else-if="mode === 'image' && modelValue"
|
||||
:src="modelValue"
|
||||
alt=""
|
||||
class="h-full w-full object-contain"
|
||||
/>
|
||||
<!-- Empty placeholder -->
|
||||
<svg
|
||||
v-else
|
||||
class="text-gray-400 dark:text-dark-500"
|
||||
:class="placeholderSizeClass"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Controls -->
|
||||
<div class="flex-1 space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="btn btn-secondary btn-sm cursor-pointer">
|
||||
<input
|
||||
type="file"
|
||||
:accept="acceptTypes"
|
||||
class="hidden"
|
||||
@change="handleUpload"
|
||||
/>
|
||||
<Icon name="upload" size="sm" class="mr-1.5" :stroke-width="2" />
|
||||
{{ uploadLabel }}
|
||||
</label>
|
||||
<button
|
||||
v-if="modelValue"
|
||||
type="button"
|
||||
class="btn btn-secondary btn-sm text-red-600 hover:text-red-700 dark:text-red-400"
|
||||
@click="$emit('update:modelValue', '')"
|
||||
>
|
||||
<Icon name="trash" size="sm" class="mr-1.5" :stroke-width="2" />
|
||||
{{ removeLabel }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="hint" class="text-xs text-gray-500 dark:text-gray-400">{{ hint }}</p>
|
||||
<p v-if="error" class="text-xs text-red-500">{{ error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { sanitizeSvg } from '@/utils/sanitize'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue: string
|
||||
mode?: 'image' | 'svg'
|
||||
size?: 'sm' | 'md'
|
||||
uploadLabel?: string
|
||||
removeLabel?: string
|
||||
hint?: string
|
||||
maxSize?: number // bytes
|
||||
}>(), {
|
||||
mode: 'image',
|
||||
size: 'md',
|
||||
uploadLabel: 'Upload',
|
||||
removeLabel: 'Remove',
|
||||
hint: '',
|
||||
maxSize: 300 * 1024,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const error = ref('')
|
||||
|
||||
const acceptTypes = computed(() => props.mode === 'svg' ? '.svg' : 'image/*')
|
||||
|
||||
const sanitizedValue = computed(() =>
|
||||
props.mode === 'svg' ? sanitizeSvg(props.modelValue ?? '') : ''
|
||||
)
|
||||
|
||||
const previewSizeClass = computed(() => props.size === 'sm' ? 'h-14 w-14' : 'h-20 w-20')
|
||||
const innerSizeClass = computed(() => props.size === 'sm' ? 'h-7 w-7' : 'h-12 w-12')
|
||||
const placeholderSizeClass = computed(() => props.size === 'sm' ? 'h-5 w-5' : 'h-8 w-8')
|
||||
|
||||
function handleUpload(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
error.value = ''
|
||||
|
||||
if (!file) return
|
||||
|
||||
if (props.maxSize && file.size > props.maxSize) {
|
||||
error.value = `File too large (${(file.size / 1024).toFixed(1)} KB), max ${(props.maxSize / 1024).toFixed(0)} KB`
|
||||
input.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
const reader = new FileReader()
|
||||
if (props.mode === 'svg') {
|
||||
reader.onload = (e) => {
|
||||
const text = e.target?.result as string
|
||||
if (text) emit('update:modelValue', text.trim())
|
||||
}
|
||||
reader.readAsText(file)
|
||||
} else {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
error.value = 'Please select an image file'
|
||||
input.value = ''
|
||||
return
|
||||
}
|
||||
reader.onload = (e) => {
|
||||
emit('update:modelValue', e.target?.result as string)
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
|
||||
reader.onerror = () => {
|
||||
error.value = 'Failed to read file'
|
||||
}
|
||||
input.value = ''
|
||||
}
|
||||
</script>
|
||||
@@ -268,6 +268,7 @@ const clientTabs = computed((): TabConfig[] => {
|
||||
case 'openai':
|
||||
return [
|
||||
{ id: 'codex', label: t('keys.useKeyModal.cliTabs.codexCli'), icon: TerminalIcon },
|
||||
{ id: 'codex-ws', label: t('keys.useKeyModal.cliTabs.codexCliWs'), icon: TerminalIcon },
|
||||
{ id: 'opencode', label: t('keys.useKeyModal.cliTabs.opencode'), icon: TerminalIcon }
|
||||
]
|
||||
case 'gemini':
|
||||
@@ -306,7 +307,7 @@ const showShellTabs = computed(() => activeClientTab.value !== 'opencode')
|
||||
|
||||
const currentTabs = computed(() => {
|
||||
if (!showShellTabs.value) return []
|
||||
if (props.platform === 'openai') {
|
||||
if (activeClientTab.value === 'codex' || activeClientTab.value === 'codex-ws') {
|
||||
return openaiTabs
|
||||
}
|
||||
return shellTabs
|
||||
@@ -401,6 +402,9 @@ const currentFiles = computed((): FileConfig[] => {
|
||||
|
||||
switch (props.platform) {
|
||||
case 'openai':
|
||||
if (activeClientTab.value === 'codex-ws') {
|
||||
return generateOpenAIWsFiles(baseUrl, apiKey)
|
||||
}
|
||||
return generateOpenAIFiles(baseUrl, apiKey)
|
||||
case 'gemini':
|
||||
return [generateGeminiCliContent(baseUrl, apiKey)]
|
||||
@@ -524,6 +528,47 @@ requires_openai_auth = true`
|
||||
]
|
||||
}
|
||||
|
||||
function generateOpenAIWsFiles(baseUrl: string, apiKey: string): FileConfig[] {
|
||||
const isWindows = activeTab.value === 'windows'
|
||||
const configDir = isWindows ? '%userprofile%\\.codex' : '~/.codex'
|
||||
|
||||
// config.toml content with WebSocket v2
|
||||
const configContent = `model_provider = "sub2api"
|
||||
model = "gpt-5.3-codex"
|
||||
model_reasoning_effort = "high"
|
||||
network_access = "enabled"
|
||||
disable_response_storage = true
|
||||
windows_wsl_setup_acknowledged = true
|
||||
model_verbosity = "high"
|
||||
|
||||
[model_providers.sub2api]
|
||||
name = "sub2api"
|
||||
base_url = "${baseUrl}"
|
||||
wire_api = "responses"
|
||||
supports_websockets = true
|
||||
requires_openai_auth = true
|
||||
|
||||
[features]
|
||||
responses_websockets_v2 = true`
|
||||
|
||||
// auth.json content
|
||||
const authContent = `{
|
||||
"OPENAI_API_KEY": "${apiKey}"
|
||||
}`
|
||||
|
||||
return [
|
||||
{
|
||||
path: `${configDir}/config.toml`,
|
||||
content: configContent,
|
||||
hint: t('keys.useKeyModal.openai.configTomlHint')
|
||||
},
|
||||
{
|
||||
path: `${configDir}/auth.json`,
|
||||
content: authContent
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: string, pathLabel?: string): FileConfig {
|
||||
const provider: Record<string, any> = {
|
||||
[platform]: {
|
||||
|
||||
@@ -194,6 +194,7 @@ import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore, useAuthStore, useOnboardingStore } from '@/stores'
|
||||
import { useAdminSettingsStore } from '@/stores/adminSettings'
|
||||
import LocaleSwitcher from '@/components/common/LocaleSwitcher.vue'
|
||||
import SubscriptionProgressMini from '@/components/common/SubscriptionProgressMini.vue'
|
||||
import AnnouncementBell from '@/components/common/AnnouncementBell.vue'
|
||||
@@ -204,6 +205,7 @@ const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
const authStore = useAuthStore()
|
||||
const adminSettingsStore = useAdminSettingsStore()
|
||||
const onboardingStore = useOnboardingStore()
|
||||
|
||||
const user = computed(() => authStore.user)
|
||||
@@ -237,6 +239,14 @@ const displayName = computed(() => {
|
||||
})
|
||||
|
||||
const pageTitle = computed(() => {
|
||||
// For custom pages, use the menu item's label instead of generic "自定义页面"
|
||||
if (route.name === 'CustomPage') {
|
||||
const id = route.params.id as string
|
||||
const publicItems = appStore.cachedPublicSettings?.custom_menu_items ?? []
|
||||
const menuItem = publicItems.find((item) => item.id === id)
|
||||
?? (authStore.isAdmin ? adminSettingsStore.customMenuItems.find((item) => item.id === id) : undefined)
|
||||
if (menuItem?.label) return menuItem.label
|
||||
}
|
||||
const titleKey = route.meta.titleKey as string
|
||||
if (titleKey) {
|
||||
return t(titleKey)
|
||||
|
||||
@@ -47,7 +47,8 @@
|
||||
"
|
||||
@click="handleMenuItemClick(item.path)"
|
||||
>
|
||||
<component :is="item.icon" class="h-5 w-5 flex-shrink-0" />
|
||||
<span v-if="item.iconSvg" class="h-5 w-5 flex-shrink-0 sidebar-svg-icon" v-html="sanitizeSvg(item.iconSvg)"></span>
|
||||
<component v-else :is="item.icon" class="h-5 w-5 flex-shrink-0" />
|
||||
<transition name="fade">
|
||||
<span v-if="!sidebarCollapsed">{{ item.label }}</span>
|
||||
</transition>
|
||||
@@ -71,7 +72,8 @@
|
||||
:data-tour="item.path === '/keys' ? 'sidebar-my-keys' : undefined"
|
||||
@click="handleMenuItemClick(item.path)"
|
||||
>
|
||||
<component :is="item.icon" class="h-5 w-5 flex-shrink-0" />
|
||||
<span v-if="item.iconSvg" class="h-5 w-5 flex-shrink-0 sidebar-svg-icon" v-html="sanitizeSvg(item.iconSvg)"></span>
|
||||
<component v-else :is="item.icon" class="h-5 w-5 flex-shrink-0" />
|
||||
<transition name="fade">
|
||||
<span v-if="!sidebarCollapsed">{{ item.label }}</span>
|
||||
</transition>
|
||||
@@ -92,7 +94,8 @@
|
||||
:data-tour="item.path === '/keys' ? 'sidebar-my-keys' : undefined"
|
||||
@click="handleMenuItemClick(item.path)"
|
||||
>
|
||||
<component :is="item.icon" class="h-5 w-5 flex-shrink-0" />
|
||||
<span v-if="item.iconSvg" class="h-5 w-5 flex-shrink-0 sidebar-svg-icon" v-html="sanitizeSvg(item.iconSvg)"></span>
|
||||
<component v-else :is="item.icon" class="h-5 w-5 flex-shrink-0" />
|
||||
<transition name="fade">
|
||||
<span v-if="!sidebarCollapsed">{{ item.label }}</span>
|
||||
</transition>
|
||||
@@ -149,6 +152,15 @@ import { useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAdminSettingsStore, useAppStore, useAuthStore, useOnboardingStore } from '@/stores'
|
||||
import VersionBadge from '@/components/common/VersionBadge.vue'
|
||||
import { sanitizeSvg } from '@/utils/sanitize'
|
||||
|
||||
interface NavItem {
|
||||
path: string
|
||||
label: string
|
||||
icon: unknown
|
||||
iconSvg?: string
|
||||
hideInSimpleMode?: boolean
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -294,17 +306,22 @@ const RechargeSubscriptionIcon = {
|
||||
render: () =>
|
||||
h(
|
||||
'svg',
|
||||
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
|
||||
{ fill: 'currentColor', viewBox: '0 0 1024 1024' },
|
||||
[
|
||||
h('path', {
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
d: 'M2.25 7.5A2.25 2.25 0 014.5 5.25h15A2.25 2.25 0 0121.75 7.5v9A2.25 2.25 0 0119.5 18.75h-15A2.25 2.25 0 012.25 16.5v-9z'
|
||||
d: 'M512 992C247.3 992 32 776.7 32 512S247.3 32 512 32s480 215.3 480 480c0 84.4-22.2 167.4-64.2 240-8.9 15.3-28.4 20.6-43.7 11.7-15.3-8.8-20.5-28.4-11.7-43.7 36.4-62.9 55.6-134.8 55.6-208 0-229.4-186.6-416-416-416S96 282.6 96 512s186.6 416 416 416c17.7 0 32 14.3 32 32s-14.3 32-32 32z'
|
||||
}),
|
||||
h('path', {
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
d: 'M6.75 12h3m4.5 0h3m-3-3v6'
|
||||
d: 'M640 512H384c-17.7 0-32-14.3-32-32s14.3-32 32-32h256c17.7 0 32 14.3 32 32s-14.3 32-32 32zM640 640H384c-17.7 0-32-14.3-32-32s14.3-32 32-32h256c17.7 0 32 14.3 32 32s-14.3 32-32 32z'
|
||||
}),
|
||||
h('path', {
|
||||
d: 'M512 480c-8.2 0-16.4-3.1-22.6-9.4l-128-128c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0l128 128c12.5 12.5 12.5 32.8 0 45.3-6.3 6.3-14.5 9.4-22.7 9.4z'
|
||||
}),
|
||||
h('path', {
|
||||
d: 'M512 480c-8.2 0-16.4-3.1-22.6-9.4-12.5-12.5-12.5-32.8 0-45.3l128-128c12.5-12.5 32.8-12.5 45.3 0s12.5 32.8 0 45.3l-128 128c-6.3 6.3-14.5 9.4-22.7 9.4z'
|
||||
}),
|
||||
h('path', {
|
||||
d: 'M512 736c-17.7 0-32-14.3-32-32V448c0-17.7 14.3-32 32-32s32 14.3 32 32v256c0 17.7-14.3 32-32 32zM896 992H512c-17.7 0-32-14.3-32-32s14.3-32 32-32h306.8l-73.4-73.4c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0l128 128c9.2 9.2 11.9 22.9 6.9 34.9S908.9 992 896 992z'
|
||||
})
|
||||
]
|
||||
)
|
||||
@@ -340,6 +357,36 @@ const ServerIcon = {
|
||||
)
|
||||
}
|
||||
|
||||
const DatabaseIcon = {
|
||||
render: () =>
|
||||
h(
|
||||
'svg',
|
||||
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
|
||||
[
|
||||
h('path', {
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
d: 'M3.75 5.25C3.75 4.007 7.443 3 12 3s8.25 1.007 8.25 2.25S16.557 7.5 12 7.5 3.75 6.493 3.75 5.25z'
|
||||
}),
|
||||
h('path', {
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
d: 'M3.75 5.25v4.5C3.75 10.993 7.443 12 12 12s8.25-1.007 8.25-2.25v-4.5'
|
||||
}),
|
||||
h('path', {
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
d: 'M3.75 9.75v4.5c0 1.243 3.693 2.25 8.25 2.25s8.25-1.007 8.25-2.25v-4.5'
|
||||
}),
|
||||
h('path', {
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
d: 'M3.75 14.25v4.5C3.75 19.993 7.443 21 12 21s8.25-1.007 8.25-2.25v-4.5'
|
||||
})
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
const BellIcon = {
|
||||
render: () =>
|
||||
h(
|
||||
@@ -435,6 +482,21 @@ const ChevronDoubleLeftIcon = {
|
||||
)
|
||||
}
|
||||
|
||||
const SoraIcon = {
|
||||
render: () =>
|
||||
h(
|
||||
'svg',
|
||||
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
|
||||
[
|
||||
h('path', {
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
d: 'M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z'
|
||||
})
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
const ChevronDoubleRightIcon = {
|
||||
render: () =>
|
||||
h(
|
||||
@@ -451,12 +513,15 @@ const ChevronDoubleRightIcon = {
|
||||
}
|
||||
|
||||
// User navigation items (for regular users)
|
||||
const userNavItems = computed(() => {
|
||||
const items = [
|
||||
const userNavItems = computed((): NavItem[] => {
|
||||
const items: NavItem[] = [
|
||||
{ path: '/dashboard', label: t('nav.dashboard'), icon: DashboardIcon },
|
||||
{ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon },
|
||||
{ path: '/usage', label: t('nav.usage'), icon: ChartIcon, hideInSimpleMode: true },
|
||||
{ path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon, hideInSimpleMode: true },
|
||||
...(appStore.cachedPublicSettings?.sora_client_enabled
|
||||
? [{ path: '/sora', label: t('nav.sora'), icon: SoraIcon }]
|
||||
: []),
|
||||
...(appStore.cachedPublicSettings?.purchase_subscription_enabled
|
||||
? [
|
||||
{
|
||||
@@ -468,17 +533,26 @@ const userNavItems = computed(() => {
|
||||
]
|
||||
: []),
|
||||
{ path: '/redeem', label: t('nav.redeem'), icon: GiftIcon, hideInSimpleMode: true },
|
||||
{ path: '/profile', label: t('nav.profile'), icon: UserIcon }
|
||||
{ path: '/profile', label: t('nav.profile'), icon: UserIcon },
|
||||
...customMenuItemsForUser.value.map((item): NavItem => ({
|
||||
path: `/custom/${item.id}`,
|
||||
label: item.label,
|
||||
icon: null,
|
||||
iconSvg: item.icon_svg,
|
||||
})),
|
||||
]
|
||||
return authStore.isSimpleMode ? items.filter(item => !item.hideInSimpleMode) : items
|
||||
})
|
||||
|
||||
// Personal navigation items (for admin's "My Account" section, without Dashboard)
|
||||
const personalNavItems = computed(() => {
|
||||
const items = [
|
||||
const personalNavItems = computed((): NavItem[] => {
|
||||
const items: NavItem[] = [
|
||||
{ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon },
|
||||
{ path: '/usage', label: t('nav.usage'), icon: ChartIcon, hideInSimpleMode: true },
|
||||
{ path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon, hideInSimpleMode: true },
|
||||
...(appStore.cachedPublicSettings?.sora_client_enabled
|
||||
? [{ path: '/sora', label: t('nav.sora'), icon: SoraIcon }]
|
||||
: []),
|
||||
...(appStore.cachedPublicSettings?.purchase_subscription_enabled
|
||||
? [
|
||||
{
|
||||
@@ -490,14 +564,34 @@ const personalNavItems = computed(() => {
|
||||
]
|
||||
: []),
|
||||
{ path: '/redeem', label: t('nav.redeem'), icon: GiftIcon, hideInSimpleMode: true },
|
||||
{ path: '/profile', label: t('nav.profile'), icon: UserIcon }
|
||||
{ path: '/profile', label: t('nav.profile'), icon: UserIcon },
|
||||
...customMenuItemsForUser.value.map((item): NavItem => ({
|
||||
path: `/custom/${item.id}`,
|
||||
label: item.label,
|
||||
icon: null,
|
||||
iconSvg: item.icon_svg,
|
||||
})),
|
||||
]
|
||||
return authStore.isSimpleMode ? items.filter(item => !item.hideInSimpleMode) : items
|
||||
})
|
||||
|
||||
// Custom menu items filtered by visibility
|
||||
const customMenuItemsForUser = computed(() => {
|
||||
const items = appStore.cachedPublicSettings?.custom_menu_items ?? []
|
||||
return items
|
||||
.filter((item) => item.visibility === 'user')
|
||||
.sort((a, b) => a.sort_order - b.sort_order)
|
||||
})
|
||||
|
||||
const customMenuItemsForAdmin = computed(() => {
|
||||
return adminSettingsStore.customMenuItems
|
||||
.filter((item) => item.visibility === 'admin')
|
||||
.sort((a, b) => a.sort_order - b.sort_order)
|
||||
})
|
||||
|
||||
// Admin navigation items
|
||||
const adminNavItems = computed(() => {
|
||||
const baseItems = [
|
||||
const adminNavItems = computed((): NavItem[] => {
|
||||
const baseItems: NavItem[] = [
|
||||
{ path: '/admin/dashboard', label: t('nav.dashboard'), icon: DashboardIcon },
|
||||
...(adminSettingsStore.opsMonitoringEnabled
|
||||
? [{ path: '/admin/ops', label: t('nav.ops'), icon: ChartIcon }]
|
||||
@@ -510,18 +604,28 @@ const adminNavItems = computed(() => {
|
||||
{ path: '/admin/proxies', label: t('nav.proxies'), icon: ServerIcon },
|
||||
{ path: '/admin/redeem', label: t('nav.redeemCodes'), icon: TicketIcon, hideInSimpleMode: true },
|
||||
{ path: '/admin/promo-codes', label: t('nav.promoCodes'), icon: GiftIcon, hideInSimpleMode: true },
|
||||
{ path: '/admin/usage', label: t('nav.usage'), icon: ChartIcon },
|
||||
{ path: '/admin/usage', label: t('nav.usage'), icon: ChartIcon }
|
||||
]
|
||||
|
||||
// 简单模式下,在系统设置前插入 API密钥
|
||||
if (authStore.isSimpleMode) {
|
||||
const filtered = baseItems.filter(item => !item.hideInSimpleMode)
|
||||
filtered.push({ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon })
|
||||
filtered.push({ path: '/admin/data-management', label: t('nav.dataManagement'), icon: DatabaseIcon })
|
||||
filtered.push({ path: '/admin/settings', label: t('nav.settings'), icon: CogIcon })
|
||||
// Add admin custom menu items after settings
|
||||
for (const cm of customMenuItemsForAdmin.value) {
|
||||
filtered.push({ path: `/custom/${cm.id}`, label: cm.label, icon: null, iconSvg: cm.icon_svg })
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
baseItems.push({ path: '/admin/data-management', label: t('nav.dataManagement'), icon: DatabaseIcon })
|
||||
baseItems.push({ path: '/admin/settings', label: t('nav.settings'), icon: CogIcon })
|
||||
// Add admin custom menu items after settings
|
||||
for (const cm of customMenuItemsForAdmin.value) {
|
||||
baseItems.push({ path: `/custom/${cm.id}`, label: cm.label, icon: null, iconSvg: cm.icon_svg })
|
||||
}
|
||||
return baseItems
|
||||
})
|
||||
|
||||
@@ -601,4 +705,12 @@ onMounted(() => {
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Custom SVG icon in sidebar: inherit color, constrain size */
|
||||
.sidebar-svg-icon :deep(svg) {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
217
frontend/src/components/sora/SoraDownloadDialog.vue
Normal file
217
frontend/src/components/sora/SoraDownloadDialog.vue
Normal file
@@ -0,0 +1,217 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="sora-modal">
|
||||
<div v-if="visible && generation" class="sora-download-overlay" @click.self="emit('close')">
|
||||
<div class="sora-download-backdrop" />
|
||||
<div class="sora-download-modal" @click.stop>
|
||||
<div class="sora-download-modal-icon">📥</div>
|
||||
<h3 class="sora-download-modal-title">{{ t('sora.downloadTitle') }}</h3>
|
||||
<p class="sora-download-modal-desc">{{ t('sora.downloadExpirationWarning') }}</p>
|
||||
|
||||
<!-- 倒计时 -->
|
||||
<div v-if="remainingText" class="sora-download-countdown">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span :class="{ expired: isExpired }">
|
||||
{{ isExpired ? t('sora.upstreamExpired') : t('sora.upstreamCountdown', { time: remainingText }) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="sora-download-modal-actions">
|
||||
<a
|
||||
v-if="generation.media_url"
|
||||
:href="generation.media_url"
|
||||
target="_blank"
|
||||
download
|
||||
class="sora-download-btn primary"
|
||||
>
|
||||
{{ t('sora.downloadNow') }}
|
||||
</a>
|
||||
<button class="sora-download-btn ghost" @click="emit('close')">
|
||||
{{ t('sora.closePreview') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { SoraGeneration } from '@/api/sora'
|
||||
|
||||
const EXPIRATION_MINUTES = 15
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
generation: SoraGeneration | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
const { t } = useI18n()
|
||||
|
||||
const now = ref(Date.now())
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const expiresAt = computed(() => {
|
||||
if (!props.generation?.completed_at) return null
|
||||
return new Date(props.generation.completed_at).getTime() + EXPIRATION_MINUTES * 60 * 1000
|
||||
})
|
||||
|
||||
const isExpired = computed(() => {
|
||||
if (!expiresAt.value) return false
|
||||
return now.value >= expiresAt.value
|
||||
})
|
||||
|
||||
const remainingText = computed(() => {
|
||||
if (!expiresAt.value) return ''
|
||||
const diff = expiresAt.value - now.value
|
||||
if (diff <= 0) return ''
|
||||
const minutes = Math.floor(diff / 60000)
|
||||
const seconds = Math.floor((diff % 60000) / 1000)
|
||||
return `${minutes}:${String(seconds).padStart(2, '0')}`
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(v) => {
|
||||
if (v) {
|
||||
now.value = Date.now()
|
||||
timer = setInterval(() => { now.value = Date.now() }, 1000)
|
||||
} else if (timer) {
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer) clearInterval(timer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sora-download-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.sora-download-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--sora-modal-backdrop, rgba(0, 0, 0, 0.4));
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.sora-download-modal {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
background: var(--sora-bg-secondary, #FFF);
|
||||
border: 1px solid var(--sora-border-color, #E5E7EB);
|
||||
border-radius: 20px;
|
||||
padding: 32px;
|
||||
max-width: 420px;
|
||||
width: 90%;
|
||||
text-align: center;
|
||||
animation: sora-modal-in 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes sora-modal-in {
|
||||
from { transform: scale(0.95); opacity: 0; }
|
||||
to { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
.sora-download-modal-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.sora-download-modal-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--sora-text-primary, #111827);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.sora-download-modal-desc {
|
||||
font-size: 14px;
|
||||
color: var(--sora-text-secondary, #6B7280);
|
||||
margin-bottom: 20px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.sora-download-countdown {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
font-size: 14px;
|
||||
color: var(--sora-text-secondary, #6B7280);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.sora-download-countdown svg {
|
||||
color: var(--sora-text-tertiary, #9CA3AF);
|
||||
}
|
||||
|
||||
.sora-download-countdown .expired {
|
||||
color: #EF4444;
|
||||
}
|
||||
|
||||
.sora-download-modal-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.sora-download-btn {
|
||||
padding: 10px 24px;
|
||||
border-radius: 9999px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.sora-download-btn.primary {
|
||||
background: var(--sora-accent-gradient);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.sora-download-btn.primary:hover {
|
||||
box-shadow: var(--sora-shadow-glow);
|
||||
}
|
||||
|
||||
.sora-download-btn.ghost {
|
||||
background: var(--sora-bg-tertiary, #F3F4F6);
|
||||
color: var(--sora-text-secondary, #6B7280);
|
||||
}
|
||||
|
||||
.sora-download-btn.ghost:hover {
|
||||
background: var(--sora-bg-hover, #E5E7EB);
|
||||
color: var(--sora-text-primary, #111827);
|
||||
}
|
||||
|
||||
/* 过渡 */
|
||||
.sora-modal-enter-active,
|
||||
.sora-modal-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.sora-modal-enter-from,
|
||||
.sora-modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
430
frontend/src/components/sora/SoraGeneratePage.vue
Normal file
430
frontend/src/components/sora/SoraGeneratePage.vue
Normal file
@@ -0,0 +1,430 @@
|
||||
<template>
|
||||
<div class="sora-generate-page">
|
||||
<div class="sora-task-area">
|
||||
<!-- 欢迎区域(无任务时显示) -->
|
||||
<div v-if="activeGenerations.length === 0" class="sora-welcome-section">
|
||||
<h1 class="sora-welcome-title">{{ t('sora.welcomeTitle') }}</h1>
|
||||
<p class="sora-welcome-subtitle">{{ t('sora.welcomeSubtitle') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 示例提示词(无任务时显示) -->
|
||||
<div v-if="activeGenerations.length === 0" class="sora-example-prompts">
|
||||
<button
|
||||
v-for="(example, idx) in examplePrompts"
|
||||
:key="idx"
|
||||
class="sora-example-prompt"
|
||||
@click="fillPrompt(example)"
|
||||
>
|
||||
{{ example }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 任务卡片列表 -->
|
||||
<div v-if="activeGenerations.length > 0" class="sora-task-cards">
|
||||
<SoraProgressCard
|
||||
v-for="gen in activeGenerations"
|
||||
:key="gen.id"
|
||||
:generation="gen"
|
||||
@cancel="handleCancel"
|
||||
@delete="handleDelete"
|
||||
@save="handleSave"
|
||||
@retry="handleRetry"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 无存储提示 Toast -->
|
||||
<div v-if="showNoStorageToast" class="sora-no-storage-toast">
|
||||
<span>⚠️</span>
|
||||
<span>{{ t('sora.noStorageToastMessage') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部创作栏 -->
|
||||
<SoraPromptBar
|
||||
ref="promptBarRef"
|
||||
:generating="generating"
|
||||
:active-task-count="activeTaskCount"
|
||||
:max-concurrent-tasks="3"
|
||||
@generate="handleGenerate"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import soraAPI, { type SoraGeneration, type GenerateRequest } from '@/api/sora'
|
||||
import SoraProgressCard from './SoraProgressCard.vue'
|
||||
import SoraPromptBar from './SoraPromptBar.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'task-count-change': [counts: { active: number; generating: boolean }]
|
||||
}>()
|
||||
|
||||
const activeGenerations = ref<SoraGeneration[]>([])
|
||||
const generating = ref(false)
|
||||
const showNoStorageToast = ref(false)
|
||||
let pollTimers: Record<number, ReturnType<typeof setTimeout>> = {}
|
||||
const promptBarRef = ref<InstanceType<typeof SoraPromptBar> | null>(null)
|
||||
|
||||
// 示例提示词
|
||||
const examplePrompts = [
|
||||
'一只金色的柴犬在东京涩谷街头散步,镜头跟随,电影感画面,4K 高清',
|
||||
'无人机航拍视角,冰岛极光下的冰川湖面反射绿色光芒,慢速推进',
|
||||
'赛博朋克风格的未来城市,霓虹灯倒映在雨后积水中,夜景,电影级色彩',
|
||||
'水墨画风格,一叶扁舟在山水间漂泊,薄雾缭绕,中国古典意境'
|
||||
]
|
||||
|
||||
// 活跃任务统计
|
||||
const activeTaskCount = computed(() =>
|
||||
activeGenerations.value.filter(g => g.status === 'pending' || g.status === 'generating').length
|
||||
)
|
||||
|
||||
const hasGeneratingTask = computed(() =>
|
||||
activeGenerations.value.some(g => g.status === 'generating')
|
||||
)
|
||||
|
||||
// 通知父组件任务数变化
|
||||
watch([activeTaskCount, hasGeneratingTask], () => {
|
||||
emit('task-count-change', {
|
||||
active: activeTaskCount.value,
|
||||
generating: hasGeneratingTask.value
|
||||
})
|
||||
}, { immediate: true })
|
||||
|
||||
// ==================== 浏览器通知 ====================
|
||||
|
||||
function requestNotificationPermission() {
|
||||
if ('Notification' in window && Notification.permission === 'default') {
|
||||
Notification.requestPermission()
|
||||
}
|
||||
}
|
||||
|
||||
function sendNotification(title: string, body: string) {
|
||||
if ('Notification' in window && Notification.permission === 'granted') {
|
||||
new Notification(title, { body, icon: '/favicon.ico' })
|
||||
}
|
||||
}
|
||||
|
||||
const originalTitle = document.title
|
||||
let titleBlinkTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function startTitleBlink(message: string) {
|
||||
stopTitleBlink()
|
||||
let show = true
|
||||
titleBlinkTimer = setInterval(() => {
|
||||
document.title = show ? message : originalTitle
|
||||
show = !show
|
||||
}, 1000)
|
||||
const onFocus = () => {
|
||||
stopTitleBlink()
|
||||
window.removeEventListener('focus', onFocus)
|
||||
}
|
||||
window.addEventListener('focus', onFocus)
|
||||
}
|
||||
|
||||
function stopTitleBlink() {
|
||||
if (titleBlinkTimer) {
|
||||
clearInterval(titleBlinkTimer)
|
||||
titleBlinkTimer = null
|
||||
}
|
||||
document.title = originalTitle
|
||||
}
|
||||
|
||||
function checkStatusTransition(oldGen: SoraGeneration, newGen: SoraGeneration) {
|
||||
const wasActive = oldGen.status === 'pending' || oldGen.status === 'generating'
|
||||
if (!wasActive) return
|
||||
if (newGen.status === 'completed') {
|
||||
const title = t('sora.notificationCompleted')
|
||||
const body = t('sora.notificationCompletedBody', { model: newGen.model })
|
||||
sendNotification(title, body)
|
||||
if (document.hidden) startTitleBlink(title)
|
||||
} else if (newGen.status === 'failed') {
|
||||
const title = t('sora.notificationFailed')
|
||||
const body = t('sora.notificationFailedBody', { model: newGen.model })
|
||||
sendNotification(title, body)
|
||||
if (document.hidden) startTitleBlink(title)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== beforeunload ====================
|
||||
|
||||
const hasUpstreamRecords = computed(() =>
|
||||
activeGenerations.value.some(g => g.status === 'completed' && g.storage_type === 'upstream')
|
||||
)
|
||||
|
||||
function beforeUnloadHandler(e: BeforeUnloadEvent) {
|
||||
if (hasUpstreamRecords.value) {
|
||||
e.preventDefault()
|
||||
e.returnValue = t('sora.beforeUnloadWarning')
|
||||
return e.returnValue
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 轮询 ====================
|
||||
|
||||
function getPollingIntervalByRuntime(createdAt: string): number {
|
||||
const createdAtMs = new Date(createdAt).getTime()
|
||||
if (Number.isNaN(createdAtMs)) return 3000
|
||||
const elapsedMs = Date.now() - createdAtMs
|
||||
if (elapsedMs < 2 * 60 * 1000) return 3000
|
||||
if (elapsedMs < 10 * 60 * 1000) return 10000
|
||||
return 30000
|
||||
}
|
||||
|
||||
function schedulePolling(id: number) {
|
||||
const current = activeGenerations.value.find(g => g.id === id)
|
||||
const interval = current ? getPollingIntervalByRuntime(current.created_at) : 3000
|
||||
if (pollTimers[id]) clearTimeout(pollTimers[id])
|
||||
pollTimers[id] = setTimeout(() => { void pollGeneration(id) }, interval)
|
||||
}
|
||||
|
||||
async function pollGeneration(id: number) {
|
||||
try {
|
||||
const gen = await soraAPI.getGeneration(id)
|
||||
const idx = activeGenerations.value.findIndex(g => g.id === id)
|
||||
if (idx >= 0) {
|
||||
checkStatusTransition(activeGenerations.value[idx], gen)
|
||||
activeGenerations.value[idx] = gen
|
||||
}
|
||||
if (gen.status === 'pending' || gen.status === 'generating') {
|
||||
schedulePolling(id)
|
||||
} else {
|
||||
delete pollTimers[id]
|
||||
}
|
||||
} catch {
|
||||
delete pollTimers[id]
|
||||
}
|
||||
}
|
||||
|
||||
async function loadActiveGenerations() {
|
||||
try {
|
||||
const res = await soraAPI.listGenerations({
|
||||
status: 'pending,generating,completed,failed,cancelled',
|
||||
page_size: 50
|
||||
})
|
||||
const generations = Array.isArray(res.data) ? res.data : []
|
||||
activeGenerations.value = generations
|
||||
for (const gen of generations) {
|
||||
if ((gen.status === 'pending' || gen.status === 'generating') && !pollTimers[gen.id]) {
|
||||
schedulePolling(gen.id)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load generations:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 操作 ====================
|
||||
|
||||
async function handleGenerate(req: GenerateRequest) {
|
||||
generating.value = true
|
||||
try {
|
||||
const res = await soraAPI.generate(req)
|
||||
const gen = await soraAPI.getGeneration(res.generation_id)
|
||||
activeGenerations.value.unshift(gen)
|
||||
schedulePolling(gen.id)
|
||||
} catch (e: any) {
|
||||
console.error('Generate failed:', e)
|
||||
alert(e?.response?.data?.message || e?.message || 'Generation failed')
|
||||
} finally {
|
||||
generating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancel(id: number) {
|
||||
try {
|
||||
await soraAPI.cancelGeneration(id)
|
||||
const idx = activeGenerations.value.findIndex(g => g.id === id)
|
||||
if (idx >= 0) activeGenerations.value[idx].status = 'cancelled'
|
||||
} catch (e) {
|
||||
console.error('Cancel failed:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
try {
|
||||
await soraAPI.deleteGeneration(id)
|
||||
activeGenerations.value = activeGenerations.value.filter(g => g.id !== id)
|
||||
} catch (e) {
|
||||
console.error('Delete failed:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave(id: number) {
|
||||
try {
|
||||
await soraAPI.saveToStorage(id)
|
||||
const gen = await soraAPI.getGeneration(id)
|
||||
const idx = activeGenerations.value.findIndex(g => g.id === id)
|
||||
if (idx >= 0) activeGenerations.value[idx] = gen
|
||||
} catch (e) {
|
||||
console.error('Save failed:', e)
|
||||
}
|
||||
}
|
||||
|
||||
function handleRetry(gen: SoraGeneration) {
|
||||
handleGenerate({ model: gen.model, prompt: gen.prompt, media_type: gen.media_type })
|
||||
}
|
||||
|
||||
function fillPrompt(text: string) {
|
||||
promptBarRef.value?.fillPrompt(text)
|
||||
}
|
||||
|
||||
// ==================== 检查存储状态 ====================
|
||||
|
||||
async function checkStorageStatus() {
|
||||
try {
|
||||
const status = await soraAPI.getStorageStatus()
|
||||
if (!status.s3_enabled || !status.s3_healthy) {
|
||||
showNoStorageToast.value = true
|
||||
setTimeout(() => { showNoStorageToast.value = false }, 8000)
|
||||
}
|
||||
} catch {
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadActiveGenerations()
|
||||
requestNotificationPermission()
|
||||
checkStorageStatus()
|
||||
window.addEventListener('beforeunload', beforeUnloadHandler)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
Object.values(pollTimers).forEach(clearTimeout)
|
||||
pollTimers = {}
|
||||
stopTitleBlink()
|
||||
window.removeEventListener('beforeunload', beforeUnloadHandler)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sora-generate-page {
|
||||
padding-bottom: 200px;
|
||||
min-height: calc(100vh - 56px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 任务区域 */
|
||||
.sora-task-area {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 24px;
|
||||
gap: 24px;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 欢迎区域 */
|
||||
.sora-welcome-section {
|
||||
text-align: center;
|
||||
padding: 60px 0 40px;
|
||||
}
|
||||
|
||||
.sora-welcome-title {
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.03em;
|
||||
margin-bottom: 12px;
|
||||
background: linear-gradient(135deg, var(--sora-text-primary) 0%, var(--sora-text-secondary) 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.sora-welcome-subtitle {
|
||||
font-size: 16px;
|
||||
color: var(--sora-text-secondary, #A0A0A0);
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* 示例提示词 */
|
||||
.sora-example-prompts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
.sora-example-prompt {
|
||||
padding: 16px 20px;
|
||||
background: var(--sora-bg-secondary, #1A1A1A);
|
||||
border: 1px solid var(--sora-border-color, #2A2A2A);
|
||||
border-radius: var(--sora-radius-md, 12px);
|
||||
font-size: 13px;
|
||||
color: var(--sora-text-secondary, #A0A0A0);
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
text-align: left;
|
||||
line-height: 1.5;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.sora-example-prompt:hover {
|
||||
background: var(--sora-bg-tertiary, #242424);
|
||||
border-color: var(--sora-bg-hover, #333);
|
||||
color: var(--sora-text-primary, #FFF);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* 任务卡片列表 */
|
||||
.sora-task-cards {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* 无存储 Toast */
|
||||
.sora-no-storage-toast {
|
||||
position: fixed;
|
||||
top: 80px;
|
||||
right: 24px;
|
||||
background: var(--sora-bg-elevated, #2A2A2A);
|
||||
border: 1px solid var(--sora-warning, #F59E0B);
|
||||
border-radius: var(--sora-radius-md, 12px);
|
||||
padding: 14px 20px;
|
||||
font-size: 13px;
|
||||
color: var(--sora-warning, #F59E0B);
|
||||
z-index: 50;
|
||||
box-shadow: var(--sora-shadow-lg, 0 8px 32px rgba(0,0,0,0.5));
|
||||
animation: sora-slide-in-right 0.3s ease;
|
||||
max-width: 340px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
@keyframes sora-slide-in-right {
|
||||
from { transform: translateX(100%); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 900px) {
|
||||
.sora-example-prompts {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.sora-welcome-title {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.sora-task-area {
|
||||
padding: 24px 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
576
frontend/src/components/sora/SoraLibraryPage.vue
Normal file
576
frontend/src/components/sora/SoraLibraryPage.vue
Normal file
@@ -0,0 +1,576 @@
|
||||
<template>
|
||||
<div class="sora-gallery-page">
|
||||
<!-- 筛选栏 -->
|
||||
<div class="sora-gallery-filter-bar">
|
||||
<div class="sora-gallery-filters">
|
||||
<button
|
||||
v-for="f in filters"
|
||||
:key="f.value"
|
||||
:class="['sora-gallery-filter', activeFilter === f.value && 'active']"
|
||||
@click="activeFilter = f.value"
|
||||
>
|
||||
{{ f.label }}
|
||||
</button>
|
||||
</div>
|
||||
<span class="sora-gallery-count">
|
||||
{{ t('sora.galleryCount', { count: filteredItems.length }) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 作品网格 -->
|
||||
<div v-if="filteredItems.length > 0" class="sora-gallery-grid">
|
||||
<div
|
||||
v-for="item in filteredItems"
|
||||
:key="item.id"
|
||||
class="sora-gallery-card"
|
||||
@click="openPreview(item)"
|
||||
>
|
||||
<div class="sora-gallery-card-thumb">
|
||||
<!-- 媒体 -->
|
||||
<video
|
||||
v-if="item.media_type === 'video' && item.media_url"
|
||||
:src="item.media_url"
|
||||
class="sora-gallery-card-image"
|
||||
muted
|
||||
loop
|
||||
@mouseenter="($event.target as HTMLVideoElement).play()"
|
||||
@mouseleave="($event.target as HTMLVideoElement).pause()"
|
||||
/>
|
||||
<img
|
||||
v-else-if="item.media_url"
|
||||
:src="item.media_url"
|
||||
class="sora-gallery-card-image"
|
||||
alt=""
|
||||
/>
|
||||
<div v-else class="sora-gallery-card-image sora-gallery-card-placeholder" :class="getGradientClass(item.id)">
|
||||
{{ item.media_type === 'video' ? '🎬' : '🎨' }}
|
||||
</div>
|
||||
|
||||
<!-- 类型角标 -->
|
||||
<span
|
||||
class="sora-gallery-card-badge"
|
||||
:class="item.media_type === 'video' ? 'video' : 'image'"
|
||||
>
|
||||
{{ item.media_type === 'video' ? 'VIDEO' : 'IMAGE' }}
|
||||
</span>
|
||||
|
||||
<!-- Hover 操作层 -->
|
||||
<div class="sora-gallery-card-overlay">
|
||||
<button
|
||||
v-if="item.media_url"
|
||||
class="sora-gallery-card-action"
|
||||
title="下载"
|
||||
@click.stop="handleDownload(item)"
|
||||
>
|
||||
📥
|
||||
</button>
|
||||
<button
|
||||
class="sora-gallery-card-action"
|
||||
title="删除"
|
||||
@click.stop="handleDelete(item.id)"
|
||||
>
|
||||
🗑
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 视频播放指示 -->
|
||||
<div v-if="item.media_type === 'video'" class="sora-gallery-card-play">▶</div>
|
||||
|
||||
<!-- 视频时长 -->
|
||||
<span v-if="item.media_type === 'video'" class="sora-gallery-card-duration">
|
||||
{{ formatDuration(item) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 卡片底部信息 -->
|
||||
<div class="sora-gallery-card-info">
|
||||
<div class="sora-gallery-card-model">{{ item.model }}</div>
|
||||
<div class="sora-gallery-card-time">{{ formatTime(item.created_at) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-else-if="!loading" class="sora-gallery-empty">
|
||||
<div class="sora-gallery-empty-icon">🎬</div>
|
||||
<h2 class="sora-gallery-empty-title">{{ t('sora.galleryEmptyTitle') }}</h2>
|
||||
<p class="sora-gallery-empty-desc">{{ t('sora.galleryEmptyDesc') }}</p>
|
||||
<button class="sora-gallery-empty-btn" @click="emit('switchToGenerate')">
|
||||
{{ t('sora.startCreating') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 加载更多 -->
|
||||
<div v-if="hasMore && filteredItems.length > 0" class="sora-gallery-load-more">
|
||||
<button
|
||||
class="sora-gallery-load-more-btn"
|
||||
:disabled="loading"
|
||||
@click="loadMore"
|
||||
>
|
||||
{{ loading ? t('sora.loading') : t('sora.loadMore') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 预览弹窗 -->
|
||||
<SoraMediaPreview
|
||||
:visible="previewVisible"
|
||||
:generation="previewItem"
|
||||
@close="previewVisible = false"
|
||||
@save="handleSaveFromPreview"
|
||||
@download="handleDownloadUrl"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import soraAPI, { type SoraGeneration } from '@/api/sora'
|
||||
import SoraMediaPreview from './SoraMediaPreview.vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
'switchToGenerate': []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const items = ref<SoraGeneration[]>([])
|
||||
const loading = ref(false)
|
||||
const page = ref(1)
|
||||
const hasMore = ref(true)
|
||||
const activeFilter = ref('all')
|
||||
const previewVisible = ref(false)
|
||||
const previewItem = ref<SoraGeneration | null>(null)
|
||||
|
||||
const filters = computed(() => [
|
||||
{ value: 'all', label: t('sora.filterAll') },
|
||||
{ value: 'video', label: t('sora.filterVideo') },
|
||||
{ value: 'image', label: t('sora.filterImage') }
|
||||
])
|
||||
|
||||
const filteredItems = computed(() => {
|
||||
if (activeFilter.value === 'all') return items.value
|
||||
return items.value.filter(i => i.media_type === activeFilter.value)
|
||||
})
|
||||
|
||||
const gradientClasses = [
|
||||
'gradient-bg-1', 'gradient-bg-2', 'gradient-bg-3', 'gradient-bg-4',
|
||||
'gradient-bg-5', 'gradient-bg-6', 'gradient-bg-7', 'gradient-bg-8'
|
||||
]
|
||||
|
||||
function getGradientClass(id: number): string {
|
||||
return gradientClasses[id % gradientClasses.length]
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - d.getTime()
|
||||
if (diff < 60000) return t('sora.justNow')
|
||||
if (diff < 3600000) return t('sora.minutesAgo', { n: Math.floor(diff / 60000) })
|
||||
if (diff < 86400000) return t('sora.hoursAgo', { n: Math.floor(diff / 3600000) })
|
||||
if (diff < 2 * 86400000) return t('sora.yesterday')
|
||||
return d.toLocaleDateString()
|
||||
}
|
||||
|
||||
function formatDuration(item: SoraGeneration): string {
|
||||
// 从模型名提取时长,如 sora2-landscape-10s -> 0:10
|
||||
const match = item.model.match(/(\d+)s$/)
|
||||
if (match) {
|
||||
const sec = parseInt(match[1])
|
||||
return `0:${sec.toString().padStart(2, '0')}`
|
||||
}
|
||||
return '0:10'
|
||||
}
|
||||
|
||||
async function loadItems(pageNum: number) {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await soraAPI.listGenerations({
|
||||
status: 'completed',
|
||||
storage_type: 's3,local',
|
||||
page: pageNum,
|
||||
page_size: 20
|
||||
})
|
||||
const rows = Array.isArray(res.data) ? res.data : []
|
||||
if (pageNum === 1) {
|
||||
items.value = rows
|
||||
} else {
|
||||
items.value.push(...rows)
|
||||
}
|
||||
hasMore.value = items.value.length < res.total
|
||||
} catch (e) {
|
||||
console.error('Failed to load library:', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function loadMore() {
|
||||
page.value++
|
||||
loadItems(page.value)
|
||||
}
|
||||
|
||||
function openPreview(item: SoraGeneration) {
|
||||
previewItem.value = item
|
||||
previewVisible.value = true
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
if (!confirm(t('sora.confirmDelete'))) return
|
||||
try {
|
||||
await soraAPI.deleteGeneration(id)
|
||||
items.value = items.value.filter(i => i.id !== id)
|
||||
} catch (e) {
|
||||
console.error('Delete failed:', e)
|
||||
}
|
||||
}
|
||||
|
||||
function handleDownload(item: SoraGeneration) {
|
||||
if (item.media_url) {
|
||||
window.open(item.media_url, '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
function handleDownloadUrl(url: string) {
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
async function handleSaveFromPreview(id: number) {
|
||||
try {
|
||||
await soraAPI.saveToStorage(id)
|
||||
const gen = await soraAPI.getGeneration(id)
|
||||
const idx = items.value.findIndex(i => i.id === id)
|
||||
if (idx >= 0) items.value[idx] = gen
|
||||
} catch (e) {
|
||||
console.error('Save failed:', e)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => loadItems(1))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sora-gallery-page {
|
||||
padding: 24px;
|
||||
padding-bottom: 40px;
|
||||
}
|
||||
|
||||
/* 筛选栏 */
|
||||
.sora-gallery-filter-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.sora-gallery-filters {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
background: var(--sora-bg-secondary, #1A1A1A);
|
||||
border-radius: var(--sora-radius-full, 9999px);
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
.sora-gallery-filter {
|
||||
padding: 6px 18px;
|
||||
border-radius: var(--sora-radius-full, 9999px);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--sora-text-secondary, #A0A0A0);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.sora-gallery-filter:hover {
|
||||
color: var(--sora-text-primary, #FFF);
|
||||
}
|
||||
|
||||
.sora-gallery-filter.active {
|
||||
background: var(--sora-bg-tertiary, #242424);
|
||||
color: var(--sora-text-primary, #FFF);
|
||||
}
|
||||
|
||||
.sora-gallery-count {
|
||||
font-size: 13px;
|
||||
color: var(--sora-text-tertiary, #666);
|
||||
}
|
||||
|
||||
/* 网格 */
|
||||
.sora-gallery-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* 卡片 */
|
||||
.sora-gallery-card {
|
||||
position: relative;
|
||||
border-radius: var(--sora-radius-md, 12px);
|
||||
overflow: hidden;
|
||||
background: var(--sora-bg-secondary, #1A1A1A);
|
||||
border: 1px solid var(--sora-border-color, #2A2A2A);
|
||||
cursor: pointer;
|
||||
transition: all 250ms ease;
|
||||
}
|
||||
|
||||
.sora-gallery-card:hover {
|
||||
border-color: var(--sora-bg-hover, #333);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--sora-shadow-lg, 0 8px 32px rgba(0,0,0,0.5));
|
||||
}
|
||||
|
||||
.sora-gallery-card-thumb {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 16/9;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sora-gallery-card-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
transition: transform 400ms ease;
|
||||
}
|
||||
|
||||
.sora-gallery-card:hover .sora-gallery-card-image {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.sora-gallery-card-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
/* 渐变背景 */
|
||||
.gradient-bg-1 { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); }
|
||||
.gradient-bg-2 { background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); }
|
||||
.gradient-bg-3 { background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%); }
|
||||
.gradient-bg-4 { background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%); }
|
||||
.gradient-bg-5 { background: linear-gradient(135deg, #fa709a 0%, #fee140 100%); }
|
||||
.gradient-bg-6 { background: linear-gradient(135deg, #a18cd1 0%, #fbc2eb 100%); }
|
||||
.gradient-bg-7 { background: linear-gradient(135deg, #fccb90 0%, #d57eeb 100%); }
|
||||
.gradient-bg-8 { background: linear-gradient(135deg, #e0c3fc 0%, #8ec5fc 100%); }
|
||||
|
||||
/* 类型角标 */
|
||||
.sora-gallery-card-badge {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
padding: 3px 8px;
|
||||
border-radius: var(--sora-radius-sm, 8px);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.sora-gallery-card-badge.video {
|
||||
background: rgba(20, 184, 166, 0.8);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.sora-gallery-card-badge.image {
|
||||
background: rgba(16, 185, 129, 0.8);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Hover 操作层 */
|
||||
.sora-gallery-card-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
opacity: 0;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
|
||||
.sora-gallery-card:hover .sora-gallery-card-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.sora-gallery-card-action {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
backdrop-filter: blur(8px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
color: white;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.sora-gallery-card-action:hover {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* 播放指示 */
|
||||
.sora-gallery-card-play {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
backdrop-filter: blur(8px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
color: white;
|
||||
opacity: 0;
|
||||
transition: all 150ms ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.sora-gallery-card:hover .sora-gallery-card-play {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 视频时长 */
|
||||
.sora-gallery-card-duration {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
right: 8px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
font-size: 11px;
|
||||
font-family: "SF Mono", "Fira Code", monospace;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* 卡片信息 */
|
||||
.sora-gallery-card-info {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.sora-gallery-card-model {
|
||||
font-size: 11px;
|
||||
font-family: "SF Mono", "Fira Code", monospace;
|
||||
color: var(--sora-text-tertiary, #666);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.sora-gallery-card-time {
|
||||
font-size: 12px;
|
||||
color: var(--sora-text-muted, #4A4A4A);
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.sora-gallery-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 120px 40px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sora-gallery-empty-icon {
|
||||
font-size: 64px;
|
||||
margin-bottom: 24px;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.sora-gallery-empty-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
color: var(--sora-text-secondary, #A0A0A0);
|
||||
}
|
||||
|
||||
.sora-gallery-empty-desc {
|
||||
font-size: 14px;
|
||||
color: var(--sora-text-tertiary, #666);
|
||||
max-width: 360px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.sora-gallery-empty-btn {
|
||||
margin-top: 24px;
|
||||
padding: 10px 28px;
|
||||
background: var(--sora-accent-gradient, linear-gradient(135deg, #14b8a6, #0d9488));
|
||||
border-radius: var(--sora-radius-full, 9999px);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: white;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.sora-gallery-empty-btn:hover {
|
||||
box-shadow: var(--sora-shadow-glow, 0 0 20px rgba(20,184,166,0.3));
|
||||
}
|
||||
|
||||
/* 加载更多 */
|
||||
.sora-gallery-load-more {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.sora-gallery-load-more-btn {
|
||||
padding: 10px 28px;
|
||||
background: var(--sora-bg-secondary, #1A1A1A);
|
||||
border: 1px solid var(--sora-border-color, #2A2A2A);
|
||||
border-radius: var(--sora-radius-full, 9999px);
|
||||
font-size: 13px;
|
||||
color: var(--sora-text-secondary, #A0A0A0);
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.sora-gallery-load-more-btn:hover {
|
||||
background: var(--sora-bg-tertiary, #242424);
|
||||
color: var(--sora-text-primary, #FFF);
|
||||
}
|
||||
|
||||
.sora-gallery-load-more-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 1200px) {
|
||||
.sora-gallery-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.sora-gallery-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.sora-gallery-page {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.sora-gallery-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
282
frontend/src/components/sora/SoraMediaPreview.vue
Normal file
282
frontend/src/components/sora/SoraMediaPreview.vue
Normal file
@@ -0,0 +1,282 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="sora-modal">
|
||||
<div
|
||||
v-if="visible && generation"
|
||||
class="sora-preview-overlay"
|
||||
@keydown.esc="emit('close')"
|
||||
>
|
||||
<!-- 背景遮罩 -->
|
||||
<div class="sora-preview-backdrop" @click="emit('close')" />
|
||||
|
||||
<!-- 内容区 -->
|
||||
<div class="sora-preview-modal">
|
||||
<!-- 顶部栏 -->
|
||||
<div class="sora-preview-header">
|
||||
<h3 class="sora-preview-title">{{ t('sora.previewTitle') }}</h3>
|
||||
<button class="sora-preview-close" @click="emit('close')">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 媒体区 -->
|
||||
<div class="sora-preview-media-area">
|
||||
<video
|
||||
v-if="generation.media_type === 'video'"
|
||||
:src="generation.media_url"
|
||||
class="sora-preview-media"
|
||||
controls
|
||||
autoplay
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
:src="generation.media_url"
|
||||
class="sora-preview-media"
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 详情 + 操作 -->
|
||||
<div class="sora-preview-footer">
|
||||
<!-- 模型 + 时间 -->
|
||||
<div class="sora-preview-meta">
|
||||
<span class="sora-preview-model-tag">{{ generation.model }}</span>
|
||||
<span>{{ formatDateTime(generation.created_at) }}</span>
|
||||
</div>
|
||||
<!-- 提示词 -->
|
||||
<p class="sora-preview-prompt">{{ generation.prompt }}</p>
|
||||
<!-- 操作按钮 -->
|
||||
<div class="sora-preview-actions">
|
||||
<button
|
||||
v-if="generation.storage_type === 'upstream'"
|
||||
class="sora-preview-btn primary"
|
||||
@click="emit('save', generation.id)"
|
||||
>
|
||||
☁️ {{ t('sora.save') }}
|
||||
</button>
|
||||
<a
|
||||
v-if="generation.media_url"
|
||||
:href="generation.media_url"
|
||||
target="_blank"
|
||||
download
|
||||
class="sora-preview-btn secondary"
|
||||
@click="emit('download', generation.media_url)"
|
||||
>
|
||||
📥 {{ t('sora.download') }}
|
||||
</a>
|
||||
<button class="sora-preview-btn ghost" @click="emit('close')">
|
||||
{{ t('sora.closePreview') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { SoraGeneration } from '@/api/sora'
|
||||
|
||||
defineProps<{
|
||||
visible: boolean
|
||||
generation: SoraGeneration | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
save: [id: number]
|
||||
download: [url: string]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
function formatDateTime(iso: string): string {
|
||||
return new Date(iso).toLocaleString()
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') emit('close')
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('keydown', handleKeydown))
|
||||
onUnmounted(() => document.removeEventListener('keydown', handleKeydown))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sora-preview-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.sora-preview-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--sora-modal-backdrop, rgba(0, 0, 0, 0.4));
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.sora-preview-modal {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 90vh;
|
||||
max-width: 90vw;
|
||||
overflow: hidden;
|
||||
border-radius: 20px;
|
||||
background: var(--sora-bg-secondary, #FFF);
|
||||
border: 1px solid var(--sora-border-color, #E5E7EB);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
animation: sora-modal-in 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes sora-modal-in {
|
||||
from { transform: scale(0.95); opacity: 0; }
|
||||
to { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
.sora-preview-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--sora-border-color, #E5E7EB);
|
||||
}
|
||||
|
||||
.sora-preview-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--sora-text-primary, #111827);
|
||||
}
|
||||
|
||||
.sora-preview-close {
|
||||
padding: 6px;
|
||||
border-radius: 8px;
|
||||
color: var(--sora-text-tertiary, #9CA3AF);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.sora-preview-close:hover {
|
||||
background: var(--sora-bg-tertiary, #F3F4F6);
|
||||
color: var(--sora-text-secondary, #6B7280);
|
||||
}
|
||||
|
||||
.sora-preview-media-area {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
background: var(--sora-bg-primary, #F9FAFB);
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.sora-preview-media {
|
||||
max-height: 70vh;
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.sora-preview-footer {
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid var(--sora-border-color, #E5E7EB);
|
||||
}
|
||||
|
||||
.sora-preview-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--sora-text-tertiary, #9CA3AF);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.sora-preview-model-tag {
|
||||
padding: 2px 8px;
|
||||
background: var(--sora-bg-tertiary, #F3F4F6);
|
||||
border-radius: 9999px;
|
||||
font-family: "SF Mono", "Fira Code", monospace;
|
||||
font-size: 11px;
|
||||
color: var(--sora-text-secondary, #6B7280);
|
||||
}
|
||||
|
||||
.sora-preview-prompt {
|
||||
font-size: 13px;
|
||||
color: var(--sora-text-secondary, #6B7280);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 16px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sora-preview-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sora-preview-btn {
|
||||
padding: 8px 16px;
|
||||
border-radius: 9999px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.sora-preview-btn.primary {
|
||||
background: var(--sora-accent-gradient);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.sora-preview-btn.primary:hover {
|
||||
box-shadow: var(--sora-shadow-glow);
|
||||
}
|
||||
|
||||
.sora-preview-btn.secondary {
|
||||
background: var(--sora-bg-tertiary, #F3F4F6);
|
||||
color: var(--sora-text-secondary, #6B7280);
|
||||
}
|
||||
|
||||
.sora-preview-btn.secondary:hover {
|
||||
background: var(--sora-bg-hover, #E5E7EB);
|
||||
color: var(--sora-text-primary, #111827);
|
||||
}
|
||||
|
||||
.sora-preview-btn.ghost {
|
||||
background: transparent;
|
||||
color: var(--sora-text-tertiary, #9CA3AF);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.sora-preview-btn.ghost:hover {
|
||||
color: var(--sora-text-secondary, #6B7280);
|
||||
}
|
||||
|
||||
/* 过渡动画 */
|
||||
.sora-modal-enter-active,
|
||||
.sora-modal-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.sora-modal-enter-from,
|
||||
.sora-modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
39
frontend/src/components/sora/SoraNoStorageWarning.vue
Normal file
39
frontend/src/components/sora/SoraNoStorageWarning.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<div class="sora-no-storage-warning">
|
||||
<span>⚠️</span>
|
||||
<div>
|
||||
<p class="sora-no-storage-title">{{ t('sora.noStorageWarningTitle') }}</p>
|
||||
<p class="sora-no-storage-desc">{{ t('sora.noStorageWarningDesc') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sora-no-storage-warning {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 14px 20px;
|
||||
background: rgba(245, 158, 11, 0.08);
|
||||
border: 1px solid rgba(245, 158, 11, 0.2);
|
||||
border-radius: 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.sora-no-storage-title {
|
||||
font-weight: 600;
|
||||
color: var(--sora-warning, #F59E0B);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.sora-no-storage-desc {
|
||||
color: var(--sora-text-secondary, #A0A0A0);
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
609
frontend/src/components/sora/SoraProgressCard.vue
Normal file
609
frontend/src/components/sora/SoraProgressCard.vue
Normal file
@@ -0,0 +1,609 @@
|
||||
<template>
|
||||
<div
|
||||
class="sora-task-card"
|
||||
:class="{
|
||||
cancelled: generation.status === 'cancelled',
|
||||
'countdown-warning': isUpstream && !isExpired && remainingMs <= 2 * 60 * 1000
|
||||
}"
|
||||
>
|
||||
<!-- 头部:状态 + 模型 + 取消按钮 -->
|
||||
<div class="sora-task-header">
|
||||
<div class="sora-task-status">
|
||||
<span class="sora-status-dot" :class="statusDotClass" />
|
||||
<span class="sora-status-label" :class="statusLabelClass">{{ statusText }}</span>
|
||||
</div>
|
||||
<div class="sora-task-header-right">
|
||||
<span class="sora-model-tag">{{ generation.model }}</span>
|
||||
<button
|
||||
v-if="generation.status === 'pending' || generation.status === 'generating'"
|
||||
class="sora-cancel-btn"
|
||||
@click="emit('cancel', generation.id)"
|
||||
>
|
||||
✕ {{ t('sora.cancel') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 提示词 -->
|
||||
<div class="sora-task-prompt" :class="{ 'line-through': generation.status === 'cancelled' }">
|
||||
{{ generation.prompt }}
|
||||
</div>
|
||||
|
||||
<!-- 错误分类(失败时) -->
|
||||
<div v-if="generation.status === 'failed' && generation.error_message" class="sora-task-error-category">
|
||||
⛔ {{ t('sora.errorCategory') }}
|
||||
</div>
|
||||
<div v-if="generation.status === 'failed' && generation.error_message" class="sora-task-error-message">
|
||||
{{ generation.error_message }}
|
||||
</div>
|
||||
|
||||
<!-- 进度条(排队/生成/失败时) -->
|
||||
<div v-if="showProgress" class="sora-task-progress-wrapper">
|
||||
<div class="sora-task-progress-bar">
|
||||
<div
|
||||
class="sora-task-progress-fill"
|
||||
:class="progressFillClass"
|
||||
:style="{ width: progressWidth }"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="generation.status !== 'failed'" class="sora-task-progress-info">
|
||||
<span>{{ progressInfoText }}</span>
|
||||
<span>{{ progressInfoRight }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 完成预览区 -->
|
||||
<div v-if="generation.status === 'completed' && generation.media_url" class="sora-task-preview">
|
||||
<video
|
||||
v-if="generation.media_type === 'video'"
|
||||
:src="generation.media_url"
|
||||
class="sora-task-preview-media"
|
||||
muted
|
||||
loop
|
||||
@mouseenter="($event.target as HTMLVideoElement).play()"
|
||||
@mouseleave="($event.target as HTMLVideoElement).pause()"
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
:src="generation.media_url"
|
||||
class="sora-task-preview-media"
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 完成占位预览(无 media_url 时) -->
|
||||
<div v-else-if="generation.status === 'completed' && !generation.media_url" class="sora-task-preview">
|
||||
<div class="sora-task-preview-placeholder">🎨</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div v-if="showActions" class="sora-task-actions">
|
||||
<!-- 已完成 -->
|
||||
<template v-if="generation.status === 'completed'">
|
||||
<!-- 已保存标签 -->
|
||||
<span v-if="generation.storage_type !== 'upstream'" class="sora-saved-badge">
|
||||
✓ {{ t('sora.savedToCloud') }}
|
||||
</span>
|
||||
<!-- 保存到存储按钮(upstream 时) -->
|
||||
<button
|
||||
v-if="generation.storage_type === 'upstream'"
|
||||
class="sora-action-btn save-storage"
|
||||
@click="emit('save', generation.id)"
|
||||
>
|
||||
☁️ {{ t('sora.save') }}
|
||||
</button>
|
||||
<!-- 本地下载 -->
|
||||
<a
|
||||
v-if="generation.media_url"
|
||||
:href="generation.media_url"
|
||||
target="_blank"
|
||||
download
|
||||
class="sora-action-btn primary"
|
||||
>
|
||||
📥 {{ t('sora.downloadLocal') }}
|
||||
</a>
|
||||
<!-- 倒计时文本(upstream) -->
|
||||
<span v-if="isUpstream && !isExpired" class="sora-countdown-text">
|
||||
⏱ {{ t('sora.upstreamCountdown', { time: countdownText }) }} {{ t('sora.canDownload') }}
|
||||
</span>
|
||||
<span v-if="isUpstream && isExpired" class="sora-countdown-text expired">
|
||||
{{ t('sora.upstreamExpired') }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!-- 失败/取消 -->
|
||||
<template v-if="generation.status === 'failed' || generation.status === 'cancelled'">
|
||||
<button class="sora-action-btn primary" @click="emit('retry', generation)">
|
||||
🔄 {{ generation.status === 'cancelled' ? t('sora.regenrate') : t('sora.retry') }}
|
||||
</button>
|
||||
<button class="sora-action-btn secondary" @click="emit('delete', generation.id)">
|
||||
🗑 {{ t('sora.delete') }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 倒计时进度条(upstream 已完成) -->
|
||||
<div v-if="isUpstream && !isExpired && generation.status === 'completed'" class="sora-countdown-bar-wrapper">
|
||||
<div class="sora-countdown-bar">
|
||||
<div class="sora-countdown-bar-fill" :style="{ width: countdownPercent + '%' }" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { SoraGeneration } from '@/api/sora'
|
||||
|
||||
const props = defineProps<{ generation: SoraGeneration }>()
|
||||
const emit = defineEmits<{
|
||||
cancel: [id: number]
|
||||
delete: [id: number]
|
||||
save: [id: number]
|
||||
retry: [gen: SoraGeneration]
|
||||
}>()
|
||||
const { t } = useI18n()
|
||||
|
||||
// ==================== 状态样式 ====================
|
||||
|
||||
const statusDotClass = computed(() => {
|
||||
const s = props.generation.status
|
||||
return {
|
||||
queued: s === 'pending',
|
||||
generating: s === 'generating',
|
||||
completed: s === 'completed',
|
||||
failed: s === 'failed',
|
||||
cancelled: s === 'cancelled'
|
||||
}
|
||||
})
|
||||
|
||||
const statusLabelClass = computed(() => statusDotClass.value)
|
||||
|
||||
const statusText = computed(() => {
|
||||
const map: Record<string, string> = {
|
||||
pending: t('sora.statusPending'),
|
||||
generating: t('sora.statusGenerating'),
|
||||
completed: t('sora.statusCompleted'),
|
||||
failed: t('sora.statusFailed'),
|
||||
cancelled: t('sora.statusCancelled')
|
||||
}
|
||||
return map[props.generation.status] || props.generation.status
|
||||
})
|
||||
|
||||
// ==================== 进度条 ====================
|
||||
|
||||
const showProgress = computed(() => {
|
||||
const s = props.generation.status
|
||||
return s === 'pending' || s === 'generating' || s === 'failed'
|
||||
})
|
||||
|
||||
const progressFillClass = computed(() => {
|
||||
const s = props.generation.status
|
||||
return {
|
||||
generating: s === 'pending' || s === 'generating',
|
||||
completed: s === 'completed',
|
||||
failed: s === 'failed'
|
||||
}
|
||||
})
|
||||
|
||||
const progressWidth = computed(() => {
|
||||
const s = props.generation.status
|
||||
if (s === 'failed') return '100%'
|
||||
if (s === 'pending') return '0%'
|
||||
if (s === 'generating') {
|
||||
// 根据创建时间估算进度
|
||||
const created = new Date(props.generation.created_at).getTime()
|
||||
const elapsed = Date.now() - created
|
||||
// 假设平均 10 分钟完成,最多到 95%
|
||||
const progress = Math.min(95, (elapsed / (10 * 60 * 1000)) * 100)
|
||||
return `${Math.round(progress)}%`
|
||||
}
|
||||
return '100%'
|
||||
})
|
||||
|
||||
const progressInfoText = computed(() => {
|
||||
const s = props.generation.status
|
||||
if (s === 'pending') return t('sora.queueWaiting')
|
||||
if (s === 'generating') {
|
||||
const created = new Date(props.generation.created_at).getTime()
|
||||
const elapsed = Date.now() - created
|
||||
return `${t('sora.waited')} ${formatElapsed(elapsed)}`
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const progressInfoRight = computed(() => {
|
||||
const s = props.generation.status
|
||||
if (s === 'pending') return t('sora.waiting')
|
||||
return ''
|
||||
})
|
||||
|
||||
function formatElapsed(ms: number): string {
|
||||
const s = Math.floor(ms / 1000)
|
||||
const m = Math.floor(s / 60)
|
||||
const sec = s % 60
|
||||
return `${m}:${sec.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// ==================== 操作按钮 ====================
|
||||
|
||||
const showActions = computed(() => {
|
||||
const s = props.generation.status
|
||||
return s === 'completed' || s === 'failed' || s === 'cancelled'
|
||||
})
|
||||
|
||||
// ==================== Upstream 倒计时 ====================
|
||||
|
||||
const UPSTREAM_TTL = 15 * 60 * 1000
|
||||
const now = ref(Date.now())
|
||||
let countdownTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const isUpstream = computed(() =>
|
||||
props.generation.status === 'completed' && props.generation.storage_type === 'upstream'
|
||||
)
|
||||
|
||||
const expireTime = computed(() => {
|
||||
if (!props.generation.completed_at) return 0
|
||||
return new Date(props.generation.completed_at).getTime() + UPSTREAM_TTL
|
||||
})
|
||||
|
||||
const remainingMs = computed(() => Math.max(0, expireTime.value - now.value))
|
||||
const isExpired = computed(() => remainingMs.value <= 0)
|
||||
const countdownPercent = computed(() => {
|
||||
if (isExpired.value) return 0
|
||||
return Math.round((remainingMs.value / UPSTREAM_TTL) * 100)
|
||||
})
|
||||
|
||||
const countdownText = computed(() => {
|
||||
const totalSec = Math.ceil(remainingMs.value / 1000)
|
||||
const m = Math.floor(totalSec / 60)
|
||||
const s = totalSec % 60
|
||||
return `${m}:${s.toString().padStart(2, '0')}`
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (isUpstream.value) {
|
||||
countdownTimer = setInterval(() => {
|
||||
now.value = Date.now()
|
||||
if (now.value >= expireTime.value && countdownTimer) {
|
||||
clearInterval(countdownTimer)
|
||||
countdownTimer = null
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (countdownTimer) {
|
||||
clearInterval(countdownTimer)
|
||||
countdownTimer = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sora-task-card {
|
||||
background: var(--sora-bg-secondary, #1A1A1A);
|
||||
border: 1px solid var(--sora-border-color, #2A2A2A);
|
||||
border-radius: var(--sora-radius-lg, 16px);
|
||||
padding: 24px;
|
||||
transition: all 250ms ease;
|
||||
animation: sora-fade-in 0.4s ease;
|
||||
}
|
||||
|
||||
.sora-task-card:hover {
|
||||
border-color: var(--sora-bg-hover, #333);
|
||||
}
|
||||
|
||||
.sora-task-card.cancelled {
|
||||
opacity: 0.6;
|
||||
border-color: var(--sora-border-subtle, #1F1F1F);
|
||||
}
|
||||
|
||||
.sora-task-card.countdown-warning {
|
||||
border-color: var(--sora-error, #EF4444) !important;
|
||||
box-shadow: 0 0 12px rgba(239, 68, 68, 0.15);
|
||||
}
|
||||
|
||||
@keyframes sora-fade-in {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* 头部 */
|
||||
.sora-task-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.sora-task-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sora-task-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 状态指示点 */
|
||||
.sora-status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.sora-status-dot.queued { background: var(--sora-text-tertiary, #666); }
|
||||
.sora-status-dot.generating {
|
||||
background: var(--sora-warning, #F59E0B);
|
||||
animation: sora-pulse-dot 1.5s ease-in-out infinite;
|
||||
}
|
||||
.sora-status-dot.completed { background: var(--sora-success, #10B981); }
|
||||
.sora-status-dot.failed { background: var(--sora-error, #EF4444); }
|
||||
.sora-status-dot.cancelled { background: var(--sora-text-tertiary, #666); }
|
||||
|
||||
@keyframes sora-pulse-dot {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
/* 状态标签 */
|
||||
.sora-status-label.queued { color: var(--sora-text-secondary, #A0A0A0); }
|
||||
.sora-status-label.generating { color: var(--sora-warning, #F59E0B); }
|
||||
.sora-status-label.completed { color: var(--sora-success, #10B981); }
|
||||
.sora-status-label.failed { color: var(--sora-error, #EF4444); }
|
||||
.sora-status-label.cancelled { color: var(--sora-text-tertiary, #666); }
|
||||
|
||||
/* 模型标签 */
|
||||
.sora-model-tag {
|
||||
font-size: 11px;
|
||||
padding: 3px 10px;
|
||||
background: var(--sora-bg-tertiary, #242424);
|
||||
border-radius: var(--sora-radius-full, 9999px);
|
||||
color: var(--sora-text-secondary, #A0A0A0);
|
||||
font-family: "SF Mono", "Fira Code", "Cascadia Code", monospace;
|
||||
}
|
||||
|
||||
/* 取消按钮 */
|
||||
.sora-cancel-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 12px;
|
||||
border-radius: var(--sora-radius-full, 9999px);
|
||||
font-size: 12px;
|
||||
color: var(--sora-text-secondary, #A0A0A0);
|
||||
background: var(--sora-bg-tertiary, #242424);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.sora-cancel-btn:hover {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: var(--sora-error, #EF4444);
|
||||
}
|
||||
|
||||
/* 提示词 */
|
||||
.sora-task-prompt {
|
||||
font-size: 14px;
|
||||
color: var(--sora-text-secondary, #A0A0A0);
|
||||
margin-bottom: 16px;
|
||||
line-height: 1.6;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sora-task-prompt.line-through {
|
||||
text-decoration: line-through;
|
||||
color: var(--sora-text-tertiary, #666);
|
||||
}
|
||||
|
||||
/* 错误分类 */
|
||||
.sora-task-error-category {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border-radius: var(--sora-radius-sm, 8px);
|
||||
font-size: 12px;
|
||||
color: var(--sora-error, #EF4444);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.sora-task-error-message {
|
||||
font-size: 13px;
|
||||
color: var(--sora-text-secondary, #A0A0A0);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* 进度条 */
|
||||
.sora-task-progress-wrapper {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.sora-task-progress-bar {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: var(--sora-bg-hover, #333);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sora-task-progress-fill {
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
transition: width 400ms ease;
|
||||
}
|
||||
|
||||
.sora-task-progress-fill.generating {
|
||||
background: var(--sora-accent-gradient, linear-gradient(135deg, #14b8a6, #0d9488));
|
||||
animation: sora-progress-shimmer 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.sora-task-progress-fill.completed {
|
||||
background: var(--sora-success, #10B981);
|
||||
}
|
||||
|
||||
.sora-task-progress-fill.failed {
|
||||
background: var(--sora-error, #EF4444);
|
||||
}
|
||||
|
||||
@keyframes sora-progress-shimmer {
|
||||
0% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
.sora-task-progress-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--sora-text-tertiary, #666);
|
||||
}
|
||||
|
||||
/* 预览 */
|
||||
.sora-task-preview {
|
||||
margin-top: 16px;
|
||||
border-radius: var(--sora-radius-md, 12px);
|
||||
overflow: hidden;
|
||||
background: var(--sora-bg-tertiary, #242424);
|
||||
}
|
||||
|
||||
.sora-task-preview-media {
|
||||
width: 100%;
|
||||
height: 280px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.sora-task-preview-placeholder {
|
||||
width: 100%;
|
||||
height: 280px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--sora-placeholder-gradient, linear-gradient(135deg, #e0e7ff 0%, #dbeafe 50%, #cffafe 100%));
|
||||
font-size: 48px;
|
||||
}
|
||||
|
||||
/* 操作按钮 */
|
||||
.sora-task-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sora-action-btn {
|
||||
padding: 8px 20px;
|
||||
border-radius: var(--sora-radius-full, 9999px);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.sora-action-btn.primary {
|
||||
background: var(--sora-accent-gradient, linear-gradient(135deg, #14b8a6, #0d9488));
|
||||
color: white;
|
||||
}
|
||||
|
||||
.sora-action-btn.primary:hover {
|
||||
background: var(--sora-accent-gradient-hover, linear-gradient(135deg, #2dd4bf, #14b8a6));
|
||||
box-shadow: var(--sora-shadow-glow, 0 0 20px rgba(20,184,166,0.3));
|
||||
}
|
||||
|
||||
.sora-action-btn.secondary {
|
||||
background: var(--sora-bg-tertiary, #242424);
|
||||
color: var(--sora-text-secondary, #A0A0A0);
|
||||
}
|
||||
|
||||
.sora-action-btn.secondary:hover {
|
||||
background: var(--sora-bg-hover, #333);
|
||||
color: var(--sora-text-primary, #FFF);
|
||||
}
|
||||
|
||||
.sora-action-btn.save-storage {
|
||||
background: linear-gradient(135deg, #10B981 0%, #059669 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.sora-action-btn.save-storage:hover {
|
||||
box-shadow: 0 0 16px rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
/* 已保存标签 */
|
||||
.sora-saved-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 14px;
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
border: 1px solid rgba(16, 185, 129, 0.25);
|
||||
border-radius: var(--sora-radius-full, 9999px);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--sora-success, #10B981);
|
||||
}
|
||||
|
||||
/* 倒计时文本 */
|
||||
.sora-countdown-text {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--sora-warning, #F59E0B);
|
||||
}
|
||||
|
||||
.sora-countdown-text.expired {
|
||||
color: var(--sora-error, #EF4444);
|
||||
}
|
||||
|
||||
/* 倒计时进度条 */
|
||||
.sora-countdown-bar-wrapper {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.sora-countdown-bar {
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
background: var(--sora-bg-hover, #333);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sora-countdown-bar-fill {
|
||||
height: 100%;
|
||||
background: var(--sora-warning, #F59E0B);
|
||||
border-radius: 2px;
|
||||
transition: width 1s linear;
|
||||
}
|
||||
|
||||
.countdown-warning .sora-countdown-bar-fill {
|
||||
background: var(--sora-error, #EF4444);
|
||||
}
|
||||
|
||||
.countdown-warning .sora-countdown-text {
|
||||
color: var(--sora-error, #EF4444);
|
||||
}
|
||||
</style>
|
||||
738
frontend/src/components/sora/SoraPromptBar.vue
Normal file
738
frontend/src/components/sora/SoraPromptBar.vue
Normal file
@@ -0,0 +1,738 @@
|
||||
<template>
|
||||
<div class="sora-creator-bar-wrapper">
|
||||
<div class="sora-creator-bar">
|
||||
<div class="sora-creator-bar-inner" :class="{ focused: isFocused }">
|
||||
<!-- 模型选择行 -->
|
||||
<div class="sora-creator-model-row">
|
||||
<div class="sora-model-select-wrapper">
|
||||
<select
|
||||
v-model="selectedFamily"
|
||||
class="sora-model-select"
|
||||
@change="onFamilyChange"
|
||||
>
|
||||
<optgroup v-if="videoFamilies.length" :label="t('sora.videoModels')">
|
||||
<option v-for="f in videoFamilies" :key="f.id" :value="f.id">{{ f.name }}</option>
|
||||
</optgroup>
|
||||
<optgroup v-if="imageFamilies.length" :label="t('sora.imageModels')">
|
||||
<option v-for="f in imageFamilies" :key="f.id" :value="f.id">{{ f.name }}</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
<span class="sora-model-select-arrow">▼</span>
|
||||
</div>
|
||||
<!-- 凭证选择器 -->
|
||||
<div class="sora-credential-select-wrapper">
|
||||
<select v-model="selectedCredentialId" class="sora-model-select">
|
||||
<option :value="0" disabled>{{ t('sora.selectCredential') }}</option>
|
||||
<optgroup v-if="apiKeyOptions.length" :label="t('sora.apiKeys')">
|
||||
<option v-for="k in apiKeyOptions" :key="'k'+k.id" :value="k.id">
|
||||
{{ k.name }}{{ k.group ? ' · ' + k.group.name : '' }}
|
||||
</option>
|
||||
</optgroup>
|
||||
<optgroup v-if="subscriptionOptions.length" :label="t('sora.subscriptions')">
|
||||
<option v-for="s in subscriptionOptions" :key="'s'+s.id" :value="-s.id">
|
||||
{{ s.group?.name || t('sora.subscription') }}
|
||||
</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
<span class="sora-model-select-arrow">▼</span>
|
||||
</div>
|
||||
<!-- 无凭证提示 -->
|
||||
<span v-if="soraCredentialEmpty" class="sora-no-storage-badge">
|
||||
⚠ {{ t('sora.noCredentialHint') }}
|
||||
</span>
|
||||
<!-- 无存储提示 -->
|
||||
<span v-if="!hasStorage" class="sora-no-storage-badge">
|
||||
⚠ {{ t('sora.noStorageConfigured') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 参考图预览 -->
|
||||
<div v-if="imagePreview" class="sora-image-preview-row">
|
||||
<div class="sora-image-preview-thumb">
|
||||
<img :src="imagePreview" alt="" />
|
||||
<button class="sora-image-preview-remove" @click="removeImage">✕</button>
|
||||
</div>
|
||||
<span class="sora-image-preview-label">{{ t('sora.referenceImage') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 输入框 -->
|
||||
<div class="sora-creator-input-wrapper">
|
||||
<textarea
|
||||
ref="textareaRef"
|
||||
v-model="prompt"
|
||||
class="sora-creator-textarea"
|
||||
:placeholder="t('sora.creatorPlaceholder')"
|
||||
rows="1"
|
||||
@input="autoResize"
|
||||
@focus="isFocused = true"
|
||||
@blur="isFocused = false"
|
||||
@keydown.enter.ctrl="submit"
|
||||
@keydown.enter.meta="submit"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 底部工具行 -->
|
||||
<div class="sora-creator-tools-row">
|
||||
<div class="sora-creator-tools-left">
|
||||
<!-- 方向选择(根据所选模型家族支持的方向动态渲染) -->
|
||||
<template v-if="availableAspects.length > 0">
|
||||
<button
|
||||
v-for="a in availableAspects"
|
||||
:key="a.value"
|
||||
class="sora-tool-btn"
|
||||
:class="{ active: currentAspect === a.value }"
|
||||
@click="currentAspect = a.value"
|
||||
>
|
||||
<span class="sora-tool-btn-icon">{{ a.icon }}</span> {{ a.label }}
|
||||
</button>
|
||||
|
||||
<span v-if="availableDurations.length > 0" class="sora-tool-divider" />
|
||||
</template>
|
||||
|
||||
<!-- 时长选择(根据所选模型家族支持的时长动态渲染) -->
|
||||
<template v-if="availableDurations.length > 0">
|
||||
<button
|
||||
v-for="d in availableDurations"
|
||||
:key="d"
|
||||
class="sora-tool-btn"
|
||||
:class="{ active: currentDuration === d }"
|
||||
@click="currentDuration = d"
|
||||
>
|
||||
{{ d }}s
|
||||
</button>
|
||||
|
||||
<span class="sora-tool-divider" />
|
||||
</template>
|
||||
|
||||
<!-- 视频数量(官方 Videos 1/2/3) -->
|
||||
<template v-if="availableVideoCounts.length > 0">
|
||||
<button
|
||||
v-for="count in availableVideoCounts"
|
||||
:key="count"
|
||||
class="sora-tool-btn"
|
||||
:class="{ active: currentVideoCount === count }"
|
||||
@click="currentVideoCount = count"
|
||||
>
|
||||
{{ count }}
|
||||
</button>
|
||||
|
||||
<span class="sora-tool-divider" />
|
||||
</template>
|
||||
|
||||
<!-- 图片上传 -->
|
||||
<button class="sora-upload-btn" :title="t('sora.uploadReference')" @click="triggerFileInput">
|
||||
📎
|
||||
</button>
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
style="display: none"
|
||||
@change="onFileChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 活跃任务计数 -->
|
||||
<span v-if="activeTaskCount > 0" class="sora-active-tasks-label">
|
||||
<span class="sora-pulse-indicator" />
|
||||
<span>{{ t('sora.generatingCount', { current: activeTaskCount, max: maxConcurrentTasks }) }}</span>
|
||||
</span>
|
||||
|
||||
<!-- 生成按钮 -->
|
||||
<button
|
||||
class="sora-generate-btn"
|
||||
:class="{ 'max-reached': isMaxReached }"
|
||||
:disabled="!canSubmit || generating || isMaxReached"
|
||||
@click="submit"
|
||||
>
|
||||
<span class="sora-generate-btn-icon">✨</span>
|
||||
<span>{{ generating ? t('sora.generating') : t('sora.generate') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文件大小错误 -->
|
||||
<p v-if="imageError" class="sora-image-error">{{ imageError }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import soraAPI, { type SoraModelFamily, type GenerateRequest } from '@/api/sora'
|
||||
import keysAPI from '@/api/keys'
|
||||
import { useSubscriptionStore } from '@/stores/subscriptions'
|
||||
import type { ApiKey, UserSubscription } from '@/types'
|
||||
|
||||
const MAX_IMAGE_SIZE = 20 * 1024 * 1024
|
||||
|
||||
/** 方向显示配置 */
|
||||
const ASPECT_META: Record<string, { icon: string; label: string }> = {
|
||||
landscape: { icon: '▬', label: '横屏' },
|
||||
portrait: { icon: '▮', label: '竖屏' },
|
||||
square: { icon: '◻', label: '方形' }
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
generating: boolean
|
||||
activeTaskCount: number
|
||||
maxConcurrentTasks: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
generate: [req: GenerateRequest]
|
||||
fillPrompt: [prompt: string]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const prompt = ref('')
|
||||
const families = ref<SoraModelFamily[]>([])
|
||||
const selectedFamily = ref('')
|
||||
const currentAspect = ref('landscape')
|
||||
const currentDuration = ref(10)
|
||||
const currentVideoCount = ref(1)
|
||||
const isFocused = ref(false)
|
||||
const imagePreview = ref<string | null>(null)
|
||||
const imageError = ref('')
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
const textareaRef = ref<HTMLTextAreaElement | null>(null)
|
||||
const hasStorage = ref(true)
|
||||
|
||||
// 凭证相关状态
|
||||
const apiKeyOptions = ref<ApiKey[]>([])
|
||||
const subscriptionOptions = ref<UserSubscription[]>([])
|
||||
const selectedCredentialId = ref<number>(0) // >0 = api_key.id, <0 = -subscription.id
|
||||
|
||||
const soraCredentialEmpty = computed(() =>
|
||||
apiKeyOptions.value.length === 0 && subscriptionOptions.value.length === 0
|
||||
)
|
||||
|
||||
// 按类型分组
|
||||
const videoFamilies = computed(() => families.value.filter(f => f.type === 'video'))
|
||||
const imageFamilies = computed(() => families.value.filter(f => f.type === 'image'))
|
||||
|
||||
// 当前选中的家族对象
|
||||
const currentFamily = computed(() => families.value.find(f => f.id === selectedFamily.value))
|
||||
|
||||
// 当前家族支持的方向列表
|
||||
const availableAspects = computed(() => {
|
||||
const fam = currentFamily.value
|
||||
if (!fam?.orientations?.length) return []
|
||||
return fam.orientations
|
||||
.map(o => ({ value: o, ...(ASPECT_META[o] || { icon: '?', label: o }) }))
|
||||
})
|
||||
|
||||
// 当前家族支持的时长列表
|
||||
const availableDurations = computed(() => currentFamily.value?.durations ?? [])
|
||||
const availableVideoCounts = computed(() => (currentFamily.value?.type === 'video' ? [1, 2, 3] : []))
|
||||
|
||||
const isMaxReached = computed(() => props.activeTaskCount >= props.maxConcurrentTasks)
|
||||
const canSubmit = computed(() =>
|
||||
prompt.value.trim().length > 0 && selectedFamily.value && selectedCredentialId.value !== 0
|
||||
)
|
||||
|
||||
/** 构建最终 model ID(family + orientation + duration) */
|
||||
function buildModelID(): string {
|
||||
const fam = currentFamily.value
|
||||
if (!fam) return selectedFamily.value
|
||||
|
||||
if (fam.type === 'image') {
|
||||
// 图像模型: "gpt-image"(方形)或 "gpt-image-landscape"
|
||||
return currentAspect.value === 'square'
|
||||
? fam.id
|
||||
: `${fam.id}-${currentAspect.value}`
|
||||
}
|
||||
// 视频模型: "sora2-landscape-10s"
|
||||
return `${fam.id}-${currentAspect.value}-${currentDuration.value}s`
|
||||
}
|
||||
|
||||
/** 切换家族时自动调整方向和时长为首个可用值 */
|
||||
function onFamilyChange() {
|
||||
const fam = families.value.find(f => f.id === selectedFamily.value)
|
||||
if (!fam) return
|
||||
// 若当前方向不在新家族支持列表中,重置为首个
|
||||
if (fam.orientations?.length && !fam.orientations.includes(currentAspect.value)) {
|
||||
currentAspect.value = fam.orientations[0]
|
||||
}
|
||||
// 若当前时长不在新家族支持列表中,重置为首个
|
||||
if (fam.durations?.length && !fam.durations.includes(currentDuration.value)) {
|
||||
currentDuration.value = fam.durations[0]
|
||||
}
|
||||
if (fam.type !== 'video') {
|
||||
currentVideoCount.value = 1
|
||||
}
|
||||
}
|
||||
|
||||
async function loadModels() {
|
||||
try {
|
||||
families.value = await soraAPI.getModels()
|
||||
if (families.value.length > 0 && !selectedFamily.value) {
|
||||
selectedFamily.value = families.value[0].id
|
||||
onFamilyChange()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load models:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStorageStatus() {
|
||||
try {
|
||||
const status = await soraAPI.getStorageStatus()
|
||||
hasStorage.value = status.s3_enabled && status.s3_healthy
|
||||
} catch {
|
||||
hasStorage.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSoraCredentials() {
|
||||
try {
|
||||
// 加载 API Keys,筛选 sora 平台 + active 状态
|
||||
const keysRes = await keysAPI.list(1, 100)
|
||||
apiKeyOptions.value = (keysRes.items || []).filter(
|
||||
(k: ApiKey) => k.status === 'active' && k.group?.platform === 'sora'
|
||||
)
|
||||
// 加载活跃订阅,筛选 sora 平台
|
||||
const subStore = useSubscriptionStore()
|
||||
const subs = await subStore.fetchActiveSubscriptions()
|
||||
subscriptionOptions.value = subs.filter(
|
||||
(s: UserSubscription) => s.status === 'active' && s.group?.platform === 'sora'
|
||||
)
|
||||
// 自动选择第一个
|
||||
if (apiKeyOptions.value.length > 0) {
|
||||
selectedCredentialId.value = apiKeyOptions.value[0].id
|
||||
} else if (subscriptionOptions.value.length > 0) {
|
||||
selectedCredentialId.value = -subscriptionOptions.value[0].id
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load sora credentials:', e)
|
||||
}
|
||||
}
|
||||
|
||||
function autoResize() {
|
||||
const el = textareaRef.value
|
||||
if (!el) return
|
||||
el.style.height = 'auto'
|
||||
el.style.height = Math.min(el.scrollHeight, 120) + 'px'
|
||||
}
|
||||
|
||||
function triggerFileInput() {
|
||||
fileInputRef.value?.click()
|
||||
}
|
||||
|
||||
function onFileChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
imageError.value = ''
|
||||
if (file.size > MAX_IMAGE_SIZE) {
|
||||
imageError.value = t('sora.imageTooLarge')
|
||||
input.value = ''
|
||||
return
|
||||
}
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
imagePreview.value = e.target?.result as string
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
function removeImage() {
|
||||
imagePreview.value = null
|
||||
imageError.value = ''
|
||||
}
|
||||
|
||||
function submit() {
|
||||
if (!canSubmit.value || props.generating || isMaxReached.value) return
|
||||
const modelID = buildModelID()
|
||||
const req: GenerateRequest = {
|
||||
model: modelID,
|
||||
prompt: prompt.value.trim(),
|
||||
media_type: currentFamily.value?.type || 'video'
|
||||
}
|
||||
if ((currentFamily.value?.type || 'video') === 'video') {
|
||||
req.video_count = currentVideoCount.value
|
||||
}
|
||||
if (imagePreview.value) {
|
||||
req.image_input = imagePreview.value
|
||||
}
|
||||
if (selectedCredentialId.value > 0) {
|
||||
req.api_key_id = selectedCredentialId.value
|
||||
}
|
||||
emit('generate', req)
|
||||
prompt.value = ''
|
||||
imagePreview.value = null
|
||||
imageError.value = ''
|
||||
if (textareaRef.value) {
|
||||
textareaRef.value.style.height = 'auto'
|
||||
}
|
||||
}
|
||||
|
||||
/** 外部调用:填充提示词 */
|
||||
function fillPrompt(text: string) {
|
||||
prompt.value = text
|
||||
setTimeout(autoResize, 0)
|
||||
textareaRef.value?.focus()
|
||||
}
|
||||
|
||||
defineExpose({ fillPrompt })
|
||||
|
||||
onMounted(() => {
|
||||
loadModels()
|
||||
loadStorageStatus()
|
||||
loadSoraCredentials()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sora-creator-bar-wrapper {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 40;
|
||||
background: linear-gradient(to top, var(--sora-bg-primary, #0D0D0D) 60%, transparent 100%);
|
||||
padding: 20px 24px 24px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.sora-creator-bar {
|
||||
max-width: 780px;
|
||||
margin: 0 auto;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.sora-creator-bar-inner {
|
||||
background: var(--sora-bg-secondary, #1A1A1A);
|
||||
border: 1px solid var(--sora-border-color, #2A2A2A);
|
||||
border-radius: var(--sora-radius-xl, 20px);
|
||||
padding: 12px 16px;
|
||||
transition: border-color 150ms ease, box-shadow 150ms ease;
|
||||
}
|
||||
|
||||
.sora-creator-bar-inner.focused {
|
||||
border-color: var(--sora-accent-primary, #14b8a6);
|
||||
box-shadow: 0 0 0 1px var(--sora-accent-primary, #14b8a6), var(--sora-shadow-glow, 0 0 20px rgba(20,184,166,0.3));
|
||||
}
|
||||
|
||||
/* 模型选择行 */
|
||||
.sora-creator-model-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.sora-model-select-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sora-model-select {
|
||||
appearance: none;
|
||||
background: var(--sora-bg-tertiary, #242424);
|
||||
color: var(--sora-text-primary, #FFF);
|
||||
padding: 5px 28px 5px 10px;
|
||||
border-radius: var(--sora-radius-sm, 8px);
|
||||
font-size: 12px;
|
||||
font-family: "SF Mono", "Fira Code", monospace;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.sora-model-select:hover {
|
||||
border-color: var(--sora-bg-hover, #333);
|
||||
}
|
||||
|
||||
.sora-model-select:focus {
|
||||
border-color: var(--sora-accent-primary, #14b8a6);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.sora-model-select option {
|
||||
background: var(--sora-bg-secondary, #1A1A1A);
|
||||
color: var(--sora-text-primary, #FFF);
|
||||
}
|
||||
|
||||
.sora-model-select-arrow {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
pointer-events: none;
|
||||
font-size: 10px;
|
||||
color: var(--sora-text-tertiary, #666);
|
||||
}
|
||||
|
||||
.sora-credential-select-wrapper {
|
||||
position: relative;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
/* 无存储提示 */
|
||||
.sora-no-storage-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 10px;
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
border: 1px solid rgba(245, 158, 11, 0.2);
|
||||
border-radius: var(--sora-radius-full, 9999px);
|
||||
font-size: 11px;
|
||||
color: var(--sora-warning, #F59E0B);
|
||||
}
|
||||
|
||||
/* 参考图预览 */
|
||||
.sora-image-preview-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.sora-image-preview-thumb {
|
||||
position: relative;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.sora-image-preview-thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--sora-border-color, #2A2A2A);
|
||||
}
|
||||
|
||||
.sora-image-preview-remove {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: -6px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: var(--sora-error, #EF4444);
|
||||
color: white;
|
||||
font-size: 10px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.sora-image-preview-label {
|
||||
font-size: 12px;
|
||||
color: var(--sora-text-tertiary, #666);
|
||||
}
|
||||
|
||||
/* 输入框 */
|
||||
.sora-creator-input-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sora-creator-textarea {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
max-height: 120px;
|
||||
padding: 10px 4px;
|
||||
font-size: 14px;
|
||||
color: var(--sora-text-primary, #FFF);
|
||||
background: transparent;
|
||||
resize: none;
|
||||
line-height: 1.5;
|
||||
overflow-y: auto;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.sora-creator-textarea::placeholder {
|
||||
color: var(--sora-text-muted, #4A4A4A);
|
||||
}
|
||||
|
||||
/* 底部工具行 */
|
||||
.sora-creator-tools-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 4px 0;
|
||||
border-top: 1px solid var(--sora-border-subtle, #1F1F1F);
|
||||
margin-top: 4px;
|
||||
padding-top: 10px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sora-creator-tools-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.sora-tool-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--sora-radius-full, 9999px);
|
||||
font-size: 12px;
|
||||
color: var(--sora-text-secondary, #A0A0A0);
|
||||
background: var(--sora-bg-tertiary, #242424);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sora-tool-btn:hover {
|
||||
background: var(--sora-bg-hover, #333);
|
||||
color: var(--sora-text-primary, #FFF);
|
||||
}
|
||||
|
||||
.sora-tool-btn.active {
|
||||
background: rgba(20, 184, 166, 0.15);
|
||||
color: var(--sora-accent-primary, #14b8a6);
|
||||
border: 1px solid rgba(20, 184, 166, 0.3);
|
||||
}
|
||||
|
||||
.sora-tool-btn-icon {
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.sora-tool-divider {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: var(--sora-border-color, #2A2A2A);
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
/* 上传按钮 */
|
||||
.sora-upload-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--sora-radius-sm, 8px);
|
||||
background: var(--sora-bg-tertiary, #242424);
|
||||
color: var(--sora-text-secondary, #A0A0A0);
|
||||
font-size: 16px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.sora-upload-btn:hover {
|
||||
background: var(--sora-bg-hover, #333);
|
||||
color: var(--sora-text-primary, #FFF);
|
||||
}
|
||||
|
||||
/* 活跃任务计数 */
|
||||
.sora-active-tasks-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 12px;
|
||||
background: rgba(20, 184, 166, 0.12);
|
||||
border: 1px solid rgba(20, 184, 166, 0.25);
|
||||
border-radius: var(--sora-radius-full, 9999px);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--sora-accent-primary, #14b8a6);
|
||||
white-space: nowrap;
|
||||
animation: sora-fade-in 0.3s ease;
|
||||
}
|
||||
|
||||
.sora-pulse-indicator {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--sora-accent-primary, #14b8a6);
|
||||
animation: sora-pulse-dot 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes sora-pulse-dot {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
@keyframes sora-fade-in {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* 生成按钮 */
|
||||
.sora-generate-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 24px;
|
||||
background: var(--sora-accent-gradient, linear-gradient(135deg, #14b8a6, #0d9488));
|
||||
border-radius: var(--sora-radius-full, 9999px);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sora-generate-btn:hover:not(:disabled) {
|
||||
background: var(--sora-accent-gradient-hover, linear-gradient(135deg, #2dd4bf, #14b8a6));
|
||||
box-shadow: var(--sora-shadow-glow, 0 0 20px rgba(20,184,166,0.3));
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.sora-generate-btn:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.sora-generate-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.sora-generate-btn.max-reached {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.sora-generate-btn-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* 图片错误 */
|
||||
.sora-image-error {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--sora-error, #EF4444);
|
||||
margin-top: 8px;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 600px) {
|
||||
.sora-creator-bar-wrapper {
|
||||
padding: 12px 12px 16px;
|
||||
}
|
||||
|
||||
.sora-creator-tools-left {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.sora-tool-btn {
|
||||
padding: 5px 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
87
frontend/src/components/sora/SoraQuotaBar.vue
Normal file
87
frontend/src/components/sora/SoraQuotaBar.vue
Normal file
@@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<div v-if="quota && quota.source !== 'none'" class="sora-quota-info">
|
||||
<div class="sora-quota-bar-wrapper">
|
||||
<div
|
||||
class="sora-quota-bar-fill"
|
||||
:class="{ warning: percentage > 80, danger: percentage > 95 }"
|
||||
:style="{ width: `${Math.min(percentage, 100)}%` }"
|
||||
/>
|
||||
</div>
|
||||
<span class="sora-quota-text" :class="{ warning: percentage > 80, danger: percentage > 95 }">
|
||||
{{ formatBytes(quota.used_bytes) }} / {{ quota.quota_bytes === 0 ? '∞' : formatBytes(quota.quota_bytes) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { QuotaInfo } from '@/api/sora'
|
||||
|
||||
const props = defineProps<{ quota: QuotaInfo }>()
|
||||
|
||||
const percentage = computed(() => {
|
||||
if (!props.quota || props.quota.quota_bytes === 0) return 0
|
||||
return (props.quota.used_bytes / props.quota.quota_bytes) * 100
|
||||
})
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024))
|
||||
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sora-quota-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 14px;
|
||||
background: var(--sora-bg-secondary);
|
||||
border-radius: var(--sora-radius-full, 9999px);
|
||||
font-size: 12px;
|
||||
color: var(--sora-text-secondary, #A0A0A0);
|
||||
}
|
||||
|
||||
.sora-quota-bar-wrapper {
|
||||
width: 80px;
|
||||
height: 4px;
|
||||
background: var(--sora-bg-hover, #333);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sora-quota-bar-fill {
|
||||
height: 100%;
|
||||
background: var(--sora-accent-gradient, linear-gradient(135deg, #14b8a6, #0d9488));
|
||||
border-radius: 2px;
|
||||
transition: width 400ms ease;
|
||||
}
|
||||
|
||||
.sora-quota-bar-fill.warning {
|
||||
background: var(--sora-warning, #F59E0B) !important;
|
||||
}
|
||||
|
||||
.sora-quota-bar-fill.danger {
|
||||
background: var(--sora-error, #EF4444) !important;
|
||||
}
|
||||
|
||||
.sora-quota-text {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sora-quota-text.warning {
|
||||
color: var(--sora-warning, #F59E0B);
|
||||
}
|
||||
|
||||
.sora-quota-text.danger {
|
||||
color: var(--sora-error, #EF4444);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.sora-quota-info {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user