mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-04-08 01:00:21 +08:00
Merge branch 'release/custom-0.1.95' into release/custom-0.1.96
This commit is contained in:
@@ -153,6 +153,27 @@ export async function getGroupApiKeys(
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate multiplier entry for a user in a group
|
||||
*/
|
||||
export interface GroupRateMultiplierEntry {
|
||||
user_id: number
|
||||
user_email: string
|
||||
rate_multiplier: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Get rate multipliers for users in a group
|
||||
* @param id - Group ID
|
||||
* @returns List of user rate multiplier entries
|
||||
*/
|
||||
export async function getGroupRateMultipliers(id: number): Promise<GroupRateMultiplierEntry[]> {
|
||||
const { data } = await apiClient.get<GroupRateMultiplierEntry[]>(
|
||||
`/admin/groups/${id}/rate-multipliers`
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Update group sort orders
|
||||
* @param updates - Array of { id, sort_order } objects
|
||||
@@ -178,6 +199,7 @@ export const groupsAPI = {
|
||||
toggleStatus,
|
||||
getStats,
|
||||
getGroupApiKeys,
|
||||
getGroupRateMultipliers,
|
||||
updateSortOrder
|
||||
}
|
||||
|
||||
|
||||
@@ -81,15 +81,15 @@
|
||||
v-if="activeModelRateLimits.length > 0"
|
||||
:class="[
|
||||
activeModelRateLimits.length <= 4
|
||||
? 'flex flex-col gap-1'
|
||||
? 'flex flex-col gap-0.5'
|
||||
: activeModelRateLimits.length <= 8
|
||||
? 'columns-2 gap-x-2'
|
||||
: 'columns-3 gap-x-2'
|
||||
]"
|
||||
>
|
||||
<div v-for="item in activeModelRateLimits" :key="item.model" class="group relative mb-1 break-inside-avoid">
|
||||
<div v-for="item in activeModelRateLimits" :key="item.model" class="group relative mb-0.5 break-inside-avoid">
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded bg-purple-100 px-1.5 py-0.5 text-xs font-medium text-purple-700 dark:bg-purple-900/30 dark:text-purple-400"
|
||||
class="inline-flex items-center gap-0.5 rounded bg-purple-100 px-1.5 py-px text-xs font-medium text-purple-700 dark:bg-purple-900/30 dark:text-purple-400"
|
||||
>
|
||||
<Icon name="exclamationTriangle" size="xs" :stroke-width="2" />
|
||||
{{ formatScopeName(item.model) }}
|
||||
|
||||
@@ -361,12 +361,13 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { Account, AccountUsageInfo, GeminiCredentials, WindowStats } from '@/types'
|
||||
import { buildOpenAIUsageRefreshKey } from '@/utils/accountUsageRefresh'
|
||||
import { resolveCodexUsageWindow } from '@/utils/codexUsage'
|
||||
import { enqueueUsageRequest } from '@/utils/usageLoadQueue'
|
||||
import UsageProgressBar from './UsageProgressBar.vue'
|
||||
import AccountQuotaInfo from './AccountQuotaInfo.vue'
|
||||
|
||||
@@ -376,6 +377,9 @@ const props = defineProps<{
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const unmounted = ref(false)
|
||||
onBeforeUnmount(() => { unmounted.value = true })
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const usageInfo = ref<AccountUsageInfo | null>(null)
|
||||
@@ -823,12 +827,30 @@ const loadUsage = async () => {
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
usageInfo.value = await adminAPI.accounts.getUsage(props.account.id)
|
||||
const fetchFn = () => adminAPI.accounts.getUsage(props.account.id)
|
||||
let result: AccountUsageInfo
|
||||
// Only throttle Anthropic OAuth/setup-token accounts to avoid upstream 429
|
||||
if (
|
||||
props.account.platform === 'anthropic' &&
|
||||
(props.account.type === 'oauth' || props.account.type === 'setup-token')
|
||||
) {
|
||||
result = await enqueueUsageRequest(
|
||||
props.account.platform,
|
||||
'claude_code',
|
||||
props.account.proxy_id,
|
||||
fetchFn
|
||||
)
|
||||
} else {
|
||||
result = await fetchFn()
|
||||
}
|
||||
if (!unmounted.value) usageInfo.value = result
|
||||
} catch (e: any) {
|
||||
error.value = t('common.error')
|
||||
console.error('Failed to load usage:', e)
|
||||
if (!unmounted.value) {
|
||||
error.value = t('common.error')
|
||||
console.error('Failed to load usage:', e)
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (!unmounted.value) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
width="wide"
|
||||
@close="handleClose"
|
||||
>
|
||||
<form id="bulk-edit-account-form" class="space-y-5" @submit.prevent="handleSubmit">
|
||||
<form id="bulk-edit-account-form" class="space-y-5" @submit.prevent="() => handleSubmit()">
|
||||
<!-- Info -->
|
||||
<div class="rounded-lg bg-blue-50 p-4 dark:bg-blue-900/20">
|
||||
<p class="text-sm text-blue-700 dark:text-blue-400">
|
||||
|
||||
@@ -1968,6 +1968,12 @@
|
||||
@input="form.load_factor = (form.load_factor && form.load_factor >= 1) ? form.load_factor : null" />
|
||||
<p class="input-hint">{{ t('admin.accounts.loadFactorHint') }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.loadFactor') }}</label>
|
||||
<input v-model.number="form.load_factor" type="number" min="1"
|
||||
class="input" :placeholder="String(form.concurrency || 1)" />
|
||||
<p class="input-hint">{{ t('admin.accounts.loadFactorHint') }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.priority') }}</label>
|
||||
<input
|
||||
|
||||
@@ -851,6 +851,12 @@
|
||||
@input="form.load_factor = (form.load_factor && form.load_factor >= 1) ? form.load_factor : null" />
|
||||
<p class="input-hint">{{ t('admin.accounts.loadFactorHint') }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.loadFactor') }}</label>
|
||||
<input v-model.number="form.load_factor" type="number" min="1"
|
||||
class="input" :placeholder="String(form.concurrency || 1)" />
|
||||
<p class="input-hint">{{ t('admin.accounts.loadFactorHint') }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.priority') }}</label>
|
||||
<input
|
||||
|
||||
@@ -14,6 +14,10 @@ vi.mock('@/api/admin', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/usageLoadQueue', () => ({
|
||||
enqueueUsageRequest: (_p: string, _t: string, _id: unknown, fn: () => Promise<unknown>) => fn()
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', async () => {
|
||||
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
|
||||
return {
|
||||
|
||||
@@ -55,7 +55,7 @@ import { Icon } from '@/components/icons'
|
||||
import type { Account } from '@/types'
|
||||
|
||||
const props = defineProps<{ show: boolean; account: Account | null; position: { top: number; left: number } | null }>()
|
||||
const emit = defineEmits(['close', 'test', 'stats', 'schedule', 'reauth', 'refresh-token', 'recover-state', 'reset-quota'])
|
||||
const emit = defineEmits(['close', 'test', 'stats', 'schedule', 'reauth', 'refresh-token', 'recover-state', 'reset-status', 'clear-rate-limit', 'reset-quota'])
|
||||
const { t } = useI18n()
|
||||
const isRateLimited = computed(() => {
|
||||
if (props.account?.rate_limit_reset_at && new Date(props.account.rate_limit_reset_at) > new Date()) {
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
<template>
|
||||
<BaseDialog :show="show" :title="t('admin.groups.rateMultipliersTitle')" width="normal" @close="$emit('close')">
|
||||
<div v-if="group" class="space-y-5">
|
||||
<!-- 分组信息 -->
|
||||
<div class="flex flex-wrap items-center gap-3 rounded-lg bg-gray-50 px-4 py-3 text-sm dark:bg-dark-700">
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ group.name }}</span>
|
||||
<span class="text-gray-400">|</span>
|
||||
<span class="text-gray-600 dark:text-gray-400">{{ t('admin.groups.platforms.' + group.platform) }}</span>
|
||||
<span class="text-gray-400">|</span>
|
||||
<span class="text-gray-600 dark:text-gray-400">
|
||||
{{ t('admin.groups.columns.rateMultiplier') }}: {{ group.rate_multiplier }}x
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 添加用户 -->
|
||||
<div class="rounded-lg border border-gray-200 p-4 dark:border-dark-600">
|
||||
<h4 class="mb-3 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.groups.addUserRate') }}
|
||||
</h4>
|
||||
<div class="flex items-end gap-3">
|
||||
<div class="relative flex-1">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
class="input w-full"
|
||||
:placeholder="t('admin.groups.searchUserPlaceholder')"
|
||||
@input="handleSearchUsers"
|
||||
@focus="showDropdown = true"
|
||||
/>
|
||||
<!-- 搜索结果下拉 -->
|
||||
<div
|
||||
v-if="showDropdown && searchResults.length > 0"
|
||||
class="absolute left-0 right-0 top-full z-10 mt-1 max-h-48 overflow-y-auto rounded-lg border border-gray-200 bg-white shadow-lg dark:border-dark-500 dark:bg-dark-700"
|
||||
>
|
||||
<button
|
||||
v-for="user in searchResults"
|
||||
:key="user.id"
|
||||
type="button"
|
||||
class="flex w-full items-center px-3 py-2 text-left text-sm hover:bg-gray-50 dark:hover:bg-dark-600"
|
||||
@click="selectUser(user)"
|
||||
>
|
||||
<span class="text-gray-900 dark:text-white">{{ user.email }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-28">
|
||||
<input
|
||||
v-model.number="newRate"
|
||||
type="number"
|
||||
step="0.001"
|
||||
min="0"
|
||||
class="hide-spinner input w-full"
|
||||
placeholder="1.0"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary shrink-0"
|
||||
:disabled="!selectedUser || !newRate || addingRate"
|
||||
@click="handleAddRate"
|
||||
>
|
||||
<Icon v-if="addingRate" name="refresh" size="sm" class="mr-1 animate-spin" />
|
||||
{{ t('common.add') }}
|
||||
</button>
|
||||
</div>
|
||||
</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>
|
||||
<h4 class="mb-3 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.groups.rateMultipliers') }} ({{ entries.length }})
|
||||
</h4>
|
||||
|
||||
<div v-if="entries.length === 0" class="py-8 text-center text-sm text-gray-400 dark:text-gray-500">
|
||||
{{ t('admin.groups.noRateMultipliers') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-2">
|
||||
<div
|
||||
v-for="entry in entries"
|
||||
:key="entry.user_id"
|
||||
class="flex items-center gap-3 rounded-lg border border-gray-200 px-4 py-3 dark:border-dark-600"
|
||||
>
|
||||
<span class="flex-1 text-sm text-gray-900 dark:text-white">{{ entry.user_email }}</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.001"
|
||||
min="0"
|
||||
:value="entry.rate_multiplier"
|
||||
class="hide-spinner w-24 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium transition-colors focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20 dark:border-dark-500 dark:bg-dark-700 dark:focus:border-primary-500"
|
||||
@blur="handleUpdateRate(entry, ($event.target as HTMLInputElement).value)"
|
||||
@keydown.enter="($event.target as HTMLInputElement).blur()"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg p-1.5 text-gray-400 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
|
||||
@click="handleDeleteRate(entry)"
|
||||
>
|
||||
<Icon name="trash" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { GroupRateMultiplierEntry } from '@/api/admin/groups'
|
||||
import type { AdminGroup, AdminUser } from '@/types'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
group: AdminGroup | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
success: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const entries = ref<GroupRateMultiplierEntry[]>([])
|
||||
const searchQuery = ref('')
|
||||
const searchResults = ref<AdminUser[]>([])
|
||||
const showDropdown = ref(false)
|
||||
const selectedUser = ref<AdminUser | null>(null)
|
||||
const newRate = ref<number | null>(null)
|
||||
const addingRate = ref(false)
|
||||
|
||||
let searchTimeout: ReturnType<typeof setTimeout>
|
||||
|
||||
const loadEntries = async () => {
|
||||
if (!props.group) return
|
||||
loading.value = true
|
||||
try {
|
||||
entries.value = await adminAPI.groups.getGroupRateMultipliers(props.group.id)
|
||||
} catch (error) {
|
||||
appStore.showError(t('admin.groups.failedToLoad'))
|
||||
console.error('Error loading group rate multipliers:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.show, (val) => {
|
||||
if (val && props.group) {
|
||||
loadEntries()
|
||||
searchQuery.value = ''
|
||||
searchResults.value = []
|
||||
selectedUser.value = null
|
||||
newRate.value = null
|
||||
}
|
||||
})
|
||||
|
||||
const handleSearchUsers = () => {
|
||||
clearTimeout(searchTimeout)
|
||||
selectedUser.value = null
|
||||
if (!searchQuery.value.trim()) {
|
||||
searchResults.value = []
|
||||
showDropdown.value = false
|
||||
return
|
||||
}
|
||||
searchTimeout = setTimeout(async () => {
|
||||
try {
|
||||
const res = await adminAPI.users.list(1, 10, { search: searchQuery.value.trim() })
|
||||
searchResults.value = res.items
|
||||
showDropdown.value = true
|
||||
} catch {
|
||||
searchResults.value = []
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
|
||||
const selectUser = (user: AdminUser) => {
|
||||
selectedUser.value = user
|
||||
searchQuery.value = user.email
|
||||
showDropdown.value = false
|
||||
searchResults.value = []
|
||||
}
|
||||
|
||||
const handleAddRate = async () => {
|
||||
if (!selectedUser.value || !newRate.value || !props.group) return
|
||||
addingRate.value = true
|
||||
try {
|
||||
await adminAPI.users.update(selectedUser.value.id, {
|
||||
group_rates: { [props.group.id]: newRate.value }
|
||||
})
|
||||
appStore.showSuccess(t('admin.groups.rateAdded'))
|
||||
searchQuery.value = ''
|
||||
selectedUser.value = null
|
||||
newRate.value = null
|
||||
await loadEntries()
|
||||
emit('success')
|
||||
} catch (error) {
|
||||
appStore.showError(t('admin.groups.failedToSave'))
|
||||
console.error('Error adding rate multiplier:', error)
|
||||
} finally {
|
||||
addingRate.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateRate = async (entry: GroupRateMultiplierEntry, value: string) => {
|
||||
if (!props.group) return
|
||||
const numValue = parseFloat(value)
|
||||
if (isNaN(numValue) || numValue === entry.rate_multiplier) return
|
||||
try {
|
||||
await adminAPI.users.update(entry.user_id, {
|
||||
group_rates: { [props.group.id]: numValue }
|
||||
})
|
||||
appStore.showSuccess(t('admin.groups.rateUpdated'))
|
||||
await loadEntries()
|
||||
emit('success')
|
||||
} catch (error) {
|
||||
appStore.showError(t('admin.groups.failedToSave'))
|
||||
console.error('Error updating rate multiplier:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteRate = async (entry: GroupRateMultiplierEntry) => {
|
||||
if (!props.group) return
|
||||
try {
|
||||
await adminAPI.users.update(entry.user_id, {
|
||||
group_rates: { [props.group.id]: null }
|
||||
})
|
||||
appStore.showSuccess(t('admin.groups.rateDeleted'))
|
||||
await loadEntries()
|
||||
emit('success')
|
||||
} catch (error) {
|
||||
appStore.showError(t('admin.groups.failedToSave'))
|
||||
console.error('Error deleting rate multiplier:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 点击外部关闭下拉
|
||||
const handleClickOutside = () => {
|
||||
showDropdown.value = false
|
||||
}
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.hide-spinner::-webkit-outer-spin-button,
|
||||
.hide-spinner::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
.hide-spinner {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
</style>
|
||||
104
frontend/src/components/common/WechatServiceButton.vue
Normal file
104
frontend/src/components/common/WechatServiceButton.vue
Normal file
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<!-- 悬浮按钮 - 使用主题色 -->
|
||||
<button
|
||||
@click="showModal = true"
|
||||
class="fixed bottom-6 right-6 z-50 flex items-center gap-2 rounded-full bg-gradient-to-r from-primary-500 to-primary-600 px-4 py-3 text-white shadow-lg shadow-primary-500/25 transition-all hover:from-primary-600 hover:to-primary-700 hover:shadow-xl hover:shadow-primary-500/30"
|
||||
>
|
||||
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 01.213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.328.328 0 00.186-.059l2.114-1.225a.87.87 0 01.415-.106.807.807 0 01.213.026 10.07 10.07 0 002.696.37c.262 0 .52-.011.776-.028a5.91 5.91 0 01-.193-1.479c0-3.644 3.374-6.6 7.536-6.6.262 0 .52.011.776.028-.628-3.513-4.27-6.472-8.885-6.472zM5.785 5.97a1.1 1.1 0 110 2.2 1.1 1.1 0 010-2.2zm5.813 0a1.1 1.1 0 110 2.2 1.1 1.1 0 010-2.2zm5.192 2.642c-3.703 0-6.71 2.567-6.71 5.73 0 3.163 3.007 5.73 6.71 5.73a7.9 7.9 0 002.126-.288.644.644 0 01.17-.022.69.69 0 01.329.085l1.672.97a.262.262 0 00.147.046c.128 0 .23-.104.23-.233a.403.403 0 00-.038-.168l-.309-1.17a.468.468 0 01.168-.527c1.449-1.065 2.374-2.643 2.374-4.423 0-3.163-3.007-5.73-6.71-5.73h-.159zm-2.434 3.34a.88.88 0 110 1.76.88.88 0 010-1.76zm4.868 0a.88.88 0 110 1.76.88.88 0 010-1.76z"/>
|
||||
</svg>
|
||||
<span class="text-sm font-medium">客服</span>
|
||||
</button>
|
||||
|
||||
<!-- 弹窗 -->
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="showModal"
|
||||
class="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm"
|
||||
@click.self="showModal = false"
|
||||
>
|
||||
<Transition name="scale">
|
||||
<div
|
||||
v-if="showModal"
|
||||
class="relative w-full max-w-sm rounded-2xl bg-white p-6 shadow-2xl dark:bg-dark-700"
|
||||
>
|
||||
<!-- 关闭按钮 -->
|
||||
<button
|
||||
@click="showModal = false"
|
||||
class="absolute right-4 top-4 text-gray-400 transition-colors hover:text-gray-600 dark:text-dark-400 dark:hover:text-dark-200"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- 标题 -->
|
||||
<div class="mb-4 flex items-center gap-3">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-gradient-to-br from-primary-500 to-primary-600">
|
||||
<svg class="h-6 w-6 text-white" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 01.213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.328.328 0 00.186-.059l2.114-1.225a.87.87 0 01.415-.106.807.807 0 01.213.026 10.07 10.07 0 002.696.37c.262 0 .52-.011.776-.028a5.91 5.91 0 01-.193-1.479c0-3.644 3.374-6.6 7.536-6.6.262 0 .52.011.776.028-.628-3.513-4.27-6.472-8.885-6.472zM5.785 5.97a1.1 1.1 0 110 2.2 1.1 1.1 0 010-2.2zm5.813 0a1.1 1.1 0 110 2.2 1.1 1.1 0 010-2.2zm5.192 2.642c-3.703 0-6.71 2.567-6.71 5.73 0 3.163 3.007 5.73 6.71 5.73a7.9 7.9 0 002.126-.288.644.644 0 01.17-.022.69.69 0 01.329.085l1.672.97a.262.262 0 00.147.046c.128 0 .23-.104.23-.233a.403.403 0 00-.038-.168l-.309-1.17a.468.468 0 01.168-.527c1.449-1.065 2.374-2.643 2.374-4.423 0-3.163-3.007-5.73-6.71-5.73h-.159zm-2.434 3.34a.88.88 0 110 1.76.88.88 0 010-1.76zm4.868 0a.88.88 0 110 1.76.88.88 0 010-1.76z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">联系客服</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-dark-400">扫码添加好友</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 二维码卡片 -->
|
||||
<div class="mb-4 overflow-hidden rounded-xl border border-primary-100 bg-gradient-to-br from-primary-50 to-white p-3 dark:border-primary-800/30 dark:from-primary-900/10 dark:to-dark-800">
|
||||
<img
|
||||
src="/wechat-qr.jpg"
|
||||
alt="微信二维码"
|
||||
class="w-full rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 提示文字 -->
|
||||
<div class="text-center">
|
||||
<p class="mb-2 text-sm font-medium text-primary-600 dark:text-primary-400">
|
||||
微信扫码添加客服
|
||||
</p>
|
||||
<p class="flex items-center justify-center gap-1 text-xs text-gray-500 dark:text-dark-400">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
工作时间:周一至周五 9:00-18:00
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const showModal = ref(false)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.scale-enter-active,
|
||||
.scale-leave-active {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.scale-enter-from,
|
||||
.scale-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
</style>
|
||||
@@ -121,23 +121,6 @@
|
||||
<Icon name="key" size="sm" />
|
||||
{{ t('nav.apiKeys') }}
|
||||
</router-link>
|
||||
|
||||
<a
|
||||
href="https://github.com/Wei-Shaw/sub2api"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
@click="closeDropdown"
|
||||
class="dropdown-item"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M12 2C6.477 2 2 6.477 2 12c0 4.42 2.865 8.17 6.839 9.49.5.092.682-.217.682-.482 0-.237-.008-.866-.013-1.7-2.782.604-3.369-1.34-3.369-1.34-.454-1.156-1.11-1.464-1.11-1.464-.908-.62.069-.608.069-.608 1.003.07 1.531 1.03 1.531 1.03.892 1.529 2.341 1.087 2.91.831.092-.646.35-1.086.636-1.336-2.22-.253-4.555-1.11-4.555-4.943 0-1.091.39-1.984 1.029-2.683-.103-.253-.446-1.27.098-2.647 0 0 .84-.269 2.75 1.025A9.578 9.578 0 0112 6.836c.85.004 1.705.114 2.504.336 1.909-1.294 2.747-1.025 2.747-1.025.546 1.377.203 2.394.1 2.647.64.699 1.028 1.592 1.028 2.683 0 3.842-2.339 4.687-4.566 4.935.359.309.678.919.678 1.852 0 1.336-.012 2.415-.012 2.743 0 .267.18.578.688.48C19.138 20.167 22 16.418 22 12c0-5.523-4.477-10-10-10z"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('nav.github') }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Contact Support (only show if configured) -->
|
||||
|
||||
@@ -1411,6 +1411,14 @@ export default {
|
||||
failedToUpdate: 'Failed to update group',
|
||||
failedToDelete: 'Failed to delete group',
|
||||
nameRequired: 'Please enter group name',
|
||||
rateMultipliers: 'Rate Multipliers',
|
||||
rateMultipliersTitle: 'Group Rate Multipliers',
|
||||
addUserRate: 'Add User Rate Multiplier',
|
||||
searchUserPlaceholder: 'Search user email...',
|
||||
noRateMultipliers: 'No user rate multipliers configured',
|
||||
rateUpdated: 'Rate multiplier updated',
|
||||
rateDeleted: 'Rate multiplier removed',
|
||||
rateAdded: 'Rate multiplier added',
|
||||
platforms: {
|
||||
all: 'All Platforms',
|
||||
anthropic: 'Anthropic',
|
||||
@@ -1508,6 +1516,14 @@ export default {
|
||||
enabled: 'Enabled',
|
||||
disabled: 'Disabled'
|
||||
},
|
||||
claudeMaxSimulation: {
|
||||
title: 'Claude Max Usage Simulation',
|
||||
tooltip:
|
||||
'When enabled, for Claude models without upstream cache-write usage, the system deterministically maps tokens to a small input plus 1h cache creation while keeping total tokens unchanged.',
|
||||
enabled: 'Enabled (simulate 1h cache)',
|
||||
disabled: 'Disabled',
|
||||
hint: 'Only token categories in usage billing logs are adjusted. No per-request mapping state is persisted.'
|
||||
},
|
||||
supportedScopes: {
|
||||
title: 'Supported Model Families',
|
||||
tooltip: 'Select the model families this group supports. Unchecked families will not be routed to this group.',
|
||||
|
||||
@@ -1510,6 +1510,14 @@ export default {
|
||||
failedToCreate: '创建分组失败',
|
||||
failedToUpdate: '更新分组失败',
|
||||
nameRequired: '请输入分组名称',
|
||||
rateMultipliers: '专属倍率',
|
||||
rateMultipliersTitle: '分组专属倍率管理',
|
||||
addUserRate: '添加用户专属倍率',
|
||||
searchUserPlaceholder: '搜索用户邮箱...',
|
||||
noRateMultipliers: '暂无用户设置了专属倍率',
|
||||
rateUpdated: '专属倍率已更新',
|
||||
rateDeleted: '专属倍率已删除',
|
||||
rateAdded: '专属倍率已添加',
|
||||
subscription: {
|
||||
title: '订阅设置',
|
||||
type: '计费类型',
|
||||
|
||||
@@ -402,6 +402,8 @@ export interface AdminGroup extends Group {
|
||||
|
||||
// MCP XML 协议注入(仅 antigravity 平台使用)
|
||||
mcp_xml_inject: boolean
|
||||
// Claude usage 模拟开关(仅 anthropic 平台使用)
|
||||
simulate_claude_max_enabled: boolean
|
||||
|
||||
// 支持的模型系列(仅 antigravity 平台使用)
|
||||
supported_model_scopes?: string[]
|
||||
@@ -496,6 +498,7 @@ export interface CreateGroupRequest {
|
||||
fallback_group_id?: number | null
|
||||
fallback_group_id_on_invalid_request?: number | null
|
||||
mcp_xml_inject?: boolean
|
||||
simulate_claude_max_enabled?: boolean
|
||||
supported_model_scopes?: string[]
|
||||
// 从指定分组复制账号
|
||||
copy_accounts_from_group_ids?: number[]
|
||||
@@ -524,6 +527,7 @@ export interface UpdateGroupRequest {
|
||||
fallback_group_id?: number | null
|
||||
fallback_group_id_on_invalid_request?: number | null
|
||||
mcp_xml_inject?: boolean
|
||||
simulate_claude_max_enabled?: boolean
|
||||
supported_model_scopes?: string[]
|
||||
copy_accounts_from_group_ids?: number[]
|
||||
}
|
||||
|
||||
87
frontend/src/utils/__tests__/usageLoadQueue.spec.ts
Normal file
87
frontend/src/utils/__tests__/usageLoadQueue.spec.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { enqueueUsageRequest } from '../usageLoadQueue'
|
||||
|
||||
function delay(ms: number) {
|
||||
return new Promise((r) => setTimeout(r, ms))
|
||||
}
|
||||
|
||||
describe('usageLoadQueue', () => {
|
||||
it('同组请求串行执行,间隔 >= 1s', async () => {
|
||||
const timestamps: number[] = []
|
||||
const makeFn = () => async () => {
|
||||
timestamps.push(Date.now())
|
||||
return 'ok'
|
||||
}
|
||||
|
||||
const p1 = enqueueUsageRequest('anthropic', 'oauth', 1, makeFn())
|
||||
const p2 = enqueueUsageRequest('anthropic', 'oauth', 1, makeFn())
|
||||
const p3 = enqueueUsageRequest('anthropic', 'oauth', 1, makeFn())
|
||||
|
||||
await Promise.all([p1, p2, p3])
|
||||
|
||||
expect(timestamps).toHaveLength(3)
|
||||
// 随机 1-1.5s 间隔,至少 950ms(留一点误差)
|
||||
expect(timestamps[1] - timestamps[0]).toBeGreaterThanOrEqual(950)
|
||||
expect(timestamps[1] - timestamps[0]).toBeLessThan(1600)
|
||||
expect(timestamps[2] - timestamps[1]).toBeGreaterThanOrEqual(950)
|
||||
expect(timestamps[2] - timestamps[1]).toBeLessThan(1600)
|
||||
})
|
||||
|
||||
it('不同组请求并行执行', async () => {
|
||||
const timestamps: Record<string, number> = {}
|
||||
const makeTracked = (key: string) => async () => {
|
||||
timestamps[key] = Date.now()
|
||||
return key
|
||||
}
|
||||
|
||||
const p1 = enqueueUsageRequest('anthropic', 'oauth', 1, makeTracked('group1'))
|
||||
const p2 = enqueueUsageRequest('anthropic', 'oauth', 2, makeTracked('group2'))
|
||||
const p3 = enqueueUsageRequest('gemini', 'oauth', 1, makeTracked('group3'))
|
||||
|
||||
await Promise.all([p1, p2, p3])
|
||||
|
||||
// 不同组应几乎同时启动(差距 < 50ms)
|
||||
const values = Object.values(timestamps)
|
||||
const spread = Math.max(...values) - Math.min(...values)
|
||||
expect(spread).toBeLessThan(50)
|
||||
})
|
||||
|
||||
it('请求失败时 reject,后续任务继续执行', async () => {
|
||||
const results: string[] = []
|
||||
|
||||
const p1 = enqueueUsageRequest('anthropic', 'oauth', 99, async () => {
|
||||
throw new Error('fail')
|
||||
})
|
||||
const p2 = enqueueUsageRequest('anthropic', 'oauth', 99, async () => {
|
||||
results.push('second')
|
||||
return 'ok'
|
||||
})
|
||||
|
||||
await expect(p1).rejects.toThrow('fail')
|
||||
await p2
|
||||
expect(results).toEqual(['second'])
|
||||
})
|
||||
|
||||
it('返回值正确透传', async () => {
|
||||
const result = await enqueueUsageRequest('test', 'oauth', null, async () => {
|
||||
return { usage: 42 }
|
||||
})
|
||||
expect(result).toEqual({ usage: 42 })
|
||||
})
|
||||
|
||||
it('proxy_id 为 null 的账号归为同一组', async () => {
|
||||
const order: number[] = []
|
||||
const makeFn = (n: number) => async () => {
|
||||
order.push(n)
|
||||
return n
|
||||
}
|
||||
|
||||
const p1 = enqueueUsageRequest('anthropic', 'oauth', null, makeFn(1))
|
||||
const p2 = enqueueUsageRequest('anthropic', 'oauth', null, makeFn(2))
|
||||
|
||||
await Promise.all([p1, p2])
|
||||
|
||||
// 同组串行,按入队顺序执行
|
||||
expect(order).toEqual([1, 2])
|
||||
})
|
||||
})
|
||||
72
frontend/src/utils/usageLoadQueue.ts
Normal file
72
frontend/src/utils/usageLoadQueue.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Usage request queue that throttles API calls by group.
|
||||
*
|
||||
* Accounts sharing the same upstream (platform + type + proxy) are placed
|
||||
* into a single serial queue with a configurable delay between requests,
|
||||
* preventing upstream 429 rate-limit errors.
|
||||
*
|
||||
* Different groups run in parallel since they hit different upstreams.
|
||||
*/
|
||||
|
||||
const GROUP_DELAY_MIN_MS = 1000
|
||||
const GROUP_DELAY_MAX_MS = 1500
|
||||
|
||||
type Task<T> = {
|
||||
fn: () => Promise<T>
|
||||
resolve: (value: T) => void
|
||||
reject: (reason: unknown) => void
|
||||
}
|
||||
|
||||
const queues = new Map<string, Task<unknown>[]>()
|
||||
const running = new Set<string>()
|
||||
|
||||
function buildGroupKey(platform: string, type: string, proxyId: number | null): string {
|
||||
return `${platform}:${type}:${proxyId ?? 'direct'}`
|
||||
}
|
||||
|
||||
async function drain(groupKey: string) {
|
||||
if (running.has(groupKey)) return
|
||||
running.add(groupKey)
|
||||
|
||||
const queue = queues.get(groupKey)
|
||||
while (queue && queue.length > 0) {
|
||||
const task = queue.shift()!
|
||||
try {
|
||||
const result = await task.fn()
|
||||
task.resolve(result)
|
||||
} catch (err) {
|
||||
task.reject(err)
|
||||
}
|
||||
// Wait a random 1–1.5s before next request in the same group
|
||||
if (queue.length > 0) {
|
||||
const jitter = GROUP_DELAY_MIN_MS + Math.random() * (GROUP_DELAY_MAX_MS - GROUP_DELAY_MIN_MS)
|
||||
await new Promise((r) => setTimeout(r, jitter))
|
||||
}
|
||||
}
|
||||
|
||||
running.delete(groupKey)
|
||||
queues.delete(groupKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue a usage fetch call. Returns a promise that resolves when the
|
||||
* request completes (after waiting its turn in the group queue).
|
||||
*/
|
||||
export function enqueueUsageRequest<T>(
|
||||
platform: string,
|
||||
type: string,
|
||||
proxyId: number | null,
|
||||
fn: () => Promise<T>
|
||||
): Promise<T> {
|
||||
const key = buildGroupKey(platform, type, proxyId)
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
let queue = queues.get(key)
|
||||
if (!queue) {
|
||||
queue = []
|
||||
queues.set(key, queue)
|
||||
}
|
||||
queue.push({ fn, resolve, reject } as Task<unknown>)
|
||||
drain(key)
|
||||
})
|
||||
}
|
||||
@@ -122,8 +122,11 @@
|
||||
>
|
||||
{{ siteName }}
|
||||
</h1>
|
||||
<p class="mb-8 text-lg text-gray-600 dark:text-dark-300 md:text-xl">
|
||||
{{ siteSubtitle }}
|
||||
<p class="mb-3 text-xl font-semibold text-primary-600 dark:text-primary-400 md:text-2xl">
|
||||
{{ t('home.heroSubtitle') }}
|
||||
</p>
|
||||
<p class="mb-8 text-base text-gray-600 dark:text-dark-300 md:text-lg">
|
||||
{{ t('home.heroDescription') }}
|
||||
</p>
|
||||
|
||||
<!-- CTA Button -->
|
||||
@@ -177,7 +180,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Feature Tags - Centered -->
|
||||
<div class="mb-12 flex flex-wrap items-center justify-center gap-4 md:gap-6">
|
||||
<div class="mb-16 flex flex-wrap items-center justify-center gap-4 md:gap-6">
|
||||
<div
|
||||
class="inline-flex items-center gap-2.5 rounded-full border border-gray-200/50 bg-white/80 px-5 py-2.5 shadow-sm backdrop-blur-sm dark:border-dark-700/50 dark:bg-dark-800/80"
|
||||
>
|
||||
@@ -204,6 +207,63 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pain Points Section -->
|
||||
<div class="mb-16">
|
||||
<h2 class="mb-8 text-center text-2xl font-bold text-gray-900 dark:text-white md:text-3xl">
|
||||
{{ t('home.painPoints.title') }}
|
||||
</h2>
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<!-- Pain Point 1: Expensive -->
|
||||
<div class="rounded-xl border border-red-200/50 bg-red-50/50 p-5 dark:border-red-900/30 dark:bg-red-950/20">
|
||||
<div class="mb-3 flex h-10 w-10 items-center justify-center rounded-lg bg-red-100 dark:bg-red-900/30">
|
||||
<svg class="h-5 w-5 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="mb-1.5 font-semibold text-gray-900 dark:text-white">{{ t('home.painPoints.items.expensive.title') }}</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-dark-400">{{ t('home.painPoints.items.expensive.desc') }}</p>
|
||||
</div>
|
||||
<!-- Pain Point 2: Complex -->
|
||||
<div class="rounded-xl border border-orange-200/50 bg-orange-50/50 p-5 dark:border-orange-900/30 dark:bg-orange-950/20">
|
||||
<div class="mb-3 flex h-10 w-10 items-center justify-center rounded-lg bg-orange-100 dark:bg-orange-900/30">
|
||||
<svg class="h-5 w-5 text-orange-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="mb-1.5 font-semibold text-gray-900 dark:text-white">{{ t('home.painPoints.items.complex.title') }}</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-dark-400">{{ t('home.painPoints.items.complex.desc') }}</p>
|
||||
</div>
|
||||
<!-- Pain Point 3: Unstable -->
|
||||
<div class="rounded-xl border border-yellow-200/50 bg-yellow-50/50 p-5 dark:border-yellow-900/30 dark:bg-yellow-950/20">
|
||||
<div class="mb-3 flex h-10 w-10 items-center justify-center rounded-lg bg-yellow-100 dark:bg-yellow-900/30">
|
||||
<svg class="h-5 w-5 text-yellow-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="mb-1.5 font-semibold text-gray-900 dark:text-white">{{ t('home.painPoints.items.unstable.title') }}</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-dark-400">{{ t('home.painPoints.items.unstable.desc') }}</p>
|
||||
</div>
|
||||
<!-- Pain Point 4: No Control -->
|
||||
<div class="rounded-xl border border-gray-200/50 bg-gray-50/50 p-5 dark:border-dark-700/50 dark:bg-dark-800/50">
|
||||
<div class="mb-3 flex h-10 w-10 items-center justify-center rounded-lg bg-gray-100 dark:bg-dark-700">
|
||||
<svg class="h-5 w-5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="mb-1.5 font-semibold text-gray-900 dark:text-white">{{ t('home.painPoints.items.noControl.title') }}</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-dark-400">{{ t('home.painPoints.items.noControl.desc') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Solutions Section Title -->
|
||||
<div class="mb-8 text-center">
|
||||
<h2 class="mb-2 text-2xl font-bold text-gray-900 dark:text-white md:text-3xl">
|
||||
{{ t('home.solutions.title') }}
|
||||
</h2>
|
||||
<p class="text-gray-600 dark:text-dark-400">{{ t('home.solutions.subtitle') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Features Grid -->
|
||||
<div class="mb-12 grid gap-6 md:grid-cols-3">
|
||||
<!-- Feature 1: Unified Gateway -->
|
||||
@@ -369,6 +429,77 @@
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Comparison Table -->
|
||||
<div class="mb-16">
|
||||
<h2 class="mb-8 text-center text-2xl font-bold text-gray-900 dark:text-white md:text-3xl">
|
||||
{{ t('home.comparison.title') }}
|
||||
</h2>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full rounded-xl border border-gray-200/50 bg-white/60 backdrop-blur-sm dark:border-dark-700/50 dark:bg-dark-800/60">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200/50 dark:border-dark-700/50">
|
||||
<th class="px-6 py-4 text-left text-sm font-semibold text-gray-900 dark:text-white">{{ t('home.comparison.headers.feature') }}</th>
|
||||
<th class="px-6 py-4 text-center text-sm font-semibold text-gray-500 dark:text-dark-400">{{ t('home.comparison.headers.official') }}</th>
|
||||
<th class="px-6 py-4 text-center text-sm font-semibold text-primary-600 dark:text-primary-400">{{ t('home.comparison.headers.us') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200/50 dark:divide-dark-700/50">
|
||||
<tr>
|
||||
<td class="px-6 py-4 text-sm font-medium text-gray-900 dark:text-white">{{ t('home.comparison.items.pricing.feature') }}</td>
|
||||
<td class="px-6 py-4 text-center text-sm text-gray-500 dark:text-dark-400">{{ t('home.comparison.items.pricing.official') }}</td>
|
||||
<td class="px-6 py-4 text-center text-sm font-medium text-primary-600 dark:text-primary-400">{{ t('home.comparison.items.pricing.us') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="px-6 py-4 text-sm font-medium text-gray-900 dark:text-white">{{ t('home.comparison.items.models.feature') }}</td>
|
||||
<td class="px-6 py-4 text-center text-sm text-gray-500 dark:text-dark-400">{{ t('home.comparison.items.models.official') }}</td>
|
||||
<td class="px-6 py-4 text-center text-sm font-medium text-primary-600 dark:text-primary-400">{{ t('home.comparison.items.models.us') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="px-6 py-4 text-sm font-medium text-gray-900 dark:text-white">{{ t('home.comparison.items.management.feature') }}</td>
|
||||
<td class="px-6 py-4 text-center text-sm text-gray-500 dark:text-dark-400">{{ t('home.comparison.items.management.official') }}</td>
|
||||
<td class="px-6 py-4 text-center text-sm font-medium text-primary-600 dark:text-primary-400">{{ t('home.comparison.items.management.us') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="px-6 py-4 text-sm font-medium text-gray-900 dark:text-white">{{ t('home.comparison.items.stability.feature') }}</td>
|
||||
<td class="px-6 py-4 text-center text-sm text-gray-500 dark:text-dark-400">{{ t('home.comparison.items.stability.official') }}</td>
|
||||
<td class="px-6 py-4 text-center text-sm font-medium text-primary-600 dark:text-primary-400">{{ t('home.comparison.items.stability.us') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="px-6 py-4 text-sm font-medium text-gray-900 dark:text-white">{{ t('home.comparison.items.control.feature') }}</td>
|
||||
<td class="px-6 py-4 text-center text-sm text-gray-500 dark:text-dark-400">{{ t('home.comparison.items.control.official') }}</td>
|
||||
<td class="px-6 py-4 text-center text-sm font-medium text-primary-600 dark:text-primary-400">{{ t('home.comparison.items.control.us') }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CTA Section -->
|
||||
<div class="mb-8 rounded-2xl bg-gradient-to-r from-primary-500 to-primary-600 p-8 text-center shadow-xl shadow-primary-500/20 md:p-12">
|
||||
<h2 class="mb-3 text-2xl font-bold text-white md:text-3xl">
|
||||
{{ t('home.cta.title') }}
|
||||
</h2>
|
||||
<p class="mb-6 text-primary-100">
|
||||
{{ t('home.cta.description') }}
|
||||
</p>
|
||||
<router-link
|
||||
v-if="!isAuthenticated"
|
||||
to="/register"
|
||||
class="inline-flex items-center gap-2 rounded-full bg-white px-8 py-3 font-semibold text-primary-600 shadow-lg transition-all hover:bg-gray-50 hover:shadow-xl"
|
||||
>
|
||||
{{ t('home.cta.button') }}
|
||||
<Icon name="arrowRight" size="md" :stroke-width="2" />
|
||||
</router-link>
|
||||
<router-link
|
||||
v-else
|
||||
:to="dashboardPath"
|
||||
class="inline-flex items-center gap-2 rounded-full bg-white px-8 py-3 font-semibold text-primary-600 shadow-lg transition-all hover:bg-gray-50 hover:shadow-xl"
|
||||
>
|
||||
{{ t('home.goToDashboard') }}
|
||||
<Icon name="arrowRight" size="md" :stroke-width="2" />
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -380,27 +511,20 @@
|
||||
<p class="text-sm text-gray-500 dark:text-dark-400">
|
||||
© {{ currentYear }} {{ siteName }}. {{ t('home.footer.allRightsReserved') }}
|
||||
</p>
|
||||
<div class="flex items-center gap-4">
|
||||
<a
|
||||
v-if="docUrl"
|
||||
:href="docUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-sm text-gray-500 transition-colors hover:text-gray-700 dark:text-dark-400 dark:hover:text-white"
|
||||
>
|
||||
{{ t('home.docs') }}
|
||||
</a>
|
||||
<a
|
||||
:href="githubUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-sm text-gray-500 transition-colors hover:text-gray-700 dark:text-dark-400 dark:hover:text-white"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
<a
|
||||
v-if="docUrl"
|
||||
:href="docUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-sm text-gray-500 transition-colors hover:text-gray-700 dark:text-dark-400 dark:hover:text-white"
|
||||
>
|
||||
{{ t('home.docs') }}
|
||||
</a>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- 微信客服悬浮按钮 -->
|
||||
<WechatServiceButton />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -410,6 +534,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useAuthStore, useAppStore } from '@/stores'
|
||||
import LocaleSwitcher from '@/components/common/LocaleSwitcher.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import WechatServiceButton from '@/components/common/WechatServiceButton.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -419,7 +544,6 @@ const appStore = useAppStore()
|
||||
// Site settings - directly from appStore (already initialized from injected config)
|
||||
const siteName = computed(() => appStore.cachedPublicSettings?.site_name || appStore.siteName || 'Sub2API')
|
||||
const siteLogo = computed(() => appStore.cachedPublicSettings?.site_logo || appStore.siteLogo || '')
|
||||
const siteSubtitle = computed(() => appStore.cachedPublicSettings?.site_subtitle || 'AI API Gateway Platform')
|
||||
const docUrl = computed(() => appStore.cachedPublicSettings?.doc_url || appStore.docUrl || '')
|
||||
const homeContent = computed(() => appStore.cachedPublicSettings?.home_content || '')
|
||||
|
||||
@@ -432,9 +556,6 @@ const isHomeContentUrl = computed(() => {
|
||||
// Theme
|
||||
const isDark = ref(document.documentElement.classList.contains('dark'))
|
||||
|
||||
// GitHub URL
|
||||
const githubUrl = 'https://github.com/Wei-Shaw/sub2api'
|
||||
|
||||
// Auth state
|
||||
const isAuthenticated = computed(() => authStore.isAuthenticated)
|
||||
const isAdmin = computed(() => authStore.isAdmin)
|
||||
|
||||
@@ -263,7 +263,7 @@
|
||||
<AccountTestModal :show="showTest" :account="testingAcc" @close="closeTestModal" />
|
||||
<AccountStatsModal :show="showStats" :account="statsAcc" @close="closeStatsModal" />
|
||||
<ScheduledTestsPanel :show="showSchedulePanel" :account-id="scheduleAcc?.id ?? null" :model-options="scheduleModelOptions" @close="closeSchedulePanel" />
|
||||
<AccountActionMenu :show="menu.show" :account="menu.acc" :position="menu.pos" @close="menu.show = false" @test="handleTest" @stats="handleViewStats" @schedule="handleSchedule" @reauth="handleReAuth" @refresh-token="handleRefresh" @recover-state="handleRecoverState" @reset-quota="handleResetQuota" />
|
||||
<AccountActionMenu :show="menu.show" :account="menu.acc" :position="menu.pos" @close="menu.show = false" @test="handleTest" @stats="handleViewStats" @schedule="handleSchedule" @reauth="handleReAuth" @refresh-token="handleRefresh" @recover-state="handleRecoverState" @reset-status="handleResetStatus" @clear-rate-limit="handleClearRateLimit" @reset-quota="handleResetQuota" />
|
||||
<SyncFromCrsModal :show="showSync" @close="showSync = false" @synced="reload" />
|
||||
<ImportDataModal :show="showImportData" @close="showImportData = false" @imported="handleDataImported" />
|
||||
<BulkEditAccountModal :show="showBulkEdit" :account-ids="selIds" :selected-platforms="selPlatforms" :selected-types="selTypes" :proxies="proxies" :groups="groups" @close="showBulkEdit = false" @updated="handleBulkUpdated" />
|
||||
@@ -1176,6 +1176,16 @@ const handleResetQuota = async (a: Account) => {
|
||||
console.error('Failed to reset quota:', error)
|
||||
}
|
||||
}
|
||||
const handleResetQuota = async (a: Account) => {
|
||||
try {
|
||||
const updated = await adminAPI.accounts.resetAccountQuota(a.id)
|
||||
patchAccountInList(updated)
|
||||
enterAutoRefreshSilentWindow()
|
||||
appStore.showSuccess(t('common.success'))
|
||||
} catch (error) {
|
||||
console.error('Failed to reset quota:', error)
|
||||
}
|
||||
}
|
||||
const handleDelete = (a: Account) => { deletingAcc.value = a; showDeleteDialog.value = true }
|
||||
const confirmDelete = async () => { if(!deletingAcc.value) return; try { await adminAPI.accounts.delete(deletingAcc.value.id); showDeleteDialog.value = false; deletingAcc.value = null; reload() } catch (error) { console.error('Failed to delete account:', error) } }
|
||||
const handleToggleSchedulable = async (a: Account) => {
|
||||
|
||||
@@ -181,6 +181,13 @@
|
||||
<Icon name="edit" size="sm" />
|
||||
<span class="text-xs">{{ t('common.edit') }}</span>
|
||||
</button>
|
||||
<button
|
||||
@click="handleRateMultipliers(row)"
|
||||
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-purple-600 dark:hover:bg-dark-700 dark:hover:text-purple-400"
|
||||
>
|
||||
<Icon name="dollar" size="sm" />
|
||||
<span class="text-xs">{{ t('admin.groups.rateMultipliers') }}</span>
|
||||
</button>
|
||||
<button
|
||||
@click="handleDelete(row)"
|
||||
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
|
||||
@@ -746,6 +753,58 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Claude Max Usage 模拟(仅 anthropic 平台) -->
|
||||
<div v-if="createForm.platform === 'anthropic'" class="border-t pt-4">
|
||||
<div class="mb-1.5 flex items-center gap-1">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.groups.claudeMaxSimulation.title') }}
|
||||
</label>
|
||||
<div class="group relative inline-flex">
|
||||
<Icon
|
||||
name="questionCircle"
|
||||
size="sm"
|
||||
:stroke-width="2"
|
||||
class="cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400"
|
||||
/>
|
||||
<div class="pointer-events-none absolute bottom-full left-0 z-50 mb-2 w-80 opacity-0 transition-all duration-200 group-hover:pointer-events-auto group-hover:opacity-100">
|
||||
<div class="rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800">
|
||||
<p class="text-xs leading-relaxed text-gray-300">
|
||||
{{ t('admin.groups.claudeMaxSimulation.tooltip') }}
|
||||
</p>
|
||||
<div class="absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
@click="createForm.simulate_claude_max_enabled = !createForm.simulate_claude_max_enabled"
|
||||
:class="[
|
||||
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
|
||||
createForm.simulate_claude_max_enabled ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600'
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
|
||||
createForm.simulate_claude_max_enabled ? 'translate-x-6' : 'translate-x-1'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{
|
||||
createForm.simulate_claude_max_enabled
|
||||
? t('admin.groups.claudeMaxSimulation.enabled')
|
||||
: t('admin.groups.claudeMaxSimulation.disabled')
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.groups.claudeMaxSimulation.hint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 无效请求兜底(仅 anthropic/antigravity 平台,且非订阅分组) -->
|
||||
<div
|
||||
v-if="['anthropic', 'antigravity'].includes(createForm.platform) && createForm.subscription_type !== 'subscription'"
|
||||
@@ -1481,6 +1540,58 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Claude Max Usage 模拟(仅 anthropic 平台) -->
|
||||
<div v-if="editForm.platform === 'anthropic'" class="border-t pt-4">
|
||||
<div class="mb-1.5 flex items-center gap-1">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.groups.claudeMaxSimulation.title') }}
|
||||
</label>
|
||||
<div class="group relative inline-flex">
|
||||
<Icon
|
||||
name="questionCircle"
|
||||
size="sm"
|
||||
:stroke-width="2"
|
||||
class="cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400"
|
||||
/>
|
||||
<div class="pointer-events-none absolute bottom-full left-0 z-50 mb-2 w-80 opacity-0 transition-all duration-200 group-hover:pointer-events-auto group-hover:opacity-100">
|
||||
<div class="rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800">
|
||||
<p class="text-xs leading-relaxed text-gray-300">
|
||||
{{ t('admin.groups.claudeMaxSimulation.tooltip') }}
|
||||
</p>
|
||||
<div class="absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
@click="editForm.simulate_claude_max_enabled = !editForm.simulate_claude_max_enabled"
|
||||
:class="[
|
||||
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
|
||||
editForm.simulate_claude_max_enabled ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600'
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
|
||||
editForm.simulate_claude_max_enabled ? 'translate-x-6' : 'translate-x-1'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{
|
||||
editForm.simulate_claude_max_enabled
|
||||
? t('admin.groups.claudeMaxSimulation.enabled')
|
||||
: t('admin.groups.claudeMaxSimulation.disabled')
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.groups.claudeMaxSimulation.hint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 无效请求兜底(仅 anthropic/antigravity 平台,且非订阅分组) -->
|
||||
<div
|
||||
v-if="['anthropic', 'antigravity'].includes(editForm.platform) && editForm.subscription_type !== 'subscription'"
|
||||
@@ -1775,6 +1886,14 @@
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
|
||||
<!-- Group Rate Multipliers Modal -->
|
||||
<GroupRateMultipliersModal
|
||||
:show="showRateMultipliersModal"
|
||||
:group="rateMultipliersGroup"
|
||||
@close="showRateMultipliersModal = false"
|
||||
@success="loadGroups"
|
||||
/>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
@@ -1796,6 +1915,7 @@ import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import PlatformIcon from '@/components/common/PlatformIcon.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import GroupRateMultipliersModal from '@/components/admin/group/GroupRateMultipliersModal.vue'
|
||||
import { VueDraggable } from 'vue-draggable-plus'
|
||||
import { createStableObjectKeyResolver } from '@/utils/stableObjectKey'
|
||||
import { useKeyedDebouncedSearch } from '@/composables/useKeyedDebouncedSearch'
|
||||
@@ -1970,6 +2090,8 @@ const submitting = ref(false)
|
||||
const sortSubmitting = ref(false)
|
||||
const editingGroup = ref<AdminGroup | null>(null)
|
||||
const deletingGroup = ref<AdminGroup | null>(null)
|
||||
const showRateMultipliersModal = ref(false)
|
||||
const rateMultipliersGroup = ref<AdminGroup | null>(null)
|
||||
const sortableGroups = ref<AdminGroup[]>([])
|
||||
|
||||
const createForm = reactive({
|
||||
@@ -1994,6 +2116,8 @@ const createForm = reactive({
|
||||
sora_storage_quota_gb: null as number | null,
|
||||
// Claude Code 客户端限制(仅 anthropic 平台使用)
|
||||
claude_code_only: false,
|
||||
// Claude Max usage 模拟开关(仅 anthropic 平台)
|
||||
simulate_claude_max_enabled: false,
|
||||
fallback_group_id: null as number | null,
|
||||
fallback_group_id_on_invalid_request: null as number | null,
|
||||
// OpenAI Messages 调度配置(仅 openai 平台使用)
|
||||
@@ -2238,6 +2362,8 @@ const editForm = reactive({
|
||||
sora_storage_quota_gb: null as number | null,
|
||||
// Claude Code 客户端限制(仅 anthropic 平台使用)
|
||||
claude_code_only: false,
|
||||
// Claude Max usage 模拟开关(仅 anthropic 平台)
|
||||
simulate_claude_max_enabled: false,
|
||||
fallback_group_id: null as number | null,
|
||||
fallback_group_id_on_invalid_request: null as number | null,
|
||||
// OpenAI Messages 调度配置(仅 openai 平台使用)
|
||||
@@ -2340,6 +2466,7 @@ const closeCreateModal = () => {
|
||||
createForm.sora_video_price_per_request_hd = null
|
||||
createForm.sora_storage_quota_gb = null
|
||||
createForm.claude_code_only = false
|
||||
createForm.simulate_claude_max_enabled = false
|
||||
createForm.fallback_group_id = null
|
||||
createForm.fallback_group_id_on_invalid_request = null
|
||||
createForm.allow_messages_dispatch = false
|
||||
@@ -2362,6 +2489,8 @@ const handleCreateGroup = async () => {
|
||||
const requestData = {
|
||||
...createRest,
|
||||
sora_storage_quota_bytes: createQuotaGb ? Math.round(createQuotaGb * 1024 * 1024 * 1024) : 0,
|
||||
simulate_claude_max_enabled:
|
||||
createForm.platform === 'anthropic' ? createForm.simulate_claude_max_enabled : false,
|
||||
model_routing: convertRoutingRulesToApiFormat(createModelRoutingRules.value)
|
||||
}
|
||||
await adminAPI.groups.create(requestData)
|
||||
@@ -2402,6 +2531,7 @@ const handleEdit = async (group: AdminGroup) => {
|
||||
editForm.sora_video_price_per_request_hd = group.sora_video_price_per_request_hd
|
||||
editForm.sora_storage_quota_gb = group.sora_storage_quota_bytes ? Number((group.sora_storage_quota_bytes / (1024 * 1024 * 1024)).toFixed(2)) : null
|
||||
editForm.claude_code_only = group.claude_code_only || false
|
||||
editForm.simulate_claude_max_enabled = group.simulate_claude_max_enabled || false
|
||||
editForm.fallback_group_id = group.fallback_group_id
|
||||
editForm.fallback_group_id_on_invalid_request = group.fallback_group_id_on_invalid_request
|
||||
editForm.allow_messages_dispatch = group.allow_messages_dispatch || false
|
||||
@@ -2423,6 +2553,7 @@ const closeEditModal = () => {
|
||||
showEditModal.value = false
|
||||
editingGroup.value = null
|
||||
editModelRoutingRules.value = []
|
||||
editForm.simulate_claude_max_enabled = false
|
||||
editForm.copy_accounts_from_group_ids = []
|
||||
}
|
||||
|
||||
@@ -2440,6 +2571,8 @@ const handleUpdateGroup = async () => {
|
||||
const payload = {
|
||||
...editRest,
|
||||
sora_storage_quota_bytes: editQuotaGb ? Math.round(editQuotaGb * 1024 * 1024 * 1024) : 0,
|
||||
simulate_claude_max_enabled:
|
||||
editForm.platform === 'anthropic' ? editForm.simulate_claude_max_enabled : false,
|
||||
fallback_group_id: editForm.fallback_group_id === null ? 0 : editForm.fallback_group_id,
|
||||
fallback_group_id_on_invalid_request:
|
||||
editForm.fallback_group_id_on_invalid_request === null
|
||||
@@ -2459,6 +2592,11 @@ const handleUpdateGroup = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleRateMultipliers = (group: AdminGroup) => {
|
||||
rateMultipliersGroup.value = group
|
||||
showRateMultipliersModal.value = true
|
||||
}
|
||||
|
||||
const handleDelete = (group: AdminGroup) => {
|
||||
deletingGroup.value = group
|
||||
showDeleteDialog.value = true
|
||||
@@ -2500,6 +2638,25 @@ watch(
|
||||
createForm.allow_messages_dispatch = false
|
||||
createForm.default_mapped_model = ''
|
||||
}
|
||||
if (newVal !== 'anthropic') {
|
||||
createForm.simulate_claude_max_enabled = false
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => editForm.platform,
|
||||
(newVal) => {
|
||||
if (!['anthropic', 'antigravity'].includes(newVal)) {
|
||||
editForm.fallback_group_id_on_invalid_request = null
|
||||
}
|
||||
if (newVal !== 'openai') {
|
||||
editForm.allow_messages_dispatch = false
|
||||
editForm.default_mapped_model = ''
|
||||
}
|
||||
if (newVal !== 'anthropic') {
|
||||
editForm.simulate_claude_max_enabled = false
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -122,6 +122,7 @@ const platformRows = computed((): SummaryRow[] => {
|
||||
available_accounts: availableAccounts,
|
||||
rate_limited_accounts: safeNumber(avail.rate_limit_count),
|
||||
|
||||
|
||||
error_accounts: safeNumber(avail.error_count),
|
||||
total_concurrency: totalConcurrency,
|
||||
used_concurrency: usedConcurrency,
|
||||
@@ -161,7 +162,6 @@ const groupRows = computed((): SummaryRow[] => {
|
||||
total_accounts: totalAccounts,
|
||||
available_accounts: availableAccounts,
|
||||
rate_limited_accounts: safeNumber(avail.rate_limit_count),
|
||||
|
||||
error_accounts: safeNumber(avail.error_count),
|
||||
total_concurrency: totalConcurrency,
|
||||
used_concurrency: usedConcurrency,
|
||||
@@ -329,6 +329,7 @@ function formatDuration(seconds: number): string {
|
||||
}
|
||||
|
||||
|
||||
|
||||
watch(
|
||||
() => realtimeEnabled.value,
|
||||
async (enabled) => {
|
||||
|
||||
Reference in New Issue
Block a user