Unverified Commit 4cce21b1 authored by Wesley Liddick's avatar Wesley Liddick Committed by GitHub
Browse files

Merge branch 'main' into main

parents 0707f3d9 c0c9c984
-- Migration: Add quota fields to api_keys table
-- This migration adds independent quota and expiration support for API keys
-- Add quota limit field (0 = unlimited)
ALTER TABLE api_keys ADD COLUMN IF NOT EXISTS quota DECIMAL(20, 8) NOT NULL DEFAULT 0;
-- Add used quota amount field
ALTER TABLE api_keys ADD COLUMN IF NOT EXISTS quota_used DECIMAL(20, 8) NOT NULL DEFAULT 0;
-- Add expiration time field (NULL = never expires)
ALTER TABLE api_keys ADD COLUMN IF NOT EXISTS expires_at TIMESTAMPTZ;
-- Add indexes for efficient quota queries
CREATE INDEX IF NOT EXISTS idx_api_keys_quota_quota_used ON api_keys(quota, quota_used) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_api_keys_expires_at ON api_keys(expires_at) WHERE deleted_at IS NULL;
-- Comment on columns for documentation
COMMENT ON COLUMN api_keys.quota IS 'Quota limit in USD for this API key (0 = unlimited)';
COMMENT ON COLUMN api_keys.quota_used IS 'Used quota amount in USD';
COMMENT ON COLUMN api_keys.expires_at IS 'Expiration time for this API key (null = never expires)';
//go:build tools
// +build tools
package tools
import (
_ "entgo.io/ent/cmd/ent"
_ "github.com/google/wire/cmd/wire"
)
......@@ -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
......
<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>&nbsp;</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'
......
This diff is collapsed.
......@@ -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)
}
}
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment