Commit 1de18b89 authored by Wang Lvyuan's avatar Wang Lvyuan
Browse files

merge: sync upstream/main before PR

parents 882518c1 9f6ab6b8
...@@ -218,7 +218,7 @@ export default { ...@@ -218,7 +218,7 @@ export default {
email: '邮箱', email: '邮箱',
password: '密码', password: '密码',
confirmPassword: '确认密码', confirmPassword: '确认密码',
passwordPlaceholder: '至少 6 个字符', passwordPlaceholder: '至少 8 个字符',
confirmPasswordPlaceholder: '确认密码', confirmPasswordPlaceholder: '确认密码',
passwordMismatch: '密码不匹配' passwordMismatch: '密码不匹配'
}, },
...@@ -723,11 +723,14 @@ export default { ...@@ -723,11 +723,14 @@ export default {
exporting: '导出中...', exporting: '导出中...',
preparingExport: '正在准备导出...', preparingExport: '正在准备导出...',
model: '模型', model: '模型',
requestedModel: '请求',
upstreamModel: '上游',
reasoningEffort: '推理强度', reasoningEffort: '推理强度',
endpoint: '端点', endpoint: '端点',
endpointDistribution: '端点分布', endpointDistribution: '端点分布',
inbound: '入站', inbound: '入站',
upstream: '上游', upstream: '上游',
mapping: '映射',
path: '路径', path: '路径',
inboundEndpoint: '入站端点', inboundEndpoint: '入站端点',
upstreamEndpoint: '上游端点', upstreamEndpoint: '上游端点',
...@@ -1561,6 +1564,8 @@ export default { ...@@ -1561,6 +1564,8 @@ export default {
priority: '优先级', priority: '优先级',
apiKeys: 'API 密钥数', apiKeys: 'API 密钥数',
accounts: '账号数', accounts: '账号数',
capacity: '容量',
usage: '用量',
status: '状态', status: '状态',
actions: '操作', actions: '操作',
billingType: '计费类型', billingType: '计费类型',
...@@ -1569,6 +1574,12 @@ export default { ...@@ -1569,6 +1574,12 @@ export default {
userNotes: '备注', userNotes: '备注',
userStatus: '状态' userStatus: '状态'
}, },
usageToday: '今日',
usageTotal: '累计',
accountsAvailable: '可用:',
accountsRateLimited: '限流:',
accountsTotal: '总量:',
accountsUnit: '个账号',
form: { form: {
name: '名称', name: '名称',
description: '描述', description: '描述',
...@@ -1774,6 +1785,7 @@ export default { ...@@ -1774,6 +1785,7 @@ export default {
revokeSubscription: '撤销订阅', revokeSubscription: '撤销订阅',
allStatus: '全部状态', allStatus: '全部状态',
allGroups: '全部分组', allGroups: '全部分组',
allPlatforms: '全部平台',
daily: '每日', daily: '每日',
weekly: '每周', weekly: '每周',
monthly: '每月', monthly: '每月',
...@@ -1838,7 +1850,37 @@ export default { ...@@ -1838,7 +1850,37 @@ export default {
pleaseSelectUser: '请选择用户', pleaseSelectUser: '请选择用户',
pleaseSelectGroup: '请选择分组', pleaseSelectGroup: '请选择分组',
validityDaysRequired: '请输入有效的天数(至少1天)', validityDaysRequired: '请输入有效的天数(至少1天)',
revokeConfirm: "确定要撤销 '{user}' 的订阅吗?此操作无法撤销。" revokeConfirm: "确定要撤销 '{user}' 的订阅吗?此操作无法撤销。",
guide: {
title: '订阅管理教程',
subtitle: '订阅模式允许你按时间周期为用户分配使用额度,支持日/周/月配额限制。按照以下步骤即可完成配置。',
showGuide: '使用指南',
step1: {
title: '创建订阅分组',
line1: '前往「分组管理」页面,点击「创建分组」',
line2: '将计费类型设为「订阅」,配置日/周/月额度限制',
line3: '保存分组,确保状态为「正常」',
link: '前往分组管理'
},
step2: {
title: '分配订阅给用户',
line1: '点击本页右上角「分配订阅」按钮',
line2: '在弹窗中搜索用户邮箱并选择目标用户',
line3: '选择订阅分组、设置有效期天数,点击「分配」'
},
step3: {
title: '管理已有订阅'
},
actions: {
adjust: '调整',
adjustDesc: '延长或缩短订阅有效期',
resetQuota: '重置配额',
resetQuotaDesc: '将日/周/月用量归零,重新开始计算',
revoke: '撤销',
revokeDesc: '立即终止该用户的订阅,不可恢复'
},
tip: '提示:订阅分组下拉列表中只会显示计费类型为「订阅」且状态为「正常」的分组。如果没有可选项,请先到分组管理中创建。'
}
}, },
// Accounts Management // Accounts Management
...@@ -4485,6 +4527,16 @@ export default { ...@@ -4485,6 +4527,16 @@ export default {
testFailed: 'Google Drive 存储测试失败' testFailed: 'Google Drive 存储测试失败'
} }
}, },
overloadCooldown: {
title: '529 过载冷却',
description: '配置上游返回 529(过载)时的账号调度暂停策略',
enabled: '启用过载冷却',
enabledHint: '收到 529 错误时暂停该账号的调度,冷却后自动恢复',
cooldownMinutes: '冷却时长(分钟)',
cooldownMinutesHint: '账号暂停调度的持续时间(1-120 分钟)',
saved: '过载冷却设置保存成功',
saveFailed: '保存过载冷却设置失败'
},
streamTimeout: { streamTimeout: {
title: '流超时处理', title: '流超时处理',
description: '配置上游响应超时时的账户处理策略,避免问题账户持续被选中', description: '配置上游响应超时时的账户处理策略,避免问题账户持续被选中',
......
...@@ -411,6 +411,8 @@ export interface AdminGroup extends Group { ...@@ -411,6 +411,8 @@ export interface AdminGroup extends Group {
// 分组下账号数量(仅管理员可见) // 分组下账号数量(仅管理员可见)
account_count?: number account_count?: number
active_account_count?: number
rate_limited_account_count?: number
// OpenAI Messages 调度配置(仅 openai 平台使用) // OpenAI Messages 调度配置(仅 openai 平台使用)
default_mapped_model?: string default_mapped_model?: string
...@@ -975,6 +977,7 @@ export interface UsageLog { ...@@ -975,6 +977,7 @@ export interface UsageLog {
account_id: number | null account_id: number | null
request_id: string request_id: string
model: string model: string
upstream_model?: string | null
service_tier?: string | null service_tier?: string | null
reasoning_effort?: string | null reasoning_effort?: string | null
inbound_endpoint?: string | null inbound_endpoint?: string | null
......
...@@ -158,12 +158,51 @@ ...@@ -158,12 +158,51 @@
</span> </span>
</template> </template>
<template #cell-account_count="{ value }"> <template #cell-account_count="{ row }">
<span <div class="space-y-0.5 text-xs">
class="inline-flex items-center rounded bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-800 dark:bg-dark-600 dark:text-gray-300" <div>
> <span class="text-gray-500 dark:text-gray-400">{{ t('admin.groups.accountsAvailable') }}</span>
{{ t('admin.groups.accountsCount', { count: value || 0 }) }} <span class="ml-1 font-medium text-emerald-600 dark:text-emerald-400">{{ (row.active_account_count || 0) - (row.rate_limited_account_count || 0) }}</span>
</span> <span class="ml-1 inline-flex items-center rounded bg-gray-100 px-1.5 py-0.5 font-medium text-gray-800 dark:bg-dark-600 dark:text-gray-300">{{ t('admin.groups.accountsUnit') }}</span>
</div>
<div v-if="row.rate_limited_account_count">
<span class="text-gray-500 dark:text-gray-400">{{ t('admin.groups.accountsRateLimited') }}</span>
<span class="ml-1 font-medium text-amber-600 dark:text-amber-400">{{ row.rate_limited_account_count }}</span>
<span class="ml-1 inline-flex items-center rounded bg-gray-100 px-1.5 py-0.5 font-medium text-gray-800 dark:bg-dark-600 dark:text-gray-300">{{ t('admin.groups.accountsUnit') }}</span>
</div>
<div>
<span class="text-gray-500 dark:text-gray-400">{{ t('admin.groups.accountsTotal') }}</span>
<span class="ml-1 font-medium text-gray-700 dark:text-gray-300">{{ row.account_count || 0 }}</span>
<span class="ml-1 inline-flex items-center rounded bg-gray-100 px-1.5 py-0.5 font-medium text-gray-800 dark:bg-dark-600 dark:text-gray-300">{{ t('admin.groups.accountsUnit') }}</span>
</div>
</div>
</template>
<template #cell-capacity="{ row }">
<GroupCapacityBadge
v-if="capacityMap.get(row.id)"
:concurrency-used="capacityMap.get(row.id)!.concurrencyUsed"
:concurrency-max="capacityMap.get(row.id)!.concurrencyMax"
:sessions-used="capacityMap.get(row.id)!.sessionsUsed"
:sessions-max="capacityMap.get(row.id)!.sessionsMax"
:rpm-used="capacityMap.get(row.id)!.rpmUsed"
:rpm-max="capacityMap.get(row.id)!.rpmMax"
/>
<span v-else class="text-xs text-gray-400"></span>
</template>
<template #cell-usage="{ row }">
<div v-if="usageLoading" class="text-xs text-gray-400"></div>
<div v-else class="space-y-0.5 text-xs">
<div class="text-gray-500 dark:text-gray-400">
<span class="text-gray-400 dark:text-gray-500">{{ t('admin.groups.usageToday') }}</span>
<span class="ml-1 font-medium text-gray-700 dark:text-gray-300">${{ formatCost(usageMap.get(row.id)?.today_cost ?? 0) }}</span>
</div>
<div class="text-gray-500 dark:text-gray-400">
<span class="text-gray-400 dark:text-gray-500">{{ t('admin.groups.usageTotal') }}</span>
<span class="ml-1 font-medium text-gray-700 dark:text-gray-300">${{ formatCost(usageMap.get(row.id)?.total_cost ?? 0) }}</span>
</div>
</div>
</template> </template>
<template #cell-status="{ value }"> <template #cell-status="{ value }">
...@@ -1812,6 +1851,7 @@ import Select from '@/components/common/Select.vue' ...@@ -1812,6 +1851,7 @@ import Select from '@/components/common/Select.vue'
import PlatformIcon from '@/components/common/PlatformIcon.vue' import PlatformIcon from '@/components/common/PlatformIcon.vue'
import Icon from '@/components/icons/Icon.vue' import Icon from '@/components/icons/Icon.vue'
import GroupRateMultipliersModal from '@/components/admin/group/GroupRateMultipliersModal.vue' import GroupRateMultipliersModal from '@/components/admin/group/GroupRateMultipliersModal.vue'
import GroupCapacityBadge from '@/components/common/GroupCapacityBadge.vue'
import { VueDraggable } from 'vue-draggable-plus' import { VueDraggable } from 'vue-draggable-plus'
import { createStableObjectKeyResolver } from '@/utils/stableObjectKey' import { createStableObjectKeyResolver } from '@/utils/stableObjectKey'
import { useKeyedDebouncedSearch } from '@/composables/useKeyedDebouncedSearch' import { useKeyedDebouncedSearch } from '@/composables/useKeyedDebouncedSearch'
...@@ -1827,6 +1867,8 @@ const columns = computed<Column[]>(() => [ ...@@ -1827,6 +1867,8 @@ const columns = computed<Column[]>(() => [
{ key: 'rate_multiplier', label: t('admin.groups.columns.rateMultiplier'), sortable: true }, { key: 'rate_multiplier', label: t('admin.groups.columns.rateMultiplier'), sortable: true },
{ key: 'is_exclusive', label: t('admin.groups.columns.type'), sortable: true }, { key: 'is_exclusive', label: t('admin.groups.columns.type'), sortable: true },
{ key: 'account_count', label: t('admin.groups.columns.accounts'), sortable: true }, { key: 'account_count', label: t('admin.groups.columns.accounts'), sortable: true },
{ key: 'capacity', label: t('admin.groups.columns.capacity'), sortable: false },
{ key: 'usage', label: t('admin.groups.columns.usage'), sortable: false },
{ key: 'status', label: t('admin.groups.columns.status'), sortable: true }, { key: 'status', label: t('admin.groups.columns.status'), sortable: true },
{ key: 'actions', label: t('admin.groups.columns.actions'), sortable: false } { key: 'actions', label: t('admin.groups.columns.actions'), sortable: false }
]) ])
...@@ -1963,6 +2005,9 @@ const copyAccountsGroupOptionsForEdit = computed(() => { ...@@ -1963,6 +2005,9 @@ const copyAccountsGroupOptionsForEdit = computed(() => {
const groups = ref<AdminGroup[]>([]) const groups = ref<AdminGroup[]>([])
const loading = ref(false) const loading = ref(false)
const usageMap = ref<Map<number, { today_cost: number; total_cost: number }>>(new Map())
const usageLoading = ref(false)
const capacityMap = ref<Map<number, { concurrencyUsed: number; concurrencyMax: number; sessionsUsed: number; sessionsMax: number; rpmUsed: number; rpmMax: number }>>(new Map())
const searchQuery = ref('') const searchQuery = ref('')
const filters = reactive({ const filters = reactive({
platform: '', platform: '',
...@@ -2301,6 +2346,8 @@ const loadGroups = async () => { ...@@ -2301,6 +2346,8 @@ const loadGroups = async () => {
groups.value = response.items groups.value = response.items
pagination.total = response.total pagination.total = response.total
pagination.pages = response.pages pagination.pages = response.pages
loadUsageSummary()
loadCapacitySummary()
} catch (error: any) { } catch (error: any) {
if (signal.aborted || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') { if (signal.aborted || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') {
return return
...@@ -2314,6 +2361,49 @@ const loadGroups = async () => { ...@@ -2314,6 +2361,49 @@ const loadGroups = async () => {
} }
} }
const formatCost = (cost: number): string => {
if (cost >= 1000) return cost.toFixed(0)
if (cost >= 100) return cost.toFixed(1)
return cost.toFixed(2)
}
const loadUsageSummary = async () => {
usageLoading.value = true
try {
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone
const data = await adminAPI.groups.getUsageSummary(tz)
const map = new Map<number, { today_cost: number; total_cost: number }>()
for (const item of data) {
map.set(item.group_id, { today_cost: item.today_cost, total_cost: item.total_cost })
}
usageMap.value = map
} catch (error) {
console.error('Error loading group usage summary:', error)
} finally {
usageLoading.value = false
}
}
const loadCapacitySummary = async () => {
try {
const data = await adminAPI.groups.getCapacitySummary()
const map = new Map<number, { concurrencyUsed: number; concurrencyMax: number; sessionsUsed: number; sessionsMax: number; rpmUsed: number; rpmMax: number }>()
for (const item of data) {
map.set(item.group_id, {
concurrencyUsed: item.concurrency_used,
concurrencyMax: item.concurrency_max,
sessionsUsed: item.sessions_used,
sessionsMax: item.sessions_max,
rpmUsed: item.rpm_used,
rpmMax: item.rpm_max
})
}
capacityMap.value = map
} catch (error) {
console.error('Error loading group capacity summary:', error)
}
}
let searchTimeout: ReturnType<typeof setTimeout> let searchTimeout: ReturnType<typeof setTimeout>
const handleSearch = () => { const handleSearch = () => {
clearTimeout(searchTimeout) clearTimeout(searchTimeout)
......
...@@ -168,8 +168,93 @@ ...@@ -168,8 +168,93 @@
</div> </div>
</div><!-- /Tab: Security Admin API Key --> </div><!-- /Tab: Security Admin API Key -->
<!-- Tab: Gateway Stream Timeout --> <!-- Tab: Gateway -->
<div v-show="activeTab === 'gateway'" class="space-y-6"> <div v-show="activeTab === 'gateway'" class="space-y-6">
<!-- Overload Cooldown (529) Settings -->
<div class="card">
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ t('admin.settings.overloadCooldown.title') }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.settings.overloadCooldown.description') }}
</p>
</div>
<div class="space-y-5 p-6">
<div v-if="overloadCooldownLoading" class="flex items-center gap-2 text-gray-500">
<div class="h-4 w-4 animate-spin rounded-full border-b-2 border-primary-600"></div>
{{ t('common.loading') }}
</div>
<template v-else>
<div class="flex items-center justify-between">
<div>
<label class="font-medium text-gray-900 dark:text-white">{{
t('admin.settings.overloadCooldown.enabled')
}}</label>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.settings.overloadCooldown.enabledHint') }}
</p>
</div>
<Toggle v-model="overloadCooldownForm.enabled" />
</div>
<div
v-if="overloadCooldownForm.enabled"
class="space-y-4 border-t border-gray-100 pt-4 dark:border-dark-700"
>
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.overloadCooldown.cooldownMinutes') }}
</label>
<input
v-model.number="overloadCooldownForm.cooldown_minutes"
type="number"
min="1"
max="120"
class="input w-32"
/>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.overloadCooldown.cooldownMinutesHint') }}
</p>
</div>
</div>
<div class="flex justify-end border-t border-gray-100 pt-4 dark:border-dark-700">
<button
type="button"
@click="saveOverloadCooldownSettings"
:disabled="overloadCooldownSaving"
class="btn btn-primary btn-sm"
>
<svg
v-if="overloadCooldownSaving"
class="mr-1 h-4 w-4 animate-spin"
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>
{{ overloadCooldownSaving ? t('common.saving') : t('common.save') }}
</button>
</div>
</template>
</div>
</div>
<!-- Stream Timeout Settings --> <!-- Stream Timeout Settings -->
<div class="card"> <div class="card">
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700"> <div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
...@@ -1765,6 +1850,14 @@ const adminApiKeyOperating = ref(false) ...@@ -1765,6 +1850,14 @@ const adminApiKeyOperating = ref(false)
const newAdminApiKey = ref('') const newAdminApiKey = ref('')
const subscriptionGroups = ref<AdminGroup[]>([]) const subscriptionGroups = ref<AdminGroup[]>([])
// Overload Cooldown (529) 状态
const overloadCooldownLoading = ref(true)
const overloadCooldownSaving = ref(false)
const overloadCooldownForm = reactive({
enabled: true,
cooldown_minutes: 10
})
// Stream Timeout 状态 // Stream Timeout 状态
const streamTimeoutLoading = ref(true) const streamTimeoutLoading = ref(true)
const streamTimeoutSaving = ref(false) const streamTimeoutSaving = ref(false)
...@@ -2274,6 +2367,37 @@ function copyNewKey() { ...@@ -2274,6 +2367,37 @@ function copyNewKey() {
}) })
} }
// Overload Cooldown 方法
async function loadOverloadCooldownSettings() {
overloadCooldownLoading.value = true
try {
const settings = await adminAPI.settings.getOverloadCooldownSettings()
Object.assign(overloadCooldownForm, settings)
} catch (error: any) {
console.error('Failed to load overload cooldown settings:', error)
} finally {
overloadCooldownLoading.value = false
}
}
async function saveOverloadCooldownSettings() {
overloadCooldownSaving.value = true
try {
const updated = await adminAPI.settings.updateOverloadCooldownSettings({
enabled: overloadCooldownForm.enabled,
cooldown_minutes: overloadCooldownForm.cooldown_minutes
})
Object.assign(overloadCooldownForm, updated)
appStore.showSuccess(t('admin.settings.overloadCooldown.saved'))
} catch (error: any) {
appStore.showError(
t('admin.settings.overloadCooldown.saveFailed') + ': ' + (error.message || t('common.unknownError'))
)
} finally {
overloadCooldownSaving.value = false
}
}
// Stream Timeout 方法 // Stream Timeout 方法
async function loadStreamTimeoutSettings() { async function loadStreamTimeoutSettings() {
streamTimeoutLoading.value = true streamTimeoutLoading.value = true
...@@ -2396,6 +2520,7 @@ onMounted(() => { ...@@ -2396,6 +2520,7 @@ onMounted(() => {
loadSettings() loadSettings()
loadSubscriptionGroups() loadSubscriptionGroups()
loadAdminApiKey() loadAdminApiKey()
loadOverloadCooldownSettings()
loadStreamTimeoutSettings() loadStreamTimeoutSettings()
loadRectifierSettings() loadRectifierSettings()
loadBetaPolicySettings() loadBetaPolicySettings()
......
...@@ -81,6 +81,14 @@ ...@@ -81,6 +81,14 @@
@change="applyFilters" @change="applyFilters"
/> />
</div> </div>
<div class="w-full sm:w-40">
<Select
v-model="filters.platform"
:options="platformFilterOptions"
:placeholder="t('admin.subscriptions.allPlatforms')"
@change="applyFilters"
/>
</div>
</div> </div>
<!-- Right: Actions --> <!-- Right: Actions -->
...@@ -144,6 +152,13 @@ ...@@ -144,6 +152,13 @@
</div> </div>
</div> </div>
</div> </div>
<button
@click="showGuideModal = true"
class="btn btn-secondary"
:title="t('admin.subscriptions.guide.showGuide')"
>
<Icon name="questionCircle" size="md" />
</button>
<button @click="showAssignModal = true" class="btn btn-primary"> <button @click="showAssignModal = true" class="btn btn-primary">
<Icon name="plus" size="md" class="mr-2" /> <Icon name="plus" size="md" class="mr-2" />
{{ t('admin.subscriptions.assignSubscription') }} {{ t('admin.subscriptions.assignSubscription') }}
...@@ -638,6 +653,85 @@ ...@@ -638,6 +653,85 @@
@confirm="confirmResetQuota" @confirm="confirmResetQuota"
@cancel="showResetQuotaConfirm = false" @cancel="showResetQuotaConfirm = false"
/> />
<!-- Subscription Guide Modal -->
<teleport to="body">
<transition name="modal">
<div v-if="showGuideModal" class="fixed inset-0 z-50 flex items-center justify-center p-4" @mousedown.self="showGuideModal = false">
<div class="fixed inset-0 bg-black/50" @click="showGuideModal = false"></div>
<div class="relative max-h-[85vh] w-full max-w-2xl overflow-y-auto rounded-xl bg-white p-6 shadow-2xl dark:bg-dark-800">
<button type="button" class="absolute right-4 top-4 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200" @click="showGuideModal = false">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
<h2 class="mb-4 text-lg font-bold text-gray-900 dark:text-white">{{ t('admin.subscriptions.guide.title') }}</h2>
<p class="mb-5 text-sm text-gray-500 dark:text-gray-400">{{ t('admin.subscriptions.guide.subtitle') }}</p>
<!-- Step 1 -->
<div class="mb-5">
<h3 class="mb-2 flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-white">
<span class="flex h-6 w-6 items-center justify-center rounded-full bg-primary-100 text-xs font-bold text-primary-700 dark:bg-primary-900/40 dark:text-primary-300">1</span>
{{ t('admin.subscriptions.guide.step1.title') }}
</h3>
<ol class="ml-8 list-decimal space-y-1 text-sm text-gray-600 dark:text-gray-300">
<li>{{ t('admin.subscriptions.guide.step1.line1') }}</li>
<li>{{ t('admin.subscriptions.guide.step1.line2') }}</li>
<li>{{ t('admin.subscriptions.guide.step1.line3') }}</li>
</ol>
<div class="ml-8 mt-2">
<router-link
to="/admin/groups"
@click="showGuideModal = false"
class="inline-flex items-center gap-1 text-sm font-medium text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
>
{{ t('admin.subscriptions.guide.step1.link') }}
<Icon name="arrowRight" size="xs" />
</router-link>
</div>
</div>
<!-- Step 2 -->
<div class="mb-5">
<h3 class="mb-2 flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-white">
<span class="flex h-6 w-6 items-center justify-center rounded-full bg-primary-100 text-xs font-bold text-primary-700 dark:bg-primary-900/40 dark:text-primary-300">2</span>
{{ t('admin.subscriptions.guide.step2.title') }}
</h3>
<ol class="ml-8 list-decimal space-y-1 text-sm text-gray-600 dark:text-gray-300">
<li>{{ t('admin.subscriptions.guide.step2.line1') }}</li>
<li>{{ t('admin.subscriptions.guide.step2.line2') }}</li>
<li>{{ t('admin.subscriptions.guide.step2.line3') }}</li>
</ol>
</div>
<!-- Step 3 -->
<div class="mb-5">
<h3 class="mb-2 flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-white">
<span class="flex h-6 w-6 items-center justify-center rounded-full bg-primary-100 text-xs font-bold text-primary-700 dark:bg-primary-900/40 dark:text-primary-300">3</span>
{{ t('admin.subscriptions.guide.step3.title') }}
</h3>
<div class="ml-8 overflow-hidden rounded-lg border border-gray-200 dark:border-dark-600">
<table class="w-full text-sm">
<tbody>
<tr v-for="(row, i) in guideActionRows" :key="i" class="border-b border-gray-100 dark:border-dark-700 last:border-0">
<td class="whitespace-nowrap bg-gray-50 px-3 py-2 font-medium text-gray-700 dark:bg-dark-700 dark:text-gray-300">{{ row.action }}</td>
<td class="px-3 py-2 text-gray-600 dark:text-gray-400">{{ row.desc }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Tip -->
<div class="rounded-lg bg-blue-50 p-3 text-xs text-blue-700 dark:bg-blue-900/20 dark:text-blue-300">
{{ t('admin.subscriptions.guide.tip') }}
</div>
<div class="mt-4 text-right">
<button type="button" class="btn btn-primary btn-sm" @click="showGuideModal = false">{{ t('common.close') }}</button>
</div>
</div>
</div>
</transition>
</teleport>
</AppLayout> </AppLayout>
</template> </template>
...@@ -674,6 +768,15 @@ interface GroupOption { ...@@ -674,6 +768,15 @@ interface GroupOption {
rate: number rate: number
} }
// Guide modal state
const showGuideModal = ref(false)
const guideActionRows = computed(() => [
{ action: t('admin.subscriptions.guide.actions.adjust'), desc: t('admin.subscriptions.guide.actions.adjustDesc') },
{ action: t('admin.subscriptions.guide.actions.resetQuota'), desc: t('admin.subscriptions.guide.actions.resetQuotaDesc') },
{ action: t('admin.subscriptions.guide.actions.revoke'), desc: t('admin.subscriptions.guide.actions.revokeDesc') }
])
// User column display mode: 'email' or 'username' // User column display mode: 'email' or 'username'
const userColumnMode = ref<'email' | 'username'>('email') const userColumnMode = ref<'email' | 'username'>('email')
const USER_COLUMN_MODE_KEY = 'subscription-user-column-mode' const USER_COLUMN_MODE_KEY = 'subscription-user-column-mode'
...@@ -813,6 +916,7 @@ let userSearchTimeout: ReturnType<typeof setTimeout> | null = null ...@@ -813,6 +916,7 @@ let userSearchTimeout: ReturnType<typeof setTimeout> | null = null
const filters = reactive({ const filters = reactive({
status: 'active', status: 'active',
group_id: '', group_id: '',
platform: '',
user_id: null as number | null user_id: null as number | null
}) })
...@@ -855,6 +959,15 @@ const groupOptions = computed(() => [ ...@@ -855,6 +959,15 @@ const groupOptions = computed(() => [
...groups.value.map((g) => ({ value: g.id.toString(), label: g.name })) ...groups.value.map((g) => ({ value: g.id.toString(), label: g.name }))
]) ])
const platformFilterOptions = computed(() => [
{ value: '', label: t('admin.subscriptions.allPlatforms') },
{ value: 'anthropic', label: 'Anthropic' },
{ value: 'openai', label: 'OpenAI' },
{ value: 'gemini', label: 'Gemini' },
{ value: 'antigravity', label: 'Antigravity' },
{ value: 'sora', label: 'Sora' }
])
// Group options for assign (only subscription type groups) // Group options for assign (only subscription type groups)
const subscriptionGroupOptions = computed(() => const subscriptionGroupOptions = computed(() =>
groups.value groups.value
...@@ -890,6 +1003,7 @@ const loadSubscriptions = async () => { ...@@ -890,6 +1003,7 @@ const loadSubscriptions = async () => {
{ {
status: (filters.status as any) || undefined, status: (filters.status as any) || undefined,
group_id: filters.group_id ? parseInt(filters.group_id) : undefined, group_id: filters.group_id ? parseInt(filters.group_id) : undefined,
platform: filters.platform || undefined,
user_id: filters.user_id || undefined, user_id: filters.user_id || undefined,
sort_by: sortState.sort_by, sort_by: sortState.sort_by,
sort_order: sortState.sort_order sort_order: sortState.sort_order
......
...@@ -24,9 +24,13 @@ ...@@ -24,9 +24,13 @@
</div> </div>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2"> <div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<ModelDistributionChart <ModelDistributionChart
v-model:source="modelDistributionSource"
v-model:metric="modelDistributionMetric" v-model:metric="modelDistributionMetric"
:model-stats="modelStats" :model-stats="requestedModelStats"
:loading="chartsLoading" :upstream-model-stats="upstreamModelStats"
:mapping-model-stats="mappingModelStats"
:loading="modelStatsLoading"
:show-source-toggle="true"
:show-metric-toggle="true" :show-metric-toggle="true"
:start-date="startDate" :start-date="startDate"
:end-date="endDate" :end-date="endDate"
...@@ -115,7 +119,7 @@ ...@@ -115,7 +119,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue' import { ref, reactive, computed, onMounted, onUnmounted, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { saveAs } from 'file-saver' import { saveAs } from 'file-saver'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
...@@ -136,10 +140,17 @@ const { t } = useI18n() ...@@ -136,10 +140,17 @@ const { t } = useI18n()
const appStore = useAppStore() const appStore = useAppStore()
type DistributionMetric = 'tokens' | 'actual_cost' type DistributionMetric = 'tokens' | 'actual_cost'
type EndpointSource = 'inbound' | 'upstream' | 'path' type EndpointSource = 'inbound' | 'upstream' | 'path'
type ModelDistributionSource = 'requested' | 'upstream' | 'mapping'
const route = useRoute() const route = useRoute()
const usageStats = ref<AdminUsageStatsResponse | null>(null); const usageLogs = ref<AdminUsageLog[]>([]); const loading = ref(false); const exporting = ref(false) const usageStats = ref<AdminUsageStatsResponse | null>(null); const usageLogs = ref<AdminUsageLog[]>([]); const loading = ref(false); const exporting = ref(false)
const trendData = ref<TrendDataPoint[]>([]); const modelStats = ref<ModelStat[]>([]); const groupStats = ref<GroupStat[]>([]); const chartsLoading = ref(false); const granularity = ref<'day' | 'hour'>('hour') const trendData = ref<TrendDataPoint[]>([]); const requestedModelStats = ref<ModelStat[]>([]); const upstreamModelStats = ref<ModelStat[]>([]); const mappingModelStats = ref<ModelStat[]>([]); const groupStats = ref<GroupStat[]>([]); const chartsLoading = ref(false); const modelStatsLoading = ref(false); const granularity = ref<'day' | 'hour'>('hour')
const modelDistributionMetric = ref<DistributionMetric>('tokens') const modelDistributionMetric = ref<DistributionMetric>('tokens')
const modelDistributionSource = ref<ModelDistributionSource>('requested')
const loadedModelSources = reactive<Record<ModelDistributionSource, boolean>>({
requested: false,
upstream: false,
mapping: false,
})
const groupDistributionMetric = ref<DistributionMetric>('tokens') const groupDistributionMetric = ref<DistributionMetric>('tokens')
const endpointDistributionMetric = ref<DistributionMetric>('tokens') const endpointDistributionMetric = ref<DistributionMetric>('tokens')
const endpointDistributionSource = ref<EndpointSource>('inbound') const endpointDistributionSource = ref<EndpointSource>('inbound')
...@@ -150,6 +161,7 @@ const endpointStatsLoading = ref(false) ...@@ -150,6 +161,7 @@ const endpointStatsLoading = ref(false)
let abortController: AbortController | null = null; let exportAbortController: AbortController | null = null let abortController: AbortController | null = null; let exportAbortController: AbortController | null = null
let chartReqSeq = 0 let chartReqSeq = 0
let statsReqSeq = 0 let statsReqSeq = 0
let modelStatsReqSeq = 0
const exportProgress = reactive({ show: false, progress: 0, current: 0, total: 0, estimatedTime: '' }) const exportProgress = reactive({ show: false, progress: 0, current: 0, total: 0, estimatedTime: '' })
const cleanupDialogVisible = ref(false) const cleanupDialogVisible = ref(false)
// Balance history modal state // Balance history modal state
...@@ -269,6 +281,68 @@ const loadStats = async () => { ...@@ -269,6 +281,68 @@ const loadStats = async () => {
if (seq === statsReqSeq) endpointStatsLoading.value = false if (seq === statsReqSeq) endpointStatsLoading.value = false
} }
} }
const resetModelStatsCache = () => {
requestedModelStats.value = []
upstreamModelStats.value = []
mappingModelStats.value = []
loadedModelSources.requested = false
loadedModelSources.upstream = false
loadedModelSources.mapping = false
}
const loadModelStats = async (source: ModelDistributionSource, force = false) => {
if (!force && loadedModelSources[source]) {
return
}
const seq = ++modelStatsReqSeq
modelStatsLoading.value = true
try {
const requestType = filters.value.request_type
const legacyStream = requestType ? requestTypeToLegacyStream(requestType) : filters.value.stream
const baseParams = {
start_date: filters.value.start_date || startDate.value,
end_date: filters.value.end_date || endDate.value,
user_id: filters.value.user_id,
model: filters.value.model,
api_key_id: filters.value.api_key_id,
account_id: filters.value.account_id,
group_id: filters.value.group_id,
request_type: requestType,
stream: legacyStream === null ? undefined : legacyStream,
billing_type: filters.value.billing_type,
}
const response = await adminAPI.dashboard.getModelStats({ ...baseParams, model_source: source })
if (seq !== modelStatsReqSeq) return
const models = response.models || []
if (source === 'requested') {
requestedModelStats.value = models
} else if (source === 'upstream') {
upstreamModelStats.value = models
} else {
mappingModelStats.value = models
}
loadedModelSources[source] = true
} catch (error) {
if (seq !== modelStatsReqSeq) return
console.error('Failed to load model stats:', error)
if (source === 'requested') {
requestedModelStats.value = []
} else if (source === 'upstream') {
upstreamModelStats.value = []
} else {
mappingModelStats.value = []
}
loadedModelSources[source] = false
} finally {
if (seq === modelStatsReqSeq) modelStatsLoading.value = false
}
}
const loadChartData = async () => { const loadChartData = async () => {
const seq = ++chartReqSeq const seq = ++chartReqSeq
chartsLoading.value = true chartsLoading.value = true
...@@ -289,18 +363,30 @@ const loadChartData = async () => { ...@@ -289,18 +363,30 @@ const loadChartData = async () => {
billing_type: filters.value.billing_type, billing_type: filters.value.billing_type,
include_stats: false, include_stats: false,
include_trend: true, include_trend: true,
include_model_stats: true, include_model_stats: false,
include_group_stats: true, include_group_stats: true,
include_users_trend: false include_users_trend: false
}) })
if (seq !== chartReqSeq) return if (seq !== chartReqSeq) return
trendData.value = snapshot.trend || [] trendData.value = snapshot.trend || []
modelStats.value = snapshot.models || []
groupStats.value = snapshot.groups || [] groupStats.value = snapshot.groups || []
} catch (error) { console.error('Failed to load chart data:', error) } finally { if (seq === chartReqSeq) chartsLoading.value = false } } catch (error) { console.error('Failed to load chart data:', error) } finally { if (seq === chartReqSeq) chartsLoading.value = false }
} }
const applyFilters = () => { pagination.page = 1; loadLogs(); loadStats(); loadChartData() } const applyFilters = () => {
const refreshData = () => { loadLogs(); loadStats(); loadChartData() } pagination.page = 1
resetModelStatsCache()
loadLogs()
loadStats()
loadModelStats(modelDistributionSource.value, true)
loadChartData()
}
const refreshData = () => {
resetModelStatsCache()
loadLogs()
loadStats()
loadModelStats(modelDistributionSource.value, true)
loadChartData()
}
const resetFilters = () => { const resetFilters = () => {
const range = getLast24HoursRangeDates() const range = getLast24HoursRangeDates()
startDate.value = range.start startDate.value = range.start
...@@ -329,7 +415,7 @@ const exportToExcel = async () => { ...@@ -329,7 +415,7 @@ const exportToExcel = async () => {
const XLSX = await import('xlsx') const XLSX = await import('xlsx')
const headers = [ const headers = [
t('usage.time'), t('admin.usage.user'), t('usage.apiKeyFilter'), t('usage.time'), t('admin.usage.user'), t('usage.apiKeyFilter'),
t('admin.usage.account'), t('usage.model'), t('usage.reasoningEffort'), t('admin.usage.group'), t('admin.usage.account'), t('usage.model'), t('usage.upstreamModel'), t('usage.reasoningEffort'), t('admin.usage.group'),
t('usage.inboundEndpoint'), t('usage.upstreamEndpoint'), t('usage.inboundEndpoint'), t('usage.upstreamEndpoint'),
t('usage.type'), t('usage.type'),
t('admin.usage.inputTokens'), t('admin.usage.outputTokens'), t('admin.usage.inputTokens'), t('admin.usage.outputTokens'),
...@@ -348,7 +434,7 @@ const exportToExcel = async () => { ...@@ -348,7 +434,7 @@ const exportToExcel = async () => {
if (c.signal.aborted) break; if (p === 1) { total = res.total; exportProgress.total = total } if (c.signal.aborted) break; if (p === 1) { total = res.total; exportProgress.total = total }
const rows = (res.items || []).map((log: AdminUsageLog) => [ const rows = (res.items || []).map((log: AdminUsageLog) => [
log.created_at, log.user?.email || '', log.api_key?.name || '', log.account?.name || '', log.model, log.created_at, log.user?.email || '', log.api_key?.name || '', log.account?.name || '', log.model,
formatReasoningEffort(log.reasoning_effort), log.group?.name || '', log.upstream_model || '', formatReasoningEffort(log.reasoning_effort), log.group?.name || '',
log.inbound_endpoint || '', log.upstream_endpoint || '', getRequestTypeLabel(log), log.inbound_endpoint || '', log.upstream_endpoint || '', getRequestTypeLabel(log),
log.input_tokens, log.output_tokens, log.cache_read_tokens, log.cache_creation_tokens, log.input_tokens, log.output_tokens, log.cache_read_tokens, log.cache_creation_tokens,
log.input_cost?.toFixed(6) || '0.000000', log.output_cost?.toFixed(6) || '0.000000', log.input_cost?.toFixed(6) || '0.000000', log.output_cost?.toFixed(6) || '0.000000',
...@@ -458,6 +544,7 @@ onMounted(() => { ...@@ -458,6 +544,7 @@ onMounted(() => {
applyRouteQueryFilters() applyRouteQueryFilters()
loadLogs() loadLogs()
loadStats() loadStats()
loadModelStats(modelDistributionSource.value, true)
window.setTimeout(() => { window.setTimeout(() => {
void loadChartData() void loadChartData()
}, 120) }, 120)
...@@ -465,4 +552,8 @@ onMounted(() => { ...@@ -465,4 +552,8 @@ onMounted(() => {
document.addEventListener('click', handleColumnClickOutside) document.addEventListener('click', handleColumnClickOutside)
}) })
onUnmounted(() => { abortController?.abort(); exportAbortController?.abort(); document.removeEventListener('click', handleColumnClickOutside) }) onUnmounted(() => { abortController?.abort(); exportAbortController?.abort(); document.removeEventListener('click', handleColumnClickOutside) })
watch(modelDistributionSource, (source) => {
void loadModelStats(source)
})
</script> </script>
...@@ -565,7 +565,7 @@ const canProceed = computed(() => { ...@@ -565,7 +565,7 @@ const canProceed = computed(() => {
case 2: case 2:
return ( return (
formData.admin.email && formData.admin.email &&
formData.admin.password.length >= 6 && formData.admin.password.length >= 8 &&
formData.admin.password === confirmPassword.value formData.admin.password === confirmPassword.value
) )
default: default:
...@@ -582,8 +582,9 @@ async function testDatabaseConnection() { ...@@ -582,8 +582,9 @@ async function testDatabaseConnection() {
await testDatabase(formData.database) await testDatabase(formData.database)
dbConnected.value = true dbConnected.value = true
} catch (error: unknown) { } catch (error: unknown) {
const err = error as { response?: { data?: { detail?: string } }; message?: string } const err = error as { response?: { data?: { detail?: string; message?: string } }; message?: string }
errorMessage.value = err.response?.data?.detail || err.message || 'Connection failed' errorMessage.value =
err.response?.data?.detail || err.response?.data?.message || err.message || 'Connection failed'
} finally { } finally {
testingDb.value = false testingDb.value = false
} }
...@@ -598,8 +599,9 @@ async function testRedisConnection() { ...@@ -598,8 +599,9 @@ async function testRedisConnection() {
await testRedis(formData.redis) await testRedis(formData.redis)
redisConnected.value = true redisConnected.value = true
} catch (error: unknown) { } catch (error: unknown) {
const err = error as { response?: { data?: { detail?: string } }; message?: string } const err = error as { response?: { data?: { detail?: string; message?: string } }; message?: string }
errorMessage.value = err.response?.data?.detail || err.message || 'Connection failed' errorMessage.value =
err.response?.data?.detail || err.response?.data?.message || err.message || 'Connection failed'
} finally { } finally {
testingRedis.value = false testingRedis.value = false
} }
...@@ -622,8 +624,9 @@ async function performInstall() { ...@@ -622,8 +624,9 @@ async function performInstall() {
// Start polling for service restart // Start polling for service restart
waitForServiceRestart() waitForServiceRestart()
} catch (error: unknown) { } catch (error: unknown) {
const err = error as { response?: { data?: { detail?: string } }; message?: string } const err = error as { response?: { data?: { detail?: string; message?: string } }; message?: string }
errorMessage.value = err.response?.data?.detail || err.message || 'Installation failed' errorMessage.value =
err.response?.data?.detail || err.response?.data?.message || err.message || 'Installation failed'
} finally { } finally {
installing.value = false installing.value = false
} }
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment