Merge branch 'main' into main
This commit is contained in:
@@ -62,3 +62,6 @@ export {
|
||||
}
|
||||
|
||||
export default adminAPI
|
||||
|
||||
// Re-export types used by components
|
||||
export type { BalanceHistoryItem } from './users'
|
||||
|
||||
@@ -174,6 +174,53 @@ export async function getUserUsageStats(
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Balance history item returned from the API
|
||||
*/
|
||||
export interface BalanceHistoryItem {
|
||||
id: number
|
||||
code: string
|
||||
type: string
|
||||
value: number
|
||||
status: string
|
||||
used_by: number | null
|
||||
used_at: string | null
|
||||
created_at: string
|
||||
group_id: number | null
|
||||
validity_days: number
|
||||
notes: string
|
||||
user?: { id: number; email: string } | null
|
||||
group?: { id: number; name: string } | null
|
||||
}
|
||||
|
||||
// Balance history response extends pagination with total_recharged summary
|
||||
export interface BalanceHistoryResponse extends PaginatedResponse<BalanceHistoryItem> {
|
||||
total_recharged: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's balance/concurrency change history
|
||||
* @param id - User ID
|
||||
* @param page - Page number
|
||||
* @param pageSize - Items per page
|
||||
* @param type - Optional type filter (balance, admin_balance, concurrency, admin_concurrency, subscription)
|
||||
* @returns Paginated balance history with total_recharged
|
||||
*/
|
||||
export async function getUserBalanceHistory(
|
||||
id: number,
|
||||
page: number = 1,
|
||||
pageSize: number = 20,
|
||||
type?: string
|
||||
): Promise<BalanceHistoryResponse> {
|
||||
const params: Record<string, any> = { page, page_size: pageSize }
|
||||
if (type) params.type = type
|
||||
const { data } = await apiClient.get<BalanceHistoryResponse>(
|
||||
`/admin/users/${id}/balance-history`,
|
||||
{ params }
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export const usersAPI = {
|
||||
list,
|
||||
getById,
|
||||
@@ -184,7 +231,8 @@ export const usersAPI = {
|
||||
updateConcurrency,
|
||||
toggleStatus,
|
||||
getUserApiKeys,
|
||||
getUserUsageStats
|
||||
getUserUsageStats,
|
||||
getUserBalanceHistory
|
||||
}
|
||||
|
||||
export default usersAPI
|
||||
|
||||
@@ -44,6 +44,8 @@ export async function getById(id: number): Promise<ApiKey> {
|
||||
* @param customKey - Optional custom key value
|
||||
* @param ipWhitelist - Optional IP whitelist
|
||||
* @param ipBlacklist - Optional IP blacklist
|
||||
* @param quota - Optional quota limit in USD (0 = unlimited)
|
||||
* @param expiresInDays - Optional days until expiry (undefined = never expires)
|
||||
* @returns Created API key
|
||||
*/
|
||||
export async function create(
|
||||
@@ -51,7 +53,9 @@ export async function create(
|
||||
groupId?: number | null,
|
||||
customKey?: string,
|
||||
ipWhitelist?: string[],
|
||||
ipBlacklist?: string[]
|
||||
ipBlacklist?: string[],
|
||||
quota?: number,
|
||||
expiresInDays?: number
|
||||
): Promise<ApiKey> {
|
||||
const payload: CreateApiKeyRequest = { name }
|
||||
if (groupId !== undefined) {
|
||||
@@ -66,6 +70,12 @@ export async function create(
|
||||
if (ipBlacklist && ipBlacklist.length > 0) {
|
||||
payload.ip_blacklist = ipBlacklist
|
||||
}
|
||||
if (quota !== undefined && quota > 0) {
|
||||
payload.quota = quota
|
||||
}
|
||||
if (expiresInDays !== undefined && expiresInDays > 0) {
|
||||
payload.expires_in_days = expiresInDays
|
||||
}
|
||||
|
||||
const { data } = await apiClient.post<ApiKey>('/keys', payload)
|
||||
return data
|
||||
|
||||
320
frontend/src/components/admin/user/UserBalanceHistoryModal.vue
Normal file
320
frontend/src/components/admin/user/UserBalanceHistoryModal.vue
Normal file
@@ -0,0 +1,320 @@
|
||||
<template>
|
||||
<BaseDialog :show="show" :title="t('admin.users.balanceHistoryTitle')" width="wide" :close-on-click-outside="true" :z-index="40" @close="$emit('close')">
|
||||
<div v-if="user" class="space-y-4">
|
||||
<!-- User header: two-row layout with full user info -->
|
||||
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-700">
|
||||
<!-- Row 1: avatar + email/username/created_at (left) + current balance (right) -->
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-primary-100 dark:bg-primary-900/30">
|
||||
<span class="text-lg font-medium text-primary-700 dark:text-primary-300">
|
||||
{{ user.email.charAt(0).toUpperCase() }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="truncate font-medium text-gray-900 dark:text-white">{{ user.email }}</p>
|
||||
<span
|
||||
v-if="user.username"
|
||||
class="flex-shrink-0 rounded bg-primary-50 px-1.5 py-0.5 text-xs text-primary-600 dark:bg-primary-900/20 dark:text-primary-400"
|
||||
>
|
||||
{{ user.username }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 dark:text-dark-500">
|
||||
{{ t('admin.users.createdAt') }}: {{ formatDateTime(user.created_at) }}
|
||||
</p>
|
||||
</div>
|
||||
<!-- Current balance: prominent display on the right -->
|
||||
<div class="flex-shrink-0 text-right">
|
||||
<p class="text-xs text-gray-500 dark:text-dark-400">{{ t('admin.users.currentBalance') }}</p>
|
||||
<p class="text-xl font-bold text-gray-900 dark:text-white">
|
||||
${{ user.balance?.toFixed(2) || '0.00' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Row 2: notes + total recharged -->
|
||||
<div class="mt-2.5 flex items-center justify-between border-t border-gray-200/60 pt-2.5 dark:border-dark-600/60">
|
||||
<p class="min-w-0 flex-1 truncate text-xs text-gray-500 dark:text-dark-400" :title="user.notes || ''">
|
||||
<template v-if="user.notes">{{ t('admin.users.notes') }}: {{ user.notes }}</template>
|
||||
<template v-else> </template>
|
||||
</p>
|
||||
<p class="ml-4 flex-shrink-0 text-xs text-gray-500 dark:text-dark-400">
|
||||
{{ t('admin.users.totalRecharged') }}: <span class="font-semibold text-emerald-600 dark:text-emerald-400">${{ totalRecharged.toFixed(2) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Type filter + Action buttons -->
|
||||
<div class="flex items-center gap-3">
|
||||
<Select
|
||||
v-model="typeFilter"
|
||||
:options="typeOptions"
|
||||
class="w-56"
|
||||
@change="loadHistory(1)"
|
||||
/>
|
||||
<!-- Deposit button - matches menu style -->
|
||||
<button
|
||||
@click="emit('deposit')"
|
||||
class="flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 transition-colors hover:bg-gray-50 dark:border-dark-600 dark:bg-dark-800 dark:text-gray-300 dark:hover:bg-dark-700"
|
||||
>
|
||||
<Icon name="plus" size="sm" class="text-emerald-500" :stroke-width="2" />
|
||||
{{ t('admin.users.deposit') }}
|
||||
</button>
|
||||
<!-- Withdraw button - matches menu style -->
|
||||
<button
|
||||
@click="emit('withdraw')"
|
||||
class="flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 transition-colors hover:bg-gray-50 dark:border-dark-600 dark:bg-dark-800 dark:text-gray-300 dark:hover:bg-dark-700"
|
||||
>
|
||||
<svg class="h-4 w-4 text-amber-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 12H4" />
|
||||
</svg>
|
||||
{{ t('admin.users.withdraw') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<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" />
|
||||
<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" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-else-if="history.length === 0" class="py-8 text-center">
|
||||
<p class="text-sm text-gray-500">{{ t('admin.users.noBalanceHistory') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- History list -->
|
||||
<div v-else class="max-h-[28rem] space-y-3 overflow-y-auto">
|
||||
<div
|
||||
v-for="item in history"
|
||||
:key="item.id"
|
||||
class="rounded-xl border border-gray-200 bg-white p-4 dark:border-dark-600 dark:bg-dark-800"
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<!-- Left: type icon + description -->
|
||||
<div class="flex items-start gap-3">
|
||||
<div
|
||||
:class="[
|
||||
'flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg',
|
||||
getIconBg(item)
|
||||
]"
|
||||
>
|
||||
<Icon :name="getIconName(item)" size="sm" :class="getIconColor(item)" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ getItemTitle(item) }}
|
||||
</p>
|
||||
<!-- Notes (admin adjustment reason) -->
|
||||
<p
|
||||
v-if="item.notes"
|
||||
class="mt-0.5 text-xs text-gray-500 dark:text-dark-400"
|
||||
:title="item.notes"
|
||||
>
|
||||
{{ item.notes.length > 60 ? item.notes.substring(0, 55) + '...' : item.notes }}
|
||||
</p>
|
||||
<p class="mt-0.5 text-xs text-gray-400 dark:text-dark-500">
|
||||
{{ formatDateTime(item.used_at || item.created_at) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Right: value -->
|
||||
<div class="text-right">
|
||||
<p :class="['text-sm font-semibold', getValueColor(item)]">
|
||||
{{ formatValue(item) }}
|
||||
</p>
|
||||
<p
|
||||
v-if="isAdminType(item.type)"
|
||||
class="text-xs text-gray-400 dark:text-dark-500"
|
||||
>
|
||||
{{ t('redeem.adminAdjustment') }}
|
||||
</p>
|
||||
<p
|
||||
v-else
|
||||
class="font-mono text-xs text-gray-400 dark:text-dark-500"
|
||||
>
|
||||
{{ item.code.slice(0, 8) }}...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div v-if="totalPages > 1" class="flex items-center justify-center gap-2 pt-2">
|
||||
<button
|
||||
:disabled="currentPage <= 1"
|
||||
class="btn btn-secondary px-3 py-1 text-sm"
|
||||
@click="loadHistory(currentPage - 1)"
|
||||
>
|
||||
{{ t('pagination.previous') }}
|
||||
</button>
|
||||
<span class="text-sm text-gray-500 dark:text-dark-400">
|
||||
{{ currentPage }} / {{ totalPages }}
|
||||
</span>
|
||||
<button
|
||||
:disabled="currentPage >= totalPages"
|
||||
class="btn btn-secondary px-3 py-1 text-sm"
|
||||
@click="loadHistory(currentPage + 1)"
|
||||
>
|
||||
{{ t('pagination.next') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { adminAPI, type BalanceHistoryItem } from '@/api/admin'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
import type { AdminUser } from '@/types'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
|
||||
const props = defineProps<{ show: boolean; user: AdminUser | null }>()
|
||||
const emit = defineEmits(['close', 'deposit', 'withdraw'])
|
||||
const { t } = useI18n()
|
||||
|
||||
const history = ref<BalanceHistoryItem[]>([])
|
||||
const loading = ref(false)
|
||||
const currentPage = ref(1)
|
||||
const total = ref(0)
|
||||
const totalRecharged = ref(0)
|
||||
const pageSize = 15
|
||||
const typeFilter = ref('')
|
||||
|
||||
const totalPages = computed(() => Math.ceil(total.value / pageSize) || 1)
|
||||
|
||||
// Type filter options
|
||||
const typeOptions = computed(() => [
|
||||
{ value: '', label: t('admin.users.allTypes') },
|
||||
{ value: 'balance', label: t('admin.users.typeBalance') },
|
||||
{ value: 'admin_balance', label: t('admin.users.typeAdminBalance') },
|
||||
{ value: 'concurrency', label: t('admin.users.typeConcurrency') },
|
||||
{ value: 'admin_concurrency', label: t('admin.users.typeAdminConcurrency') },
|
||||
{ value: 'subscription', label: t('admin.users.typeSubscription') }
|
||||
])
|
||||
|
||||
// Watch modal open
|
||||
watch(() => props.show, (v) => {
|
||||
if (v && props.user) {
|
||||
typeFilter.value = ''
|
||||
loadHistory(1)
|
||||
}
|
||||
})
|
||||
|
||||
const loadHistory = async (page: number) => {
|
||||
if (!props.user) return
|
||||
loading.value = true
|
||||
currentPage.value = page
|
||||
try {
|
||||
const res = await adminAPI.users.getUserBalanceHistory(
|
||||
props.user.id,
|
||||
page,
|
||||
pageSize,
|
||||
typeFilter.value || undefined
|
||||
)
|
||||
history.value = res.items || []
|
||||
total.value = res.total || 0
|
||||
totalRecharged.value = res.total_recharged || 0
|
||||
} catch (error) {
|
||||
console.error('Failed to load balance history:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: check if admin type
|
||||
const isAdminType = (type: string) => type === 'admin_balance' || type === 'admin_concurrency'
|
||||
|
||||
// Helper: check if balance type (includes admin_balance)
|
||||
const isBalanceType = (type: string) => type === 'balance' || type === 'admin_balance'
|
||||
|
||||
// Helper: check if subscription type
|
||||
const isSubscriptionType = (type: string) => type === 'subscription'
|
||||
|
||||
// Icon name based on type
|
||||
const getIconName = (item: BalanceHistoryItem) => {
|
||||
if (isBalanceType(item.type)) return 'dollar'
|
||||
if (isSubscriptionType(item.type)) return 'badge'
|
||||
return 'bolt' // concurrency
|
||||
}
|
||||
|
||||
// Icon background color
|
||||
const getIconBg = (item: BalanceHistoryItem) => {
|
||||
if (isBalanceType(item.type)) {
|
||||
return item.value >= 0
|
||||
? 'bg-emerald-100 dark:bg-emerald-900/30'
|
||||
: 'bg-red-100 dark:bg-red-900/30'
|
||||
}
|
||||
if (isSubscriptionType(item.type)) return 'bg-purple-100 dark:bg-purple-900/30'
|
||||
return item.value >= 0
|
||||
? 'bg-blue-100 dark:bg-blue-900/30'
|
||||
: 'bg-orange-100 dark:bg-orange-900/30'
|
||||
}
|
||||
|
||||
// Icon text color
|
||||
const getIconColor = (item: BalanceHistoryItem) => {
|
||||
if (isBalanceType(item.type)) {
|
||||
return item.value >= 0
|
||||
? 'text-emerald-600 dark:text-emerald-400'
|
||||
: 'text-red-600 dark:text-red-400'
|
||||
}
|
||||
if (isSubscriptionType(item.type)) return 'text-purple-600 dark:text-purple-400'
|
||||
return item.value >= 0
|
||||
? 'text-blue-600 dark:text-blue-400'
|
||||
: 'text-orange-600 dark:text-orange-400'
|
||||
}
|
||||
|
||||
// Value text color
|
||||
const getValueColor = (item: BalanceHistoryItem) => {
|
||||
if (isBalanceType(item.type)) {
|
||||
return item.value >= 0
|
||||
? 'text-emerald-600 dark:text-emerald-400'
|
||||
: 'text-red-600 dark:text-red-400'
|
||||
}
|
||||
if (isSubscriptionType(item.type)) return 'text-purple-600 dark:text-purple-400'
|
||||
return item.value >= 0
|
||||
? 'text-blue-600 dark:text-blue-400'
|
||||
: 'text-orange-600 dark:text-orange-400'
|
||||
}
|
||||
|
||||
// Item title
|
||||
const getItemTitle = (item: BalanceHistoryItem) => {
|
||||
switch (item.type) {
|
||||
case 'balance':
|
||||
return t('redeem.balanceAddedRedeem')
|
||||
case 'admin_balance':
|
||||
return item.value >= 0 ? t('redeem.balanceAddedAdmin') : t('redeem.balanceDeductedAdmin')
|
||||
case 'concurrency':
|
||||
return t('redeem.concurrencyAddedRedeem')
|
||||
case 'admin_concurrency':
|
||||
return item.value >= 0 ? t('redeem.concurrencyAddedAdmin') : t('redeem.concurrencyReducedAdmin')
|
||||
case 'subscription':
|
||||
return t('redeem.subscriptionAssigned')
|
||||
default:
|
||||
return t('common.unknown')
|
||||
}
|
||||
}
|
||||
|
||||
// Format display value
|
||||
const formatValue = (item: BalanceHistoryItem) => {
|
||||
if (isBalanceType(item.type)) {
|
||||
const sign = item.value >= 0 ? '+' : ''
|
||||
return `${sign}$${item.value.toFixed(2)}`
|
||||
}
|
||||
if (isSubscriptionType(item.type)) {
|
||||
const days = item.validity_days || Math.round(item.value)
|
||||
const groupName = item.group?.name || ''
|
||||
return groupName ? `${days}d - ${groupName}` : `${days}d`
|
||||
}
|
||||
// concurrency types
|
||||
const sign = item.value >= 0 ? '+' : ''
|
||||
return `${sign}${item.value}`
|
||||
}
|
||||
</script>
|
||||
@@ -4,6 +4,7 @@
|
||||
<div
|
||||
v-if="show"
|
||||
class="modal-overlay"
|
||||
:style="zIndexStyle"
|
||||
:aria-labelledby="dialogId"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
@@ -60,6 +61,7 @@ interface Props {
|
||||
width?: DialogWidth
|
||||
closeOnEscape?: boolean
|
||||
closeOnClickOutside?: boolean
|
||||
zIndex?: number
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
@@ -69,11 +71,17 @@ interface Emits {
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
width: 'normal',
|
||||
closeOnEscape: true,
|
||||
closeOnClickOutside: false
|
||||
closeOnClickOutside: false,
|
||||
zIndex: 50
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
// Custom z-index style (overrides the default z-50 from CSS)
|
||||
const zIndexStyle = computed(() => {
|
||||
return props.zIndex !== 50 ? { zIndex: props.zIndex } : undefined
|
||||
})
|
||||
|
||||
const widthClasses = computed(() => {
|
||||
// Width guidance: narrow=confirm/short prompts, normal=standard forms,
|
||||
// wide=multi-section forms or rich content, extra-wide=analytics/tables,
|
||||
|
||||
@@ -407,6 +407,7 @@ export default {
|
||||
usage: 'Usage',
|
||||
today: 'Today',
|
||||
total: 'Total',
|
||||
quota: 'Quota',
|
||||
useKey: 'Use Key',
|
||||
useKeyModal: {
|
||||
title: 'Use API Key',
|
||||
@@ -470,6 +471,33 @@ export default {
|
||||
geminiCli: 'Gemini CLI',
|
||||
geminiCliDesc: 'Import as Gemini CLI configuration',
|
||||
},
|
||||
// Quota and expiration
|
||||
quotaLimit: 'Quota Limit',
|
||||
quotaAmount: 'Quota Amount (USD)',
|
||||
quotaAmountPlaceholder: 'Enter quota limit in USD',
|
||||
quotaAmountHint: 'Set the maximum amount this key can spend. 0 = unlimited.',
|
||||
quotaUsed: 'Quota Used',
|
||||
reset: 'Reset',
|
||||
resetQuotaUsed: 'Reset used quota to 0',
|
||||
resetQuotaTitle: 'Confirm Reset Quota',
|
||||
resetQuotaConfirmMessage: 'Are you sure you want to reset the used quota (${used}) for key "{name}" to 0? This action cannot be undone.',
|
||||
quotaResetSuccess: 'Quota reset successfully',
|
||||
failedToResetQuota: 'Failed to reset quota',
|
||||
expiration: 'Expiration',
|
||||
expiresInDays: '{days} days',
|
||||
extendDays: '+{days} days',
|
||||
customDate: 'Custom',
|
||||
expirationDate: 'Expiration Date',
|
||||
expirationDateHint: 'Select when this API key should expire.',
|
||||
currentExpiration: 'Current expiration',
|
||||
expiresAt: 'Expires',
|
||||
noExpiration: 'Never',
|
||||
status: {
|
||||
active: 'Active',
|
||||
inactive: 'Inactive',
|
||||
quota_exhausted: 'Quota Exhausted',
|
||||
expired: 'Expired',
|
||||
},
|
||||
},
|
||||
|
||||
// Usage
|
||||
@@ -843,6 +871,20 @@ export default {
|
||||
failedToDeposit: 'Failed to deposit',
|
||||
failedToWithdraw: 'Failed to withdraw',
|
||||
useDepositWithdrawButtons: 'Please use deposit/withdraw buttons to adjust balance',
|
||||
// Balance History
|
||||
balanceHistory: 'Recharge History',
|
||||
balanceHistoryTip: 'Click to open recharge history',
|
||||
balanceHistoryTitle: 'User Recharge & Concurrency History',
|
||||
noBalanceHistory: 'No records found for this user',
|
||||
allTypes: 'All Types',
|
||||
typeBalance: 'Balance (Redeem)',
|
||||
typeAdminBalance: 'Balance (Admin)',
|
||||
typeConcurrency: 'Concurrency (Redeem)',
|
||||
typeAdminConcurrency: 'Concurrency (Admin)',
|
||||
typeSubscription: 'Subscription',
|
||||
failedToLoadBalanceHistory: 'Failed to load balance history',
|
||||
createdAt: 'Created',
|
||||
totalRecharged: 'Total Recharged',
|
||||
roles: {
|
||||
admin: 'Admin',
|
||||
user: 'User'
|
||||
|
||||
@@ -291,7 +291,8 @@ export default {
|
||||
sendingResetLink: '发送中...',
|
||||
sendResetLinkFailed: '发送重置链接失败,请重试。',
|
||||
resetEmailSent: '重置链接已发送',
|
||||
resetEmailSentHint: '如果该邮箱已注册,您将很快收到密码重置链接。请检查您的收件箱和垃圾邮件文件夹。',
|
||||
resetEmailSentHint:
|
||||
'如果该邮箱已注册,您将很快收到密码重置链接。请检查您的收件箱和垃圾邮件文件夹。',
|
||||
backToLogin: '返回登录',
|
||||
rememberedPassword: '想起密码了?',
|
||||
// 重置密码
|
||||
@@ -404,6 +405,7 @@ export default {
|
||||
usage: '用量',
|
||||
today: '今日',
|
||||
total: '累计',
|
||||
quota: '额度',
|
||||
useKey: '使用密钥',
|
||||
useKeyModal: {
|
||||
title: '使用 API 密钥',
|
||||
@@ -412,36 +414,41 @@ export default {
|
||||
copied: '已复制',
|
||||
note: '这些环境变量将在当前终端会话中生效。如需永久配置,请将其添加到 ~/.bashrc、~/.zshrc 或相应的配置文件中。',
|
||||
noGroupTitle: '请先分配分组',
|
||||
noGroupDescription: '此 API 密钥尚未分配分组,请先在密钥列表中点击分组列进行分配,然后才能查看使用配置。',
|
||||
noGroupDescription:
|
||||
'此 API 密钥尚未分配分组,请先在密钥列表中点击分组列进行分配,然后才能查看使用配置。',
|
||||
openai: {
|
||||
description: '将以下配置文件添加到 Codex CLI 配置目录中。',
|
||||
configTomlHint: '请确保以下内容位于 config.toml 文件的开头部分',
|
||||
note: '请确保配置目录存在。macOS/Linux 用户可运行 mkdir -p ~/.codex 创建目录。',
|
||||
noteWindows: '按 Win+R,输入 %userprofile%\\.codex 打开配置目录。如目录不存在,请先手动创建。',
|
||||
noteWindows:
|
||||
'按 Win+R,输入 %userprofile%\\.codex 打开配置目录。如目录不存在,请先手动创建。'
|
||||
},
|
||||
cliTabs: {
|
||||
claudeCode: 'Claude Code',
|
||||
geminiCli: 'Gemini CLI',
|
||||
codexCli: 'Codex CLI',
|
||||
opencode: 'OpenCode',
|
||||
opencode: 'OpenCode'
|
||||
},
|
||||
antigravity: {
|
||||
description: '为 Antigravity 分组配置 API 访问。请根据您使用的客户端选择对应的配置方式。',
|
||||
claudeCode: 'Claude Code',
|
||||
geminiCli: 'Gemini CLI',
|
||||
claudeNote: '这些环境变量将在当前终端会话中生效。如需永久配置,请将其添加到 ~/.bashrc、~/.zshrc 或相应的配置文件中。',
|
||||
geminiNote: '这些环境变量将在当前终端会话中生效。如需永久配置,请将其添加到 ~/.bashrc、~/.zshrc 或相应的配置文件中。',
|
||||
claudeNote:
|
||||
'这些环境变量将在当前终端会话中生效。如需永久配置,请将其添加到 ~/.bashrc、~/.zshrc 或相应的配置文件中。',
|
||||
geminiNote:
|
||||
'这些环境变量将在当前终端会话中生效。如需永久配置,请将其添加到 ~/.bashrc、~/.zshrc 或相应的配置文件中。'
|
||||
},
|
||||
gemini: {
|
||||
description: '将以下环境变量添加到您的终端配置文件或直接在终端中运行,以配置 Gemini CLI 访问。',
|
||||
description:
|
||||
'将以下环境变量添加到您的终端配置文件或直接在终端中运行,以配置 Gemini CLI 访问。',
|
||||
modelComment: '如果你有 Gemini 3 权限可以填:gemini-3-pro-preview',
|
||||
note: '这些环境变量将在当前终端会话中生效。如需永久配置,请将其添加到 ~/.bashrc、~/.zshrc 或相应的配置文件中。',
|
||||
note: '这些环境变量将在当前终端会话中生效。如需永久配置,请将其添加到 ~/.bashrc、~/.zshrc 或相应的配置文件中。'
|
||||
},
|
||||
opencode: {
|
||||
title: 'OpenCode 配置示例',
|
||||
subtitle: 'opencode.json',
|
||||
hint: '配置文件路径:~/.config/opencode/opencode.json(或 opencode.jsonc),不存在需手动创建。可使用默认 provider(openai/anthropic/google)或自定义 provider_id。API Key 支持直接配置或通过客户端 /connect 命令配置。示例仅供参考,模型与选项可按需调整。',
|
||||
},
|
||||
hint: '配置文件路径:~/.config/opencode/opencode.json(或 opencode.jsonc),不存在需手动创建。可使用默认 provider(openai/anthropic/google)或自定义 provider_id。API Key 支持直接配置或通过客户端 /connect 命令配置。示例仅供参考,模型与选项可按需调整。'
|
||||
}
|
||||
},
|
||||
customKeyLabel: '自定义密钥',
|
||||
customKeyPlaceholder: '输入自定义密钥(至少16个字符)',
|
||||
@@ -457,15 +464,43 @@ export default {
|
||||
ipBlacklistPlaceholder: '1.2.3.4\n5.6.0.0/16',
|
||||
ipBlacklistHint: '每行一个 IP 或 CIDR,这些 IP 将被禁止使用此密钥',
|
||||
ipRestrictionEnabled: '已配置 IP 限制',
|
||||
ccSwitchNotInstalled: 'CC-Switch 未安装或协议处理程序未注册。请先安装 CC-Switch 或手动复制 API 密钥。',
|
||||
ccSwitchNotInstalled:
|
||||
'CC-Switch 未安装或协议处理程序未注册。请先安装 CC-Switch 或手动复制 API 密钥。',
|
||||
ccsClientSelect: {
|
||||
title: '选择客户端',
|
||||
description: '请选择您要导入到 CC-Switch 的客户端类型:',
|
||||
claudeCode: 'Claude Code',
|
||||
claudeCodeDesc: '导入为 Claude Code 配置',
|
||||
geminiCli: 'Gemini CLI',
|
||||
geminiCliDesc: '导入为 Gemini CLI 配置',
|
||||
geminiCliDesc: '导入为 Gemini CLI 配置'
|
||||
},
|
||||
// 配额和有效期
|
||||
quotaLimit: '额度限制',
|
||||
quotaAmount: '额度金额 (USD)',
|
||||
quotaAmountPlaceholder: '输入 USD 额度限制',
|
||||
quotaAmountHint: '设置此密钥可消费的最大金额。0 = 无限制。',
|
||||
quotaUsed: '已用额度',
|
||||
reset: '重置',
|
||||
resetQuotaUsed: '将已用额度重置为 0',
|
||||
resetQuotaTitle: '确认重置额度',
|
||||
resetQuotaConfirmMessage: '确定要将密钥 "{name}" 的已用额度(${used})重置为 0 吗?此操作不可撤销。',
|
||||
quotaResetSuccess: '额度重置成功',
|
||||
failedToResetQuota: '重置额度失败',
|
||||
expiration: '密钥有效期',
|
||||
expiresInDays: '{days} 天',
|
||||
extendDays: '+{days} 天',
|
||||
customDate: '自定义',
|
||||
expirationDate: '过期时间',
|
||||
expirationDateHint: '选择此 API 密钥的过期时间。',
|
||||
currentExpiration: '当前过期时间',
|
||||
expiresAt: '过期时间',
|
||||
noExpiration: '永久有效',
|
||||
status: {
|
||||
active: '活跃',
|
||||
inactive: '已停用',
|
||||
quota_exhausted: '额度耗尽',
|
||||
expired: '已过期'
|
||||
}
|
||||
},
|
||||
|
||||
// Usage
|
||||
@@ -757,8 +792,8 @@ export default {
|
||||
editUser: '编辑用户',
|
||||
deleteUser: '删除用户',
|
||||
deleteConfirmMessage: "确定要删除用户 '{email}' 吗?此操作无法撤销。",
|
||||
searchPlaceholder: '搜索用户...',
|
||||
searchUsers: '搜索用户...',
|
||||
searchPlaceholder: '搜索用户邮箱或用户名、备注、支持模糊查询...',
|
||||
searchUsers: '搜索用户邮箱或用户名、备注、支持模糊查询',
|
||||
roleFilter: '角色筛选',
|
||||
allRoles: '全部角色',
|
||||
allStatus: '全部状态',
|
||||
@@ -894,6 +929,20 @@ export default {
|
||||
failedToDeposit: '充值失败',
|
||||
failedToWithdraw: '退款失败',
|
||||
useDepositWithdrawButtons: '请使用充值/退款按钮调整余额',
|
||||
// 余额变动记录
|
||||
balanceHistory: '充值记录',
|
||||
balanceHistoryTip: '点击查看充值记录',
|
||||
balanceHistoryTitle: '用户充值和并发变动记录',
|
||||
noBalanceHistory: '暂无变动记录',
|
||||
allTypes: '全部类型',
|
||||
typeBalance: '余额(兑换码)',
|
||||
typeAdminBalance: '余额(管理员调整)',
|
||||
typeConcurrency: '并发(兑换码)',
|
||||
typeAdminConcurrency: '并发(管理员调整)',
|
||||
typeSubscription: '订阅',
|
||||
failedToLoadBalanceHistory: '加载余额记录失败',
|
||||
createdAt: '创建时间',
|
||||
totalRecharged: '总充值',
|
||||
// Settings Dropdowns
|
||||
filterSettings: '筛选设置',
|
||||
columnSettings: '列设置',
|
||||
@@ -1014,9 +1063,11 @@ export default {
|
||||
exclusiveHint: '专属分组,可以手动指定给特定用户',
|
||||
exclusiveTooltip: {
|
||||
title: '什么是专属分组?',
|
||||
description: '开启后,用户在创建 API Key 时将无法看到此分组。只有管理员手动将用户分配到此分组后,用户才能使用。',
|
||||
description:
|
||||
'开启后,用户在创建 API Key 时将无法看到此分组。只有管理员手动将用户分配到此分组后,用户才能使用。',
|
||||
example: '使用场景:',
|
||||
exampleContent: '公开分组费率 0.8,您可以创建一个费率 0.7 的专属分组,手动分配给 VIP 用户,让他们享受更优惠的价格。'
|
||||
exampleContent:
|
||||
'公开分组费率 0.8,您可以创建一个费率 0.7 的专属分组,手动分配给 VIP 用户,让他们享受更优惠的价格。'
|
||||
},
|
||||
rateMultiplierHint: '1.0 = 标准费率,0.5 = 半价,2.0 = 双倍',
|
||||
platforms: {
|
||||
@@ -1080,7 +1131,8 @@ export default {
|
||||
},
|
||||
claudeCode: {
|
||||
title: 'Claude Code 客户端限制',
|
||||
tooltip: '启用后,此分组仅允许 Claude Code 官方客户端访问。非 Claude Code 请求将被拒绝或降级到指定分组。',
|
||||
tooltip:
|
||||
'启用后,此分组仅允许 Claude Code 官方客户端访问。非 Claude Code 请求将被拒绝或降级到指定分组。',
|
||||
enabled: '仅限 Claude Code',
|
||||
disabled: '允许所有客户端',
|
||||
fallbackGroup: '降级分组',
|
||||
@@ -1102,7 +1154,8 @@ export default {
|
||||
},
|
||||
modelRouting: {
|
||||
title: '模型路由配置',
|
||||
tooltip: '配置特定模型请求优先路由到指定账号。支持通配符匹配,如 claude-opus-* 匹配所有 opus 模型。',
|
||||
tooltip:
|
||||
'配置特定模型请求优先路由到指定账号。支持通配符匹配,如 claude-opus-* 匹配所有 opus 模型。',
|
||||
enabled: '已启用',
|
||||
disabled: '已禁用',
|
||||
disabledHint: '启用后,配置的路由规则才会生效',
|
||||
@@ -1630,8 +1683,7 @@ export default {
|
||||
regenerate: '重新生成',
|
||||
step2OpenUrl: '在浏览器中打开 URL 并完成授权',
|
||||
openUrlDesc: '在新标签页中打开授权 URL,登录您的 Claude 账号并授权。',
|
||||
proxyWarning:
|
||||
'注意:如果您配置了代理,请确保浏览器使用相同的代理访问授权页面。',
|
||||
proxyWarning: '注意:如果您配置了代理,请确保浏览器使用相同的代理访问授权页面。',
|
||||
step3EnterCode: '输入授权码',
|
||||
authCodeDesc: '授权完成后,页面会显示一个授权码。复制并粘贴到下方:',
|
||||
authCode: '授权码',
|
||||
@@ -1663,45 +1715,50 @@ export default {
|
||||
authCodeHint: '您可以直接复制整个链接或仅复制 code 参数值,系统会自动识别'
|
||||
},
|
||||
// Gemini specific
|
||||
gemini: {
|
||||
title: 'Gemini 账户授权',
|
||||
followSteps: '请按照以下步骤完成 Gemini 账户的授权:',
|
||||
step1GenerateUrl: '生成授权链接',
|
||||
generateAuthUrl: '生成授权链接',
|
||||
projectIdLabel: 'Project ID(可选)',
|
||||
projectIdPlaceholder: '例如:my-gcp-project 或 cloud-ai-companion-xxxxx',
|
||||
projectIdHint: '留空则在兑换授权码后自动探测;若自动探测失败,可填写后重新生成授权链接再授权。',
|
||||
howToGetProjectId: '如何获取',
|
||||
step2OpenUrl: '在浏览器中打开链接并完成授权',
|
||||
openUrlDesc: '请在新标签页中打开授权链接,登录您的 Google 账户并授权。',
|
||||
step3EnterCode: '输入回调链接或 Code',
|
||||
authCodeDesc: '授权完成后,复制浏览器跳转后的回调链接(推荐)或仅复制 code,粘贴到下方即可。',
|
||||
authCode: '回调链接或 Code',
|
||||
authCodePlaceholder: '方式1(推荐):粘贴回调链接\n方式2:仅粘贴 code 参数的值',
|
||||
authCodeHint: '系统会自动从链接中解析 code/state。',
|
||||
gemini: {
|
||||
title: 'Gemini 账户授权',
|
||||
followSteps: '请按照以下步骤完成 Gemini 账户的授权:',
|
||||
step1GenerateUrl: '生成授权链接',
|
||||
generateAuthUrl: '生成授权链接',
|
||||
projectIdLabel: 'Project ID(可选)',
|
||||
projectIdPlaceholder: '例如:my-gcp-project 或 cloud-ai-companion-xxxxx',
|
||||
projectIdHint:
|
||||
'留空则在兑换授权码后自动探测;若自动探测失败,可填写后重新生成授权链接再授权。',
|
||||
howToGetProjectId: '如何获取',
|
||||
step2OpenUrl: '在浏览器中打开链接并完成授权',
|
||||
openUrlDesc: '请在新标签页中打开授权链接,登录您的 Google 账户并授权。',
|
||||
step3EnterCode: '输入回调链接或 Code',
|
||||
authCodeDesc:
|
||||
'授权完成后,复制浏览器跳转后的回调链接(推荐)或仅复制 code,粘贴到下方即可。',
|
||||
authCode: '回调链接或 Code',
|
||||
authCodePlaceholder: '方式1(推荐):粘贴回调链接\n方式2:仅粘贴 code 参数的值',
|
||||
authCodeHint: '系统会自动从链接中解析 code/state。',
|
||||
redirectUri: 'Redirect URI',
|
||||
redirectUriHint: '需要在 Google OAuth Client 中配置,且必须与此处完全一致。',
|
||||
confirmRedirectUri: '我已在 Google OAuth Client 中配置了该 Redirect URI(必须完全一致)',
|
||||
invalidRedirectUri: 'Redirect URI 必须是合法的 http(s) URL',
|
||||
redirectUriNotConfirmed: '请确认 Redirect URI 已在 Google OAuth Client 中正确配置',
|
||||
missingRedirectUri: '缺少 Redirect URI',
|
||||
failedToGenerateUrl: '生成 Gemini 授权链接失败',
|
||||
missingExchangeParams: '缺少 code / session_id / state',
|
||||
failedToExchangeCode: 'Gemini 授权码兑换失败',
|
||||
missingProjectId: 'GCP Project ID 获取失败:您的 Google 账号未关联有效的 GCP 项目。请前往 Google Cloud Console 激活 GCP 并绑定信用卡,或在授权时手动填写 Project ID。',
|
||||
modelPassthrough: 'Gemini 直接转发模型',
|
||||
modelPassthroughDesc: '所有模型请求将直接转发至 Gemini API,不进行模型限制或映射。',
|
||||
stateWarningTitle: '提示',
|
||||
stateWarningDesc: '建议粘贴完整回调链接(包含 code 和 state)。',
|
||||
oauthTypeLabel: 'OAuth 类型',
|
||||
redirectUriNotConfirmed: '请确认 Redirect URI 已在 Google OAuth Client 中正确配置',
|
||||
missingRedirectUri: '缺少 Redirect URI',
|
||||
failedToGenerateUrl: '生成 Gemini 授权链接失败',
|
||||
missingExchangeParams: '缺少 code / session_id / state',
|
||||
failedToExchangeCode: 'Gemini 授权码兑换失败',
|
||||
missingProjectId:
|
||||
'GCP Project ID 获取失败:您的 Google 账号未关联有效的 GCP 项目。请前往 Google Cloud Console 激活 GCP 并绑定信用卡,或在授权时手动填写 Project ID。',
|
||||
modelPassthrough: 'Gemini 直接转发模型',
|
||||
modelPassthroughDesc: '所有模型请求将直接转发至 Gemini API,不进行模型限制或映射。',
|
||||
stateWarningTitle: '提示',
|
||||
stateWarningDesc: '建议粘贴完整回调链接(包含 code 和 state)。',
|
||||
oauthTypeLabel: 'OAuth 类型',
|
||||
needsProjectId: '内置授权(Code Assist)',
|
||||
needsProjectIdDesc: '需要 GCP 项目与 Project ID',
|
||||
noProjectIdNeeded: '自定义授权(AI Studio)',
|
||||
noProjectIdNeededDesc: '需管理员配置 OAuth Client',
|
||||
aiStudioNotConfiguredShort: '未配置',
|
||||
aiStudioNotConfiguredTip: 'AI Studio OAuth 未配置:请先设置 GEMINI_OAUTH_CLIENT_ID / GEMINI_OAUTH_CLIENT_SECRET,并在 Google OAuth Client 添加 Redirect URI:http://localhost:1455/auth/callback(Consent Screen scopes 需包含 https://www.googleapis.com/auth/generative-language.retriever)',
|
||||
aiStudioNotConfigured: 'AI Studio OAuth 未配置:请先设置 GEMINI_OAUTH_CLIENT_ID / GEMINI_OAUTH_CLIENT_SECRET,并在 Google OAuth Client 添加 Redirect URI:http://localhost:1455/auth/callback'
|
||||
},
|
||||
aiStudioNotConfiguredShort: '未配置',
|
||||
aiStudioNotConfiguredTip:
|
||||
'AI Studio OAuth 未配置:请先设置 GEMINI_OAUTH_CLIENT_ID / GEMINI_OAUTH_CLIENT_SECRET,并在 Google OAuth Client 添加 Redirect URI:http://localhost:1455/auth/callback(Consent Screen scopes 需包含 https://www.googleapis.com/auth/generative-language.retriever)',
|
||||
aiStudioNotConfigured:
|
||||
'AI Studio OAuth 未配置:请先设置 GEMINI_OAUTH_CLIENT_ID / GEMINI_OAUTH_CLIENT_SECRET,并在 Google OAuth Client 添加 Redirect URI:http://localhost:1455/auth/callback'
|
||||
},
|
||||
// Antigravity specific
|
||||
antigravity: {
|
||||
title: 'Antigravity 账户授权',
|
||||
@@ -1723,7 +1780,7 @@ export default {
|
||||
missingExchangeParams: '缺少 code / session_id / state',
|
||||
failedToExchangeCode: 'Antigravity 授权码兑换失败'
|
||||
}
|
||||
},
|
||||
},
|
||||
// Gemini specific (platform-wide)
|
||||
gemini: {
|
||||
helpButton: '使用帮助',
|
||||
@@ -1738,7 +1795,8 @@ export default {
|
||||
tier: {
|
||||
label: '账号等级',
|
||||
hint: '提示:系统会优先尝试自动识别账号等级;若自动识别不可用或失败,则使用你选择的等级作为回退(本地模拟配额)。',
|
||||
aiStudioHint: 'AI Studio 的配额是按模型分别限流(Pro/Flash 独立)。若已绑卡(按量付费),请选 Pay-as-you-go。',
|
||||
aiStudioHint:
|
||||
'AI Studio 的配额是按模型分别限流(Pro/Flash 独立)。若已绑卡(按量付费),请选 Pay-as-you-go。',
|
||||
googleOne: {
|
||||
free: 'Google One Free',
|
||||
pro: 'Google One Pro',
|
||||
@@ -1882,9 +1940,9 @@ export default {
|
||||
outputCopied: '输出已复制',
|
||||
startingTestForAccount: '开始测试账号:{name}',
|
||||
testAccountTypeLabel: '账号类型:{type}',
|
||||
selectTestModel: '选择测试模型',
|
||||
testModel: '测试模型',
|
||||
testPrompt: '提示词:"hi"',
|
||||
selectTestModel: '选择测试模型',
|
||||
testModel: '测试模型',
|
||||
testPrompt: '提示词:"hi"',
|
||||
// Stats Modal
|
||||
viewStats: '查看统计',
|
||||
usageStatistics: '使用统计',
|
||||
@@ -2567,7 +2625,7 @@ export default {
|
||||
internal: '内部'
|
||||
},
|
||||
total: '总计:',
|
||||
searchPlaceholder: '搜索 request_id / client_request_id / message',
|
||||
searchPlaceholder: '搜索 request_id / client_request_id / message'
|
||||
},
|
||||
// Error Detail Modal
|
||||
errorDetail: {
|
||||
@@ -2998,7 +3056,8 @@ export default {
|
||||
ignoreCountTokensErrors: '忽略 count_tokens 错误',
|
||||
ignoreCountTokensErrorsHint: '启用后,count_tokens 请求的错误将不会写入错误日志。',
|
||||
ignoreContextCanceled: '忽略客户端断连错误',
|
||||
ignoreContextCanceledHint: '启用后,客户端主动断开连接(context canceled)的错误将不会写入错误日志。',
|
||||
ignoreContextCanceledHint:
|
||||
'启用后,客户端主动断开连接(context canceled)的错误将不会写入错误日志。',
|
||||
ignoreNoAvailableAccounts: '忽略无可用账号错误',
|
||||
ignoreNoAvailableAccountsHint: '启用后,"No available accounts" 错误将不会写入错误日志(不推荐,这通常是配置问题)。',
|
||||
ignoreInvalidApiKeyErrors: '忽略无效 API Key 错误',
|
||||
@@ -3118,7 +3177,8 @@ export default {
|
||||
siteKeyHint: '从 Cloudflare Dashboard 获取',
|
||||
cloudflareDashboard: 'Cloudflare Dashboard',
|
||||
secretKeyHint: '服务端验证密钥(请保密)',
|
||||
secretKeyConfiguredHint: '密钥已配置,留空以保留当前值。' },
|
||||
secretKeyConfiguredHint: '密钥已配置,留空以保留当前值。'
|
||||
},
|
||||
linuxdo: {
|
||||
title: 'LinuxDo Connect 登录',
|
||||
description: '配置 LinuxDo Connect OAuth,用于 Sub2API 用户登录',
|
||||
@@ -3172,9 +3232,12 @@ export default {
|
||||
logoTypeError: '请选择图片文件',
|
||||
logoReadError: '读取图片文件失败',
|
||||
homeContent: '首页内容',
|
||||
homeContentPlaceholder: '在此输入首页内容,支持 Markdown & HTML 代码。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性。',
|
||||
homeContentHint: '自定义首页内容,支持 Markdown/HTML。如果输入的是链接(以 http:// 或 https:// 开头),则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为首页。设置后首页的状态信息将不再显示。',
|
||||
homeContentIframeWarning: '⚠️ iframe 模式提示:部分网站设置了 X-Frame-Options 或 CSP 安全策略,禁止被嵌入到 iframe 中。如果页面显示空白或报错,请确认目标网站允许被嵌入,或考虑使用 HTML 模式自行构建页面内容。',
|
||||
homeContentPlaceholder:
|
||||
'在此输入首页内容,支持 Markdown & HTML 代码。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性。',
|
||||
homeContentHint:
|
||||
'自定义首页内容,支持 Markdown/HTML。如果输入的是链接(以 http:// 或 https:// 开头),则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为首页。设置后首页的状态信息将不再显示。',
|
||||
homeContentIframeWarning:
|
||||
'⚠️ iframe 模式提示:部分网站设置了 X-Frame-Options 或 CSP 安全策略,禁止被嵌入到 iframe 中。如果页面显示空白或报错,请确认目标网站允许被嵌入,或考虑使用 HTML 模式自行构建页面内容。',
|
||||
hideCcsImportButton: '隐藏 CCS 导入按钮',
|
||||
hideCcsImportButtonHint: '启用后将在 API Keys 页面隐藏"导入 CCS"按钮'
|
||||
},
|
||||
@@ -3411,131 +3474,158 @@ export default {
|
||||
admin: {
|
||||
welcome: {
|
||||
title: '👋 欢迎使用 Sub2API',
|
||||
description: '<div style="line-height: 1.8;"><p style="margin-bottom: 16px;">Sub2API 是一个强大的 AI 服务中转平台,让您轻松管理和分发 AI 服务。</p><p style="margin-bottom: 12px;"><b>🎯 核心功能:</b></p><ul style="margin-left: 20px; margin-bottom: 16px;"><li>📦 <b>分组管理</b> - 创建不同的服务套餐(VIP、免费试用等)</li><li>🔗 <b>账号池</b> - 连接多个上游 AI 服务商账号</li><li>🔑 <b>密钥分发</b> - 为用户生成独立的 API Key</li><li>💰 <b>计费管理</b> - 灵活的费率和配额控制</li></ul><p style="color: #10b981; font-weight: 600;">接下来,我们将用 3 分钟带您完成首次配置 →</p></div>',
|
||||
description:
|
||||
'<div style="line-height: 1.8;"><p style="margin-bottom: 16px;">Sub2API 是一个强大的 AI 服务中转平台,让您轻松管理和分发 AI 服务。</p><p style="margin-bottom: 12px;"><b>🎯 核心功能:</b></p><ul style="margin-left: 20px; margin-bottom: 16px;"><li>📦 <b>分组管理</b> - 创建不同的服务套餐(VIP、免费试用等)</li><li>🔗 <b>账号池</b> - 连接多个上游 AI 服务商账号</li><li>🔑 <b>密钥分发</b> - 为用户生成独立的 API Key</li><li>💰 <b>计费管理</b> - 灵活的费率和配额控制</li></ul><p style="color: #10b981; font-weight: 600;">接下来,我们将用 3 分钟带您完成首次配置 →</p></div>',
|
||||
nextBtn: '开始配置 🚀',
|
||||
prevBtn: '跳过'
|
||||
},
|
||||
groupManage: {
|
||||
title: '📦 第一步:分组管理',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;"><b>什么是分组?</b></p><p style="margin-bottom: 12px;">分组是 Sub2API 的核心概念,它就像一个"服务套餐":</p><ul style="margin-left: 20px; margin-bottom: 12px; font-size: 13px;"><li>🎯 每个分组可以包含多个上游账号</li><li>💰 每个分组有独立的计费倍率</li><li>👥 可以设置为公开或专属分组</li></ul><p style="margin-top: 12px; padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 示例:</b>您可以创建"VIP专线"(高倍率)和"免费试用"(低倍率)两个分组</p><p style="margin-top: 16px; color: #10b981; font-weight: 600;">👉 点击左侧的"分组管理"开始</p></div>'
|
||||
description:
|
||||
'<div style="line-height: 1.7;"><p style="margin-bottom: 12px;"><b>什么是分组?</b></p><p style="margin-bottom: 12px;">分组是 Sub2API 的核心概念,它就像一个"服务套餐":</p><ul style="margin-left: 20px; margin-bottom: 12px; font-size: 13px;"><li>🎯 每个分组可以包含多个上游账号</li><li>💰 每个分组有独立的计费倍率</li><li>👥 可以设置为公开或专属分组</li></ul><p style="margin-top: 12px; padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 示例:</b>您可以创建"VIP专线"(高倍率)和"免费试用"(低倍率)两个分组</p><p style="margin-top: 16px; color: #10b981; font-weight: 600;">👉 点击左侧的"分组管理"开始</p></div>'
|
||||
},
|
||||
createGroup: {
|
||||
title: '➕ 创建新分组',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">现在让我们创建第一个分组。</p><p style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📝 提示:</b>建议先创建一个测试分组,熟悉流程后再创建正式分组</p><p style="color: #10b981; font-weight: 600;">👉 点击"创建分组"按钮</p></div>'
|
||||
description:
|
||||
'<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">现在让我们创建第一个分组。</p><p style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📝 提示:</b>建议先创建一个测试分组,熟悉流程后再创建正式分组</p><p style="color: #10b981; font-weight: 600;">👉 点击"创建分组"按钮</p></div>'
|
||||
},
|
||||
groupName: {
|
||||
title: '✏️ 1. 分组名称',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">为您的分组起一个易于识别的名称。</p><div style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>💡 命名建议:</b><ul style="margin: 8px 0 0 16px;"><li>"测试分组" - 用于测试</li><li>"VIP专线" - 高质量服务</li><li>"免费试用" - 体验版</li></ul></div><p style="font-size: 13px; color: #6b7280;">填写完成后点击"下一步"继续</p></div>',
|
||||
description:
|
||||
'<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">为您的分组起一个易于识别的名称。</p><div style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>💡 命名建议:</b><ul style="margin: 8px 0 0 16px;"><li>"测试分组" - 用于测试</li><li>"VIP专线" - 高质量服务</li><li>"免费试用" - 体验版</li></ul></div><p style="font-size: 13px; color: #6b7280;">填写完成后点击"下一步"继续</p></div>',
|
||||
nextBtn: '下一步'
|
||||
},
|
||||
groupPlatform: {
|
||||
title: '🤖 2. 选择平台',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">选择该分组支持的 AI 平台。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📌 平台说明:</b><ul style="margin: 8px 0 0 16px;"><li><b>Anthropic</b> - Claude 系列模型</li><li><b>OpenAI</b> - GPT 系列模型</li><li><b>Google</b> - Gemini 系列模型</li></ul></div><p style="font-size: 13px; color: #6b7280;">一个分组只能选择一个平台</p></div>',
|
||||
description:
|
||||
'<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">选择该分组支持的 AI 平台。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📌 平台说明:</b><ul style="margin: 8px 0 0 16px;"><li><b>Anthropic</b> - Claude 系列模型</li><li><b>OpenAI</b> - GPT 系列模型</li><li><b>Google</b> - Gemini 系列模型</li></ul></div><p style="font-size: 13px; color: #6b7280;">一个分组只能选择一个平台</p></div>',
|
||||
nextBtn: '下一步'
|
||||
},
|
||||
groupMultiplier: {
|
||||
title: '💰 3. 费率倍数',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">设置该分组的计费倍率,控制用户的实际扣费。</p><div style="padding: 8px 12px; background: #fef3c7; border-left: 3px solid #f59e0b; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>⚙️ 计费规则:</b><ul style="margin: 8px 0 0 16px;"><li><b>1.0</b> - 原价计费(成本价)</li><li><b>1.5</b> - 用户消耗 $1,扣除 $1.5</li><li><b>2.0</b> - 用户消耗 $1,扣除 $2</li><li><b>0.8</b> - 补贴模式(亏本运营)</li></ul></div><p style="font-size: 13px; color: #6b7280;">建议测试分组设置为 1.0</p></div>',
|
||||
description:
|
||||
'<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">设置该分组的计费倍率,控制用户的实际扣费。</p><div style="padding: 8px 12px; background: #fef3c7; border-left: 3px solid #f59e0b; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>⚙️ 计费规则:</b><ul style="margin: 8px 0 0 16px;"><li><b>1.0</b> - 原价计费(成本价)</li><li><b>1.5</b> - 用户消耗 $1,扣除 $1.5</li><li><b>2.0</b> - 用户消耗 $1,扣除 $2</li><li><b>0.8</b> - 补贴模式(亏本运营)</li></ul></div><p style="font-size: 13px; color: #6b7280;">建议测试分组设置为 1.0</p></div>',
|
||||
nextBtn: '下一步'
|
||||
},
|
||||
groupExclusive: {
|
||||
title: '🔒 4. 专属分组(可选)',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">控制分组的可见性和访问权限。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>🔐 权限说明:</b><ul style="margin: 8px 0 0 16px;"><li><b>关闭</b> - 公开分组,所有用户可见</li><li><b>开启</b> - 专属分组,仅指定用户可见</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 使用场景:</b>VIP 用户专属、内部测试、特殊客户等</p></div>',
|
||||
description:
|
||||
'<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">控制分组的可见性和访问权限。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>🔐 权限说明:</b><ul style="margin: 8px 0 0 16px;"><li><b>关闭</b> - 公开分组,所有用户可见</li><li><b>开启</b> - 专属分组,仅指定用户可见</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 使用场景:</b>VIP 用户专属、内部测试、特殊客户等</p></div>',
|
||||
nextBtn: '下一步'
|
||||
},
|
||||
groupSubmit: {
|
||||
title: '✅ 保存分组',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">确认信息无误后,点击创建按钮保存分组。</p><p style="padding: 8px 12px; background: #fef3c7; border-left: 3px solid #f59e0b; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>⚠️ 注意:</b>分组创建后,平台类型不可修改,其他信息可以随时编辑</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>📌 下一步:</b>创建成功后,我们将添加上游账号到这个分组</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"创建"按钮</p></div>'
|
||||
description:
|
||||
'<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">确认信息无误后,点击创建按钮保存分组。</p><p style="padding: 8px 12px; background: #fef3c7; border-left: 3px solid #f59e0b; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>⚠️ 注意:</b>分组创建后,平台类型不可修改,其他信息可以随时编辑</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>📌 下一步:</b>创建成功后,我们将添加上游账号到这个分组</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"创建"按钮</p></div>'
|
||||
},
|
||||
accountManage: {
|
||||
title: '🔗 第二步:添加账号',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;"><b>太棒了!分组已创建成功 🎉</b></p><p style="margin-bottom: 12px;">现在需要添加上游 AI 服务商的账号,让分组能够实际提供服务。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>🔑 账号的作用:</b><ul style="margin: 8px 0 0 16px;"><li>连接到上游 AI 服务(Claude、GPT 等)</li><li>一个分组可以包含多个账号(负载均衡)</li><li>支持 OAuth 和 Session Key 两种方式</li></ul></div><p style="margin-top: 16px; color: #10b981; font-weight: 600;">👉 点击左侧的"账号管理"</p></div>'
|
||||
description:
|
||||
'<div style="line-height: 1.7;"><p style="margin-bottom: 12px;"><b>太棒了!分组已创建成功 🎉</b></p><p style="margin-bottom: 12px;">现在需要添加上游 AI 服务商的账号,让分组能够实际提供服务。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>🔑 账号的作用:</b><ul style="margin: 8px 0 0 16px;"><li>连接到上游 AI 服务(Claude、GPT 等)</li><li>一个分组可以包含多个账号(负载均衡)</li><li>支持 OAuth 和 Session Key 两种方式</li></ul></div><p style="margin-top: 16px; color: #10b981; font-weight: 600;">👉 点击左侧的"账号管理"</p></div>'
|
||||
},
|
||||
createAccount: {
|
||||
title: '➕ 添加新账号',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">点击按钮开始添加您的第一个上游账号。</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 提示:</b>建议使用 OAuth 方式,更安全且无需手动提取密钥</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"添加账号"按钮</p></div>'
|
||||
description:
|
||||
'<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">点击按钮开始添加您的第一个上游账号。</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 提示:</b>建议使用 OAuth 方式,更安全且无需手动提取密钥</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"添加账号"按钮</p></div>'
|
||||
},
|
||||
accountName: {
|
||||
title: '✏️ 1. 账号名称',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">为账号设置一个便于识别的名称。</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 命名建议:</b>"Claude主账号"、"GPT备用1"、"测试账号" 等</p></div>',
|
||||
description:
|
||||
'<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">为账号设置一个便于识别的名称。</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 命名建议:</b>"Claude主账号"、"GPT备用1"、"测试账号" 等</p></div>',
|
||||
nextBtn: '下一步'
|
||||
},
|
||||
accountPlatform: {
|
||||
title: '🤖 2. 选择平台',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">选择该账号对应的服务商平台。</p><p style="padding: 8px 12px; background: #fef3c7; border-left: 3px solid #f59e0b; border-radius: 4px; font-size: 13px;"><b>⚠️ 重要:</b>平台必须与刚才创建的分组平台一致</p></div>',
|
||||
description:
|
||||
'<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">选择该账号对应的服务商平台。</p><p style="padding: 8px 12px; background: #fef3c7; border-left: 3px solid #f59e0b; border-radius: 4px; font-size: 13px;"><b>⚠️ 重要:</b>平台必须与刚才创建的分组平台一致</p></div>',
|
||||
nextBtn: '下一步'
|
||||
},
|
||||
accountType: {
|
||||
title: '🔐 3. 授权方式',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">选择账号的授权方式。</p><div style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>✅ 推荐:OAuth 方式</b><ul style="margin: 8px 0 0 16px;"><li>无需手动提取密钥</li><li>更安全,支持自动刷新</li><li>适用于 Claude Code、ChatGPT OAuth</li></ul></div><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px;"><b>📌 Session Key 方式</b><ul style="margin: 8px 0 0 16px;"><li>需要手动从浏览器提取</li><li>可能需要定期更新</li><li>适用于不支持 OAuth 的平台</li></ul></div></div>',
|
||||
description:
|
||||
'<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">选择账号的授权方式。</p><div style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>✅ 推荐:OAuth 方式</b><ul style="margin: 8px 0 0 16px;"><li>无需手动提取密钥</li><li>更安全,支持自动刷新</li><li>适用于 Claude Code、ChatGPT OAuth</li></ul></div><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px;"><b>📌 Session Key 方式</b><ul style="margin: 8px 0 0 16px;"><li>需要手动从浏览器提取</li><li>可能需要定期更新</li><li>适用于不支持 OAuth 的平台</li></ul></div></div>',
|
||||
nextBtn: '下一步'
|
||||
},
|
||||
accountPriority: {
|
||||
title: '⚖️ 4. 优先级(可选)',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">设置账号的调用优先级。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📊 优先级规则:</b><ul style="margin: 8px 0 0 16px;"><li>数字越小,优先级越高</li><li>系统优先使用低数值账号</li><li>相同优先级则随机选择</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 使用场景:</b>主账号设置低数值,备用账号设置高数值</p></div>',
|
||||
description:
|
||||
'<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">设置账号的调用优先级。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📊 优先级规则:</b><ul style="margin: 8px 0 0 16px;"><li>数字越小,优先级越高</li><li>系统优先使用低数值账号</li><li>相同优先级则随机选择</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 使用场景:</b>主账号设置低数值,备用账号设置高数值</p></div>',
|
||||
nextBtn: '下一步'
|
||||
},
|
||||
accountGroups: {
|
||||
title: '🎯 5. 分配分组',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;"><b>关键步骤!</b>将账号分配到刚才创建的分组。</p><div style="padding: 8px 12px; background: #fee2e2; border-left: 3px solid #ef4444; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>⚠️ 重要提醒:</b><ul style="margin: 8px 0 0 16px;"><li>必须勾选至少一个分组</li><li>未分配分组的账号无法使用</li><li>一个账号可以分配给多个分组</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 提示:</b>请勾选刚才创建的测试分组</p></div>',
|
||||
description:
|
||||
'<div style="line-height: 1.7;"><p style="margin-bottom: 12px;"><b>关键步骤!</b>将账号分配到刚才创建的分组。</p><div style="padding: 8px 12px; background: #fee2e2; border-left: 3px solid #ef4444; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>⚠️ 重要提醒:</b><ul style="margin: 8px 0 0 16px;"><li>必须勾选至少一个分组</li><li>未分配分组的账号无法使用</li><li>一个账号可以分配给多个分组</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 提示:</b>请勾选刚才创建的测试分组</p></div>',
|
||||
nextBtn: '下一步'
|
||||
},
|
||||
accountSubmit: {
|
||||
title: '✅ 保存账号',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">确认信息无误后,点击保存按钮。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📌 OAuth 授权流程:</b><ul style="margin: 8px 0 0 16px;"><li>点击保存后会跳转到服务商页面</li><li>在服务商页面完成登录授权</li><li>授权成功后自动返回</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>📌 下一步:</b>账号添加成功后,我们将创建 API 密钥</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"保存"按钮</p></div>'
|
||||
description:
|
||||
'<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">确认信息无误后,点击保存按钮。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📌 OAuth 授权流程:</b><ul style="margin: 8px 0 0 16px;"><li>点击保存后会跳转到服务商页面</li><li>在服务商页面完成登录授权</li><li>授权成功后自动返回</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>📌 下一步:</b>账号添加成功后,我们将创建 API 密钥</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"保存"按钮</p></div>'
|
||||
},
|
||||
keyManage: {
|
||||
title: '🔑 第三步:生成密钥',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;"><b>恭喜!账号配置完成 🎉</b></p><p style="margin-bottom: 12px;">最后一步,生成 API Key 来测试服务是否正常工作。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>🔑 API Key 的作用:</b><ul style="margin: 8px 0 0 16px;"><li>用于调用 AI 服务的凭证</li><li>每个 Key 绑定一个分组</li><li>可以设置配额和有效期</li><li>支持独立的使用统计</li></ul></div><p style="margin-top: 16px; color: #10b981; font-weight: 600;">👉 点击左侧的"API 密钥"</p></div>'
|
||||
description:
|
||||
'<div style="line-height: 1.7;"><p style="margin-bottom: 12px;"><b>恭喜!账号配置完成 🎉</b></p><p style="margin-bottom: 12px;">最后一步,生成 API Key 来测试服务是否正常工作。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>🔑 API Key 的作用:</b><ul style="margin: 8px 0 0 16px;"><li>用于调用 AI 服务的凭证</li><li>每个 Key 绑定一个分组</li><li>可以设置配额和有效期</li><li>支持独立的使用统计</li></ul></div><p style="margin-top: 16px; color: #10b981; font-weight: 600;">👉 点击左侧的"API 密钥"</p></div>'
|
||||
},
|
||||
createKey: {
|
||||
title: '➕ 创建密钥',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">点击按钮创建您的第一个 API Key。</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 提示:</b>创建后请立即复制保存,密钥只显示一次</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"创建密钥"按钮</p></div>'
|
||||
description:
|
||||
'<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">点击按钮创建您的第一个 API Key。</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 提示:</b>创建后请立即复制保存,密钥只显示一次</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"创建密钥"按钮</p></div>'
|
||||
},
|
||||
keyName: {
|
||||
title: '✏️ 1. 密钥名称',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">为密钥设置一个便于管理的名称。</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 命名建议:</b>"测试密钥"、"生产环境"、"移动端" 等</p></div>',
|
||||
description:
|
||||
'<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">为密钥设置一个便于管理的名称。</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 命名建议:</b>"测试密钥"、"生产环境"、"移动端" 等</p></div>',
|
||||
nextBtn: '下一步'
|
||||
},
|
||||
keyGroup: {
|
||||
title: '🎯 2. 选择分组',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">选择刚才配置好的分组。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📌 分组决定:</b><ul style="margin: 8px 0 0 16px;"><li>该密钥可以使用哪些账号</li><li>计费倍率是多少</li><li>是否为专属密钥</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 提示:</b>选择刚才创建的测试分组</p></div>',
|
||||
description:
|
||||
'<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">选择刚才配置好的分组。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📌 分组决定:</b><ul style="margin: 8px 0 0 16px;"><li>该密钥可以使用哪些账号</li><li>计费倍率是多少</li><li>是否为专属密钥</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 提示:</b>选择刚才创建的测试分组</p></div>',
|
||||
nextBtn: '下一步'
|
||||
},
|
||||
keySubmit: {
|
||||
title: '🎉 生成并复制',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">点击创建后,系统会生成完整的 API Key。</p><div style="padding: 8px 12px; background: #fee2e2; border-left: 3px solid #ef4444; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>⚠️ 重要提醒:</b><ul style="margin: 8px 0 0 16px;"><li>密钥只显示一次,请立即复制</li><li>丢失后需要重新生成</li><li>妥善保管,不要泄露给他人</li></ul></div><div style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>🚀 下一步:</b><ul style="margin: 8px 0 0 16px;"><li>复制生成的 sk-xxx 密钥</li><li>在支持 OpenAI 接口的客户端中使用</li><li>开始体验 AI 服务!</li></ul></div><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"创建"按钮</p></div>'
|
||||
description:
|
||||
'<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">点击创建后,系统会生成完整的 API Key。</p><div style="padding: 8px 12px; background: #fee2e2; border-left: 3px solid #ef4444; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>⚠️ 重要提醒:</b><ul style="margin: 8px 0 0 16px;"><li>密钥只显示一次,请立即复制</li><li>丢失后需要重新生成</li><li>妥善保管,不要泄露给他人</li></ul></div><div style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>🚀 下一步:</b><ul style="margin: 8px 0 0 16px;"><li>复制生成的 sk-xxx 密钥</li><li>在支持 OpenAI 接口的客户端中使用</li><li>开始体验 AI 服务!</li></ul></div><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"创建"按钮</p></div>'
|
||||
}
|
||||
},
|
||||
// User tour steps
|
||||
user: {
|
||||
welcome: {
|
||||
title: '👋 欢迎使用 Sub2API',
|
||||
description: '<div style="line-height: 1.8;"><p style="margin-bottom: 16px;">您好!欢迎来到 Sub2API AI 服务平台。</p><p style="margin-bottom: 12px;"><b>🎯 快速开始:</b></p><ul style="margin-left: 20px; margin-bottom: 16px;"><li>🔑 创建 API 密钥</li><li>📋 复制密钥到您的应用</li><li>🚀 开始使用 AI 服务</li></ul><p style="color: #10b981; font-weight: 600;">只需 1 分钟,让我们开始吧 →</p></div>',
|
||||
description:
|
||||
'<div style="line-height: 1.8;"><p style="margin-bottom: 16px;">您好!欢迎来到 Sub2API AI 服务平台。</p><p style="margin-bottom: 12px;"><b>🎯 快速开始:</b></p><ul style="margin-left: 20px; margin-bottom: 16px;"><li>🔑 创建 API 密钥</li><li>📋 复制密钥到您的应用</li><li>🚀 开始使用 AI 服务</li></ul><p style="color: #10b981; font-weight: 600;">只需 1 分钟,让我们开始吧 →</p></div>',
|
||||
nextBtn: '开始 🚀',
|
||||
prevBtn: '跳过'
|
||||
},
|
||||
keyManage: {
|
||||
title: '🔑 API 密钥管理',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">在这里管理您的所有 API 访问密钥。</p><p style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px;"><b>📌 什么是 API 密钥?</b><br/>API 密钥是您访问 AI 服务的凭证,就像一把钥匙,让您的应用能够调用 AI 能力。</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击进入密钥页面</p></div>'
|
||||
description:
|
||||
'<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">在这里管理您的所有 API 访问密钥。</p><p style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px;"><b>📌 什么是 API 密钥?</b><br/>API 密钥是您访问 AI 服务的凭证,就像一把钥匙,让您的应用能够调用 AI 能力。</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击进入密钥页面</p></div>'
|
||||
},
|
||||
createKey: {
|
||||
title: '➕ 创建新密钥',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">点击按钮创建您的第一个 API 密钥。</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 提示:</b>创建后密钥只显示一次,请务必复制保存</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"创建密钥"</p></div>'
|
||||
description:
|
||||
'<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">点击按钮创建您的第一个 API 密钥。</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 提示:</b>创建后密钥只显示一次,请务必复制保存</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"创建密钥"</p></div>'
|
||||
},
|
||||
keyName: {
|
||||
title: '✏️ 密钥名称',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">为密钥起一个便于识别的名称。</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 示例:</b>"我的第一个密钥"、"测试用" 等</p></div>',
|
||||
description:
|
||||
'<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">为密钥起一个便于识别的名称。</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 示例:</b>"我的第一个密钥"、"测试用" 等</p></div>',
|
||||
nextBtn: '下一步'
|
||||
},
|
||||
keyGroup: {
|
||||
title: '🎯 选择分组',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">选择管理员为您分配的服务分组。</p><p style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px;"><b>📌 分组说明:</b><br/>不同分组可能有不同的服务质量和计费标准,请根据需要选择。</p></div>',
|
||||
description:
|
||||
'<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">选择管理员为您分配的服务分组。</p><p style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px;"><b>📌 分组说明:</b><br/>不同分组可能有不同的服务质量和计费标准,请根据需要选择。</p></div>',
|
||||
nextBtn: '下一步'
|
||||
},
|
||||
keySubmit: {
|
||||
title: '🎉 完成创建',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">点击确认创建您的 API 密钥。</p><div style="padding: 8px 12px; background: #fee2e2; border-left: 3px solid #ef4444; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>⚠️ 重要:</b><ul style="margin: 8px 0 0 16px;"><li>创建后请立即复制密钥(sk-xxx)</li><li>密钥只显示一次,丢失需重新生成</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>🚀 如何使用:</b><br/>将密钥配置到支持 OpenAI 接口的任何客户端(如 ChatBox、OpenCat 等),即可开始使用!</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"创建"按钮</p></div>'
|
||||
description:
|
||||
'<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">点击确认创建您的 API 密钥。</p><div style="padding: 8px 12px; background: #fee2e2; border-left: 3px solid #ef4444; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>⚠️ 重要:</b><ul style="margin: 8px 0 0 16px;"><li>创建后请立即复制密钥(sk-xxx)</li><li>密钥只显示一次,丢失需重新生成</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>🚀 如何使用:</b><br/>将密钥配置到支持 OpenAI 接口的任何客户端(如 ChatBox、OpenCat 等),即可开始使用!</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"创建"按钮</p></div>'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -381,9 +381,12 @@ export interface ApiKey {
|
||||
key: string
|
||||
name: string
|
||||
group_id: number | null
|
||||
status: 'active' | 'inactive'
|
||||
status: 'active' | 'inactive' | 'quota_exhausted' | 'expired'
|
||||
ip_whitelist: string[]
|
||||
ip_blacklist: string[]
|
||||
quota: number // Quota limit in USD (0 = unlimited)
|
||||
quota_used: number // Used quota amount in USD
|
||||
expires_at: string | null // Expiration time (null = never expires)
|
||||
created_at: string
|
||||
updated_at: string
|
||||
group?: Group
|
||||
@@ -395,6 +398,8 @@ export interface CreateApiKeyRequest {
|
||||
custom_key?: string // Optional custom API Key
|
||||
ip_whitelist?: string[]
|
||||
ip_blacklist?: string[]
|
||||
quota?: number // Quota limit in USD (0 = unlimited)
|
||||
expires_in_days?: number // Days until expiry (null = never expires)
|
||||
}
|
||||
|
||||
export interface UpdateApiKeyRequest {
|
||||
@@ -403,6 +408,9 @@ export interface UpdateApiKeyRequest {
|
||||
status?: 'active' | 'inactive'
|
||||
ip_whitelist?: string[]
|
||||
ip_blacklist?: string[]
|
||||
quota?: number // Quota limit in USD (null = no change, 0 = unlimited)
|
||||
expires_at?: string | null // Expiration time (null = no change)
|
||||
reset_quota?: boolean // Reset quota_used to 0
|
||||
}
|
||||
|
||||
export interface CreateGroupRequest {
|
||||
|
||||
@@ -300,8 +300,29 @@
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-balance="{ value }">
|
||||
<span class="font-medium text-gray-900 dark:text-white">${{ value.toFixed(2) }}</span>
|
||||
<template #cell-balance="{ value, row }">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="group relative">
|
||||
<button
|
||||
class="font-medium text-gray-900 underline decoration-dashed decoration-gray-300 underline-offset-4 transition-colors hover:text-primary-600 dark:text-white dark:decoration-dark-500 dark:hover:text-primary-400"
|
||||
@click="handleBalanceHistory(row)"
|
||||
>
|
||||
${{ value.toFixed(2) }}
|
||||
</button>
|
||||
<!-- Instant tooltip -->
|
||||
<div class="pointer-events-none absolute bottom-full left-1/2 z-50 mb-1.5 -translate-x-1/2 whitespace-nowrap rounded bg-gray-900 px-2 py-1 text-xs text-white opacity-0 shadow-lg transition-opacity duration-75 group-hover:opacity-100 dark:bg-dark-600">
|
||||
{{ t('admin.users.balanceHistoryTip') }}
|
||||
<div class="absolute left-1/2 top-full -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-dark-600"></div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click.stop="handleDeposit(row)"
|
||||
class="rounded px-2 py-0.5 text-xs font-medium text-emerald-600 transition-colors hover:bg-emerald-50 dark:text-emerald-400 dark:hover:bg-emerald-900/20"
|
||||
:title="t('admin.users.deposit')"
|
||||
>
|
||||
{{ t('admin.users.deposit') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-usage="{ row }">
|
||||
@@ -456,6 +477,15 @@
|
||||
{{ t('admin.users.withdraw') }}
|
||||
</button>
|
||||
|
||||
<!-- Balance History -->
|
||||
<button
|
||||
@click="handleBalanceHistory(user); closeActionMenu()"
|
||||
class="flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
|
||||
>
|
||||
<Icon name="dollar" size="sm" class="text-gray-400" :stroke-width="2" />
|
||||
{{ t('admin.users.balanceHistory') }}
|
||||
</button>
|
||||
|
||||
<div class="my-1 border-t border-gray-100 dark:border-dark-700"></div>
|
||||
|
||||
<!-- Delete (not for admin) -->
|
||||
@@ -479,6 +509,7 @@
|
||||
<UserApiKeysModal :show="showApiKeysModal" :user="viewingUser" @close="closeApiKeysModal" />
|
||||
<UserAllowedGroupsModal :show="showAllowedGroupsModal" :user="allowedGroupsUser" @close="closeAllowedGroupsModal" @success="loadUsers" />
|
||||
<UserBalanceModal :show="showBalanceModal" :user="balanceUser" :operation="balanceOperation" @close="closeBalanceModal" @success="loadUsers" />
|
||||
<UserBalanceHistoryModal :show="showBalanceHistoryModal" :user="balanceHistoryUser" @close="closeBalanceHistoryModal" @deposit="handleDepositFromHistory" @withdraw="handleWithdrawFromHistory" />
|
||||
<UserAttributesConfigModal :show="showAttributesModal" @close="handleAttributesModalClose" />
|
||||
</AppLayout>
|
||||
</template>
|
||||
@@ -509,6 +540,7 @@ import UserEditModal from '@/components/admin/user/UserEditModal.vue'
|
||||
import UserApiKeysModal from '@/components/admin/user/UserApiKeysModal.vue'
|
||||
import UserAllowedGroupsModal from '@/components/admin/user/UserAllowedGroupsModal.vue'
|
||||
import UserBalanceModal from '@/components/admin/user/UserBalanceModal.vue'
|
||||
import UserBalanceHistoryModal from '@/components/admin/user/UserBalanceHistoryModal.vue'
|
||||
|
||||
const appStore = useAppStore()
|
||||
|
||||
@@ -828,6 +860,10 @@ const showBalanceModal = ref(false)
|
||||
const balanceUser = ref<AdminUser | null>(null)
|
||||
const balanceOperation = ref<'add' | 'subtract'>('add')
|
||||
|
||||
// Balance History modal state
|
||||
const showBalanceHistoryModal = ref(false)
|
||||
const balanceHistoryUser = ref<AdminUser | null>(null)
|
||||
|
||||
// 计算剩余天数
|
||||
const getDaysRemaining = (expiresAt: string): number => {
|
||||
const now = new Date()
|
||||
@@ -1078,6 +1114,30 @@ const closeBalanceModal = () => {
|
||||
balanceUser.value = null
|
||||
}
|
||||
|
||||
const handleBalanceHistory = (user: AdminUser) => {
|
||||
balanceHistoryUser.value = user
|
||||
showBalanceHistoryModal.value = true
|
||||
}
|
||||
|
||||
const closeBalanceHistoryModal = () => {
|
||||
showBalanceHistoryModal.value = false
|
||||
balanceHistoryUser.value = null
|
||||
}
|
||||
|
||||
// Handle deposit from balance history modal
|
||||
const handleDepositFromHistory = () => {
|
||||
if (balanceHistoryUser.value) {
|
||||
handleDeposit(balanceHistoryUser.value)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle withdraw from balance history modal
|
||||
const handleWithdrawFromHistory = () => {
|
||||
if (balanceHistoryUser.value) {
|
||||
handleWithdraw(balanceHistoryUser.value)
|
||||
}
|
||||
}
|
||||
|
||||
// 滚动时关闭菜单
|
||||
const handleScroll = () => {
|
||||
closeActionMenu()
|
||||
|
||||
@@ -108,12 +108,53 @@
|
||||
${{ (usageStats[row.id]?.total_actual_cost ?? 0).toFixed(4) }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- Quota progress (if quota is set) -->
|
||||
<div v-if="row.quota > 0" class="mt-1.5">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('keys.quota') }}:</span>
|
||||
<span :class="[
|
||||
'font-medium',
|
||||
row.quota_used >= row.quota ? 'text-red-500' :
|
||||
row.quota_used >= row.quota * 0.8 ? 'text-yellow-500' :
|
||||
'text-gray-900 dark:text-white'
|
||||
]">
|
||||
${{ row.quota_used?.toFixed(2) || '0.00' }} / ${{ row.quota?.toFixed(2) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-1 h-1.5 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-dark-600">
|
||||
<div
|
||||
:class="[
|
||||
'h-full rounded-full transition-all',
|
||||
row.quota_used >= row.quota ? 'bg-red-500' :
|
||||
row.quota_used >= row.quota * 0.8 ? 'bg-yellow-500' :
|
||||
'bg-primary-500'
|
||||
]"
|
||||
:style="{ width: Math.min((row.quota_used / row.quota) * 100, 100) + '%' }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-expires_at="{ value }">
|
||||
<span v-if="value" :class="[
|
||||
'text-sm',
|
||||
new Date(value) < new Date() ? 'text-red-500 dark:text-red-400' : 'text-gray-500 dark:text-dark-400'
|
||||
]">
|
||||
{{ formatDateTime(value) }}
|
||||
</span>
|
||||
<span v-else class="text-sm text-gray-400 dark:text-dark-500">{{ t('keys.noExpiration') }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-status="{ value }">
|
||||
<span :class="['badge', value === 'active' ? 'badge-success' : 'badge-gray']">
|
||||
{{ t('admin.accounts.status.' + value) }}
|
||||
<span :class="[
|
||||
'badge',
|
||||
value === 'active' ? 'badge-success' :
|
||||
value === 'quota_exhausted' ? 'badge-warning' :
|
||||
value === 'expired' ? 'badge-danger' :
|
||||
'badge-gray'
|
||||
]">
|
||||
{{ t('keys.status.' + value) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
@@ -334,6 +375,145 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quota Limit Section -->
|
||||
<div class="space-y-3">
|
||||
<label class="input-label">{{ t('keys.quotaLimit') }}</label>
|
||||
<!-- Switch commented out - always show input, 0 = unlimited
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="input-label mb-0">{{ t('keys.quotaLimit') }}</label>
|
||||
<button
|
||||
type="button"
|
||||
@click="formData.enable_quota = !formData.enable_quota"
|
||||
:class="[
|
||||
'relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none',
|
||||
formData.enable_quota ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||
formData.enable_quota ? 'translate-x-4' : 'translate-x-0'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
-->
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<div class="relative">
|
||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">$</span>
|
||||
<input
|
||||
v-model.number="formData.quota"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="input pl-7"
|
||||
:placeholder="t('keys.quotaAmountPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
<p class="input-hint">{{ t('keys.quotaAmountHint') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Quota used display (only in edit mode) -->
|
||||
<div v-if="showEditModal && selectedKey && selectedKey.quota > 0">
|
||||
<label class="input-label">{{ t('keys.quotaUsed') }}</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex-1 rounded-lg bg-gray-100 px-3 py-2 dark:bg-dark-700">
|
||||
<span class="font-medium text-gray-900 dark:text-white">
|
||||
${{ selectedKey.quota_used?.toFixed(4) || '0.0000' }}
|
||||
</span>
|
||||
<span class="mx-2 text-gray-400">/</span>
|
||||
<span class="text-gray-500 dark:text-gray-400">
|
||||
${{ selectedKey.quota?.toFixed(2) || '0.00' }}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="confirmResetQuota"
|
||||
class="btn btn-secondary text-sm"
|
||||
:title="t('keys.resetQuotaUsed')"
|
||||
>
|
||||
{{ t('keys.reset') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Expiration Section -->
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="input-label mb-0">{{ t('keys.expiration') }}</label>
|
||||
<button
|
||||
type="button"
|
||||
@click="formData.enable_expiration = !formData.enable_expiration"
|
||||
:class="[
|
||||
'relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none',
|
||||
formData.enable_expiration ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||
formData.enable_expiration ? 'translate-x-4' : 'translate-x-0'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="formData.enable_expiration" class="space-y-4 pt-2">
|
||||
<!-- Quick select buttons (for both create and edit mode) -->
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="days in ['7', '30', '90']"
|
||||
:key="days"
|
||||
type="button"
|
||||
@click="setExpirationDays(parseInt(days))"
|
||||
:class="[
|
||||
'rounded-lg px-3 py-1.5 text-sm transition-colors',
|
||||
formData.expiration_preset === days
|
||||
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-700 dark:text-gray-400 dark:hover:bg-dark-600'
|
||||
]"
|
||||
>
|
||||
{{ showEditModal ? t('keys.extendDays', { days }) : t('keys.expiresInDays', { days }) }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="formData.expiration_preset = 'custom'"
|
||||
:class="[
|
||||
'rounded-lg px-3 py-1.5 text-sm transition-colors',
|
||||
formData.expiration_preset === 'custom'
|
||||
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-700 dark:text-gray-400 dark:hover:bg-dark-600'
|
||||
]"
|
||||
>
|
||||
{{ t('keys.customDate') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Date picker (always show for precise adjustment) -->
|
||||
<div>
|
||||
<label class="input-label">{{ t('keys.expirationDate') }}</label>
|
||||
<input
|
||||
v-model="formData.expiration_date"
|
||||
type="datetime-local"
|
||||
class="input"
|
||||
/>
|
||||
<p class="input-hint">{{ t('keys.expirationDateHint') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Current expiration display (only in edit mode) -->
|
||||
<div v-if="showEditModal && selectedKey?.expires_at" class="text-sm">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('keys.currentExpiration') }}: </span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">
|
||||
{{ formatDateTime(selectedKey.expires_at) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
@@ -391,6 +571,18 @@
|
||||
@cancel="showDeleteDialog = false"
|
||||
/>
|
||||
|
||||
<!-- Reset Quota Confirmation Dialog -->
|
||||
<ConfirmDialog
|
||||
:show="showResetQuotaDialog"
|
||||
:title="t('keys.resetQuotaTitle')"
|
||||
:message="t('keys.resetQuotaConfirmMessage', { name: selectedKey?.name, used: selectedKey?.quota_used?.toFixed(4) })"
|
||||
:confirm-text="t('keys.reset')"
|
||||
:cancel-text="t('common.cancel')"
|
||||
:danger="true"
|
||||
@confirm="resetQuotaUsed"
|
||||
@cancel="showResetQuotaDialog = false"
|
||||
/>
|
||||
|
||||
<!-- Use Key Modal -->
|
||||
<UseKeyModal
|
||||
:show="showUseKeyModal"
|
||||
@@ -514,6 +706,13 @@ import type { Column } from '@/components/common/types'
|
||||
import type { BatchApiKeyUsageStats } from '@/api/usage'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
|
||||
// Helper to format date for datetime-local input
|
||||
const formatDateTimeLocal = (isoDate: string): string => {
|
||||
const date = new Date(isoDate)
|
||||
const pad = (n: number) => n.toString().padStart(2, '0')
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||
}
|
||||
|
||||
interface GroupOption {
|
||||
value: number
|
||||
label: string
|
||||
@@ -532,6 +731,7 @@ const columns = computed<Column[]>(() => [
|
||||
{ key: 'key', label: t('keys.apiKey'), sortable: false },
|
||||
{ key: 'group', label: t('keys.group'), sortable: false },
|
||||
{ key: 'usage', label: t('keys.usage'), sortable: false },
|
||||
{ key: 'expires_at', label: t('keys.expiresAt'), sortable: true },
|
||||
{ key: 'status', label: t('common.status'), sortable: true },
|
||||
{ key: 'created_at', label: t('keys.created'), sortable: true },
|
||||
{ key: 'actions', label: t('common.actions'), sortable: false }
|
||||
@@ -553,6 +753,7 @@ const pagination = ref({
|
||||
const showCreateModal = ref(false)
|
||||
const showEditModal = ref(false)
|
||||
const showDeleteDialog = ref(false)
|
||||
const showResetQuotaDialog = ref(false)
|
||||
const showUseKeyModal = ref(false)
|
||||
const showCcsClientSelect = ref(false)
|
||||
const pendingCcsRow = ref<ApiKey | null>(null)
|
||||
@@ -587,7 +788,13 @@ const formData = ref({
|
||||
custom_key: '',
|
||||
enable_ip_restriction: false,
|
||||
ip_whitelist: '',
|
||||
ip_blacklist: ''
|
||||
ip_blacklist: '',
|
||||
// Quota settings (empty = unlimited)
|
||||
enable_quota: false,
|
||||
quota: null as number | null,
|
||||
enable_expiration: false,
|
||||
expiration_preset: '30' as '7' | '30' | '90' | 'custom',
|
||||
expiration_date: ''
|
||||
})
|
||||
|
||||
// 自定义Key验证
|
||||
@@ -724,15 +931,21 @@ const handlePageSizeChange = (pageSize: number) => {
|
||||
const editKey = (key: ApiKey) => {
|
||||
selectedKey.value = key
|
||||
const hasIPRestriction = (key.ip_whitelist?.length > 0) || (key.ip_blacklist?.length > 0)
|
||||
const hasExpiration = !!key.expires_at
|
||||
formData.value = {
|
||||
name: key.name,
|
||||
group_id: key.group_id,
|
||||
status: key.status,
|
||||
status: key.status === 'quota_exhausted' || key.status === 'expired' ? 'inactive' : key.status,
|
||||
use_custom_key: false,
|
||||
custom_key: '',
|
||||
enable_ip_restriction: hasIPRestriction,
|
||||
ip_whitelist: (key.ip_whitelist || []).join('\n'),
|
||||
ip_blacklist: (key.ip_blacklist || []).join('\n')
|
||||
ip_blacklist: (key.ip_blacklist || []).join('\n'),
|
||||
enable_quota: key.quota > 0,
|
||||
quota: key.quota > 0 ? key.quota : null,
|
||||
enable_expiration: hasExpiration,
|
||||
expiration_preset: 'custom',
|
||||
expiration_date: key.expires_at ? formatDateTimeLocal(key.expires_at) : ''
|
||||
}
|
||||
showEditModal.value = true
|
||||
}
|
||||
@@ -820,6 +1033,28 @@ const handleSubmit = async () => {
|
||||
const ipWhitelist = formData.value.enable_ip_restriction ? parseIPList(formData.value.ip_whitelist) : []
|
||||
const ipBlacklist = formData.value.enable_ip_restriction ? parseIPList(formData.value.ip_blacklist) : []
|
||||
|
||||
// Calculate quota value (null/empty/0 = unlimited, stored as 0)
|
||||
const quota = formData.value.quota && formData.value.quota > 0 ? formData.value.quota : 0
|
||||
|
||||
// Calculate expiration
|
||||
let expiresInDays: number | undefined
|
||||
let expiresAt: string | null | undefined
|
||||
if (formData.value.enable_expiration && formData.value.expiration_date) {
|
||||
if (!showEditModal.value) {
|
||||
// Create mode: calculate days from date
|
||||
const expDate = new Date(formData.value.expiration_date)
|
||||
const now = new Date()
|
||||
const diffDays = Math.ceil((expDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
|
||||
expiresInDays = diffDays > 0 ? diffDays : 1
|
||||
} else {
|
||||
// Edit mode: use custom date directly
|
||||
expiresAt = new Date(formData.value.expiration_date).toISOString()
|
||||
}
|
||||
} else if (showEditModal.value) {
|
||||
// Edit mode: if expiration disabled or date cleared, send empty string to clear
|
||||
expiresAt = ''
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
if (showEditModal.value && selectedKey.value) {
|
||||
@@ -828,12 +1063,22 @@ const handleSubmit = async () => {
|
||||
group_id: formData.value.group_id,
|
||||
status: formData.value.status,
|
||||
ip_whitelist: ipWhitelist,
|
||||
ip_blacklist: ipBlacklist
|
||||
ip_blacklist: ipBlacklist,
|
||||
quota: quota,
|
||||
expires_at: expiresAt
|
||||
})
|
||||
appStore.showSuccess(t('keys.keyUpdatedSuccess'))
|
||||
} else {
|
||||
const customKey = formData.value.use_custom_key ? formData.value.custom_key : undefined
|
||||
await keysAPI.create(formData.value.name, formData.value.group_id, customKey, ipWhitelist, ipBlacklist)
|
||||
await keysAPI.create(
|
||||
formData.value.name,
|
||||
formData.value.group_id,
|
||||
customKey,
|
||||
ipWhitelist,
|
||||
ipBlacklist,
|
||||
quota,
|
||||
expiresInDays
|
||||
)
|
||||
appStore.showSuccess(t('keys.keyCreatedSuccess'))
|
||||
// Only advance tour if active, on submit step, and creation succeeded
|
||||
if (onboardingStore.isCurrentStep('[data-tour="key-form-submit"]')) {
|
||||
@@ -883,7 +1128,42 @@ const closeModals = () => {
|
||||
custom_key: '',
|
||||
enable_ip_restriction: false,
|
||||
ip_whitelist: '',
|
||||
ip_blacklist: ''
|
||||
ip_blacklist: '',
|
||||
enable_quota: false,
|
||||
quota: null,
|
||||
enable_expiration: false,
|
||||
expiration_preset: '30',
|
||||
expiration_date: ''
|
||||
}
|
||||
}
|
||||
|
||||
// Show reset quota confirmation dialog
|
||||
const confirmResetQuota = () => {
|
||||
showResetQuotaDialog.value = true
|
||||
}
|
||||
|
||||
// Set expiration date based on quick select days
|
||||
const setExpirationDays = (days: number) => {
|
||||
formData.value.expiration_preset = days.toString() as '7' | '30' | '90'
|
||||
const expDate = new Date()
|
||||
expDate.setDate(expDate.getDate() + days)
|
||||
formData.value.expiration_date = formatDateTimeLocal(expDate.toISOString())
|
||||
}
|
||||
|
||||
// Reset quota used for an API key
|
||||
const resetQuotaUsed = async () => {
|
||||
if (!selectedKey.value) return
|
||||
showResetQuotaDialog.value = false
|
||||
try {
|
||||
await keysAPI.update(selectedKey.value.id, { reset_quota: true })
|
||||
appStore.showSuccess(t('keys.quotaResetSuccess'))
|
||||
// Update local state
|
||||
if (selectedKey.value) {
|
||||
selectedKey.value.quota_used = 0
|
||||
}
|
||||
} catch (error: any) {
|
||||
const errorMsg = error.response?.data?.detail || t('keys.failedToResetQuota')
|
||||
appStore.showError(errorMsg)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user