Commit 706af292 authored by shaw's avatar shaw
Browse files

Merge branch 'fix/account-filters-menu-missing-features'

## Summary
- 修复账号管理页面组件拆分时遗漏的功能
- 统一所有内联 SVG 为 Icon 组件
- 修复 ProxySelector 选择"无代理"时发送错误值的问题

## Changes
- AccountTableFilters: 添加 Antigravity 平台选项、类型筛选器、inactive 状态
- AccountActionMenu: 恢复重置状态和清除限速按钮
- AccountsView: 修正 handleClearRateLimit 调用正确的 API
- ProxySelector: 修复选择"无代理"时发送 null 而不是 0

## Conflict Resolution
- ProxySelector.vue: 采用 PR 分支的正确逻辑(发送 null 而不是 0)
  这是正确的修复,因为后端使用 *int64 类型,nil 会触发 ClearProxyID()
parents 4d078a88 b6a41829
......@@ -6,47 +6,47 @@
<div class="space-y-3 p-4">
<button @click="router.push('/keys')" class="group flex w-full items-center gap-4 rounded-xl bg-gray-50 p-4 text-left transition-all duration-200 hover:bg-gray-100 dark:bg-dark-800/50 dark:hover:bg-dark-800">
<div class="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl bg-primary-100 transition-transform group-hover:scale-105 dark:bg-primary-900/30">
<svg class="h-6 w-6 text-primary-600 dark:text-primary-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" />
</svg>
<Icon name="key" size="lg" class="text-primary-600 dark:text-primary-400" />
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ t('dashboard.createApiKey') }}</p>
<p class="text-xs text-gray-500 dark:text-dark-400">{{ t('dashboard.generateNewKey') }}</p>
</div>
<svg class="h-5 w-5 text-gray-400 transition-colors group-hover:text-primary-500 dark:text-dark-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
<Icon
name="chevronRight"
size="md"
class="text-gray-400 transition-colors group-hover:text-primary-500 dark:text-dark-500"
/>
</button>
<button @click="router.push('/usage')" class="group flex w-full items-center gap-4 rounded-xl bg-gray-50 p-4 text-left transition-all duration-200 hover:bg-gray-100 dark:bg-dark-800/50 dark:hover:bg-dark-800">
<div class="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl bg-emerald-100 transition-transform group-hover:scale-105 dark:bg-emerald-900/30">
<svg class="h-6 w-6 text-emerald-600 dark:text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" />
</svg>
<Icon name="chart" size="lg" class="text-emerald-600 dark:text-emerald-400" />
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ t('dashboard.viewUsage') }}</p>
<p class="text-xs text-gray-500 dark:text-dark-400">{{ t('dashboard.checkDetailedLogs') }}</p>
</div>
<svg class="h-5 w-5 text-gray-400 transition-colors group-hover:text-emerald-500 dark:text-dark-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
<Icon
name="chevronRight"
size="md"
class="text-gray-400 transition-colors group-hover:text-emerald-500 dark:text-dark-500"
/>
</button>
<button @click="router.push('/redeem')" class="group flex w-full items-center gap-4 rounded-xl bg-gray-50 p-4 text-left transition-all duration-200 hover:bg-gray-100 dark:bg-dark-800/50 dark:hover:bg-dark-800">
<div class="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl bg-amber-100 transition-transform group-hover:scale-105 dark:bg-amber-900/30">
<svg class="h-6 w-6 text-amber-600 dark:text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 11.25v8.25a1.5 1.5 0 01-1.5 1.5H5.25a1.5 1.5 0 01-1.5-1.5v-8.25M12 4.875A2.625 2.625 0 109.375 7.5H12m0-2.625V7.5m0-2.625A2.625 2.625 0 1114.625 7.5H12m0 0V21m-8.625-9.75h18c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125h-18c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
</svg>
<Icon name="gift" size="lg" class="text-amber-600 dark:text-amber-400" />
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ t('dashboard.redeemCode') }}</p>
<p class="text-xs text-gray-500 dark:text-dark-400">{{ t('dashboard.addBalance') }}</p>
</div>
<svg class="h-5 w-5 text-gray-400 transition-colors group-hover:text-amber-500 dark:text-dark-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
<Icon
name="chevronRight"
size="md"
class="text-gray-400 transition-colors group-hover:text-amber-500 dark:text-dark-500"
/>
</button>
</div>
</div>
......@@ -55,6 +55,7 @@
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import Icon from '@/components/icons/Icon.vue'
const router = useRouter()
const { t } = useI18n()
</script>
......@@ -15,9 +15,7 @@
<div v-for="log in data" :key="log.id" class="flex items-center justify-between rounded-xl bg-gray-50 p-4 transition-colors hover:bg-gray-100 dark:bg-dark-800/50 dark:hover:bg-dark-800">
<div class="flex items-center gap-4">
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-primary-100 dark:bg-primary-900/30">
<svg class="h-5 w-5 text-primary-600 dark:text-primary-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.75 3.104v5.714a2.25 2.25 0 01-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 014.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0112 15a9.065 9.065 0 00-6.23.693L5 14.5m14.8.8l1.402 1.402c1.232 1.232.65 3.318-1.067 3.611A48.309 48.309 0 0112 21c-2.773 0-5.491-.235-8.135-.687-1.718-.293-2.3-2.379-1.067-3.61L5 14.5" />
</svg>
<Icon name="beaker" size="md" class="text-primary-600 dark:text-primary-400" />
</div>
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ log.model }}</p>
......@@ -35,9 +33,7 @@
<router-link to="/usage" class="flex items-center justify-center gap-2 py-3 text-sm font-medium text-primary-600 transition-colors hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300">
{{ t('dashboard.viewAllUsage') }}
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
</svg>
<Icon name="arrowRight" size="sm" />
</router-link>
</div>
</div>
......@@ -48,6 +44,7 @@
import { useI18n } from 'vue-i18n'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import Icon from '@/components/icons/Icon.vue'
import { formatDateTime } from '@/utils/format'
import type { UsageLog } from '@/types'
......
......@@ -21,9 +21,7 @@
<div class="card p-4">
<div class="flex items-center gap-3">
<div class="rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30">
<svg class="h-5 w-5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" />
</svg>
<Icon name="key" size="md" class="text-blue-600 dark:text-blue-400" :stroke-width="2" />
</div>
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('dashboard.apiKeys') }}</p>
......@@ -37,9 +35,7 @@
<div class="card p-4">
<div class="flex items-center gap-3">
<div class="rounded-lg bg-green-100 p-2 dark:bg-green-900/30">
<svg class="h-5 w-5 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" />
</svg>
<Icon name="chart" size="md" class="text-green-600 dark:text-green-400" :stroke-width="2" />
</div>
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('dashboard.todayRequests') }}</p>
......@@ -53,9 +49,7 @@
<div class="card p-4">
<div class="flex items-center gap-3">
<div class="rounded-lg bg-purple-100 p-2 dark:bg-purple-900/30">
<svg class="h-5 w-5 text-purple-600 dark:text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v12m-3-2.818l.879.659c1.171.879 3.07.879 4.242 0 1.172-.879 1.172-2.303 0-3.182C13.536 12.219 12.768 12 12 12c-.725 0-1.45-.22-2.003-.659-1.106-.879-1.106-2.303 0-3.182s2.9-.879 4.006 0l.415.33M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<Icon name="dollar" size="md" class="text-purple-600 dark:text-purple-400" :stroke-width="2" />
</div>
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('dashboard.todayCost') }}</p>
......@@ -79,9 +73,7 @@
<div class="card p-4">
<div class="flex items-center gap-3">
<div class="rounded-lg bg-amber-100 p-2 dark:bg-amber-900/30">
<svg class="h-5 w-5 text-amber-600 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m21 7.5-9-5.25L3 7.5m18 0-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9" />
</svg>
<Icon name="cube" size="md" class="text-amber-600 dark:text-amber-400" :stroke-width="2" />
</div>
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('dashboard.todayTokens') }}</p>
......@@ -95,9 +87,7 @@
<div class="card p-4">
<div class="flex items-center gap-3">
<div class="rounded-lg bg-indigo-100 p-2 dark:bg-indigo-900/30">
<svg class="h-5 w-5 text-indigo-600 dark:text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125" />
</svg>
<Icon name="database" size="md" class="text-indigo-600 dark:text-indigo-400" :stroke-width="2" />
</div>
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('dashboard.totalTokens') }}</p>
......@@ -111,9 +101,7 @@
<div class="card p-4">
<div class="flex items-center gap-3">
<div class="rounded-lg bg-violet-100 p-2 dark:bg-violet-900/30">
<svg class="h-5 w-5 text-violet-600 dark:text-violet-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<Icon name="bolt" size="md" class="text-violet-600 dark:text-violet-400" :stroke-width="2" />
</div>
<div class="flex-1">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('dashboard.performance') }}</p>
......@@ -133,9 +121,7 @@
<div class="card p-4">
<div class="flex items-center gap-3">
<div class="rounded-lg bg-rose-100 p-2 dark:bg-rose-900/30">
<svg class="h-5 w-5 text-rose-600 dark:text-rose-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<Icon name="clock" size="md" class="text-rose-600 dark:text-rose-400" :stroke-width="2" />
</div>
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('dashboard.avgResponse') }}</p>
......@@ -149,6 +135,7 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import Icon from '@/components/icons/Icon.vue'
import type { UserDashboardStats as UserStatsType } from '@/api/usage'
defineProps<{
......
......@@ -30,38 +30,14 @@
<div class="px-6 py-4">
<div class="space-y-3">
<div class="flex items-center gap-3 text-sm text-gray-600 dark:text-gray-400">
<svg
class="h-4 w-4 text-gray-400 dark:text-gray-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75"
/>
</svg>
<Icon name="mail" size="sm" class="text-gray-400 dark:text-gray-500" />
<span class="truncate">{{ user?.email }}</span>
</div>
<div
v-if="user?.username"
class="flex items-center gap-3 text-sm text-gray-600 dark:text-gray-400"
>
<svg
class="h-4 w-4 text-gray-400 dark:text-gray-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z"
/>
</svg>
<Icon name="user" size="sm" class="text-gray-400 dark:text-gray-500" />
<span class="truncate">{{ user.username }}</span>
</div>
</div>
......@@ -71,6 +47,7 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import Icon from '@/components/icons/Icon.vue'
import type { User } from '@/types'
defineProps<{
......
......@@ -462,7 +462,8 @@ export default {
days: ' days',
codeRedeemSuccess: 'Code redeemed successfully!',
failedToRedeem: 'Failed to redeem code. Please check the code and try again.',
subscriptionRefreshFailed: 'Redeemed successfully, but failed to refresh subscription status.'
subscriptionRefreshFailed: 'Redeemed successfully, but failed to refresh subscription status.',
pleaseEnterCode: 'Please enter a redeem code'
},
// Profile
......@@ -658,6 +659,10 @@ export default {
failedToDelete: 'Failed to delete user',
failedToToggle: 'Failed to update user status',
failedToLoadApiKeys: 'Failed to load user API keys',
emailRequired: 'Please enter email',
concurrencyMin: 'Concurrency must be at least 1',
amountRequired: 'Please enter a valid amount',
insufficientBalance: 'Insufficient balance',
deleteConfirm: "Are you sure you want to delete '{email}'? This action cannot be undone.",
setAllowedGroups: 'Set Allowed Groups',
allowedGroupsHint:
......@@ -689,7 +694,6 @@ export default {
failedToDeposit: 'Failed to deposit',
failedToWithdraw: 'Failed to withdraw',
useDepositWithdrawButtons: 'Please use deposit/withdraw buttons to adjust balance',
insufficientBalance: 'Insufficient balance, balance cannot be negative after withdrawal',
roles: {
admin: 'Admin',
user: 'User'
......@@ -749,6 +753,9 @@ export default {
failedToLoad: 'Failed to load attributes',
failedToCreate: 'Failed to create attribute',
failedToUpdate: 'Failed to update attribute',
keyRequired: 'Please enter attribute key',
nameRequired: 'Please enter display name',
optionsRequired: 'Please add at least one option',
failedToDelete: 'Failed to delete attribute',
failedToReorder: 'Failed to update order',
keyExists: 'Attribute key already exists',
......@@ -816,6 +823,7 @@ export default {
failedToCreate: 'Failed to create group',
failedToUpdate: 'Failed to update group',
failedToDelete: 'Failed to delete group',
nameRequired: 'Please enter group name',
platforms: {
all: 'All Platforms',
anthropic: 'Anthropic',
......@@ -904,6 +912,9 @@ export default {
failedToAssign: 'Failed to assign subscription',
failedToExtend: 'Failed to extend subscription',
failedToRevoke: 'Failed to revoke subscription',
pleaseSelectUser: 'Please select a user',
pleaseSelectGroup: 'Please select a group',
validityDaysRequired: 'Please enter a valid number of days (at least 1)',
revokeConfirm:
"Are you sure you want to revoke the subscription for '{user}'? This action cannot be undone."
},
......@@ -1587,6 +1598,9 @@ export default {
failedToUpdate: 'Failed to update proxy',
failedToDelete: 'Failed to delete proxy',
failedToTest: 'Failed to test proxy',
nameRequired: 'Please enter proxy name',
hostRequired: 'Please enter host address',
portInvalid: 'Port must be between 1-65535',
deleteConfirm:
"Are you sure you want to delete '{name}'? Accounts using this proxy will have their proxy removed."
},
......@@ -1644,6 +1658,11 @@ export default {
failedToDelete: 'Failed to delete code',
failedToDeleteUnused: 'Failed to delete unused codes',
failedToCopy: 'Failed to copy codes',
types: {
balance: 'Balance',
concurrency: 'Concurrency',
subscription: 'Subscription'
},
selectGroup: 'Select Group',
selectGroupPlaceholder: 'Choose a subscription group',
validityDays: 'Validity Days',
......
......@@ -459,7 +459,8 @@ export default {
days: '',
codeRedeemSuccess: '兑换成功!',
failedToRedeem: '兑换失败,请检查兑换码后重试。',
subscriptionRefreshFailed: '兑换成功,但订阅状态刷新失败。'
subscriptionRefreshFailed: '兑换成功,但订阅状态刷新失败。',
pleaseEnterCode: '请输入兑换码'
},
// Profile
......@@ -716,6 +717,10 @@ export default {
concurrencyAdjustedSuccess: '并发数调整成功',
failedToSave: '保存用户失败',
failedToAdjust: '调整失败',
emailRequired: '请输入邮箱',
concurrencyMin: '并发数不能小于1',
amountRequired: '请输入有效金额',
insufficientBalance: '余额不足',
setAllowedGroups: '设置允许分组',
allowedGroupsHint: '选择此用户可以使用的标准分组。订阅类型分组请在订阅管理中配置。',
noStandardGroups: '暂无标准分组',
......@@ -742,7 +747,6 @@ export default {
failedToDeposit: '充值失败',
failedToWithdraw: '退款失败',
useDepositWithdrawButtons: '请使用充值/退款按钮调整余额',
insufficientBalance: '余额不足,退款后余额不能为负数',
// Settings Dropdowns
filterSettings: '筛选设置',
columnSettings: '列设置',
......@@ -798,6 +802,9 @@ export default {
failedToLoad: '加载属性列表失败',
failedToCreate: '创建属性失败',
failedToUpdate: '更新属性失败',
keyRequired: '请输入属性键',
nameRequired: '请输入显示名称',
optionsRequired: '请至少添加一个选项',
failedToDelete: '删除属性失败',
failedToReorder: '更新排序失败',
keyExists: '属性键已存在',
......@@ -905,6 +912,7 @@ export default {
groupDeleted: '分组删除成功',
failedToCreate: '创建分组失败',
failedToUpdate: '更新分组失败',
nameRequired: '请输入分组名称',
subscription: {
title: '订阅设置',
type: '计费类型',
......@@ -981,6 +989,9 @@ export default {
failedToAssign: '分配订阅失败',
failedToExtend: '延长订阅失败',
failedToRevoke: '撤销订阅失败',
pleaseSelectUser: '请选择用户',
pleaseSelectGroup: '请选择分组',
validityDaysRequired: '请输入有效的天数(至少1天)',
revokeConfirm: "确定要撤销 '{user}' 的订阅吗?此操作无法撤销。"
},
......@@ -1618,6 +1629,11 @@ export default {
deleteConfirmMessage: "确定要删除代理 '{name}' 吗?",
testProxy: '测试代理',
columns: {
name: '名称',
protocol: '协议',
address: '地址',
status: '状态',
actions: '操作',
nameLabel: '名称',
namePlaceholder: '请输入代理名称',
protocolLabel: '协议',
......@@ -1696,6 +1712,9 @@ export default {
failedToCreate: '创建代理失败',
failedToUpdate: '更新代理失败',
failedToTest: '测试代理失败',
nameRequired: '请输入代理名称',
hostRequired: '请输入主机地址',
portInvalid: '端口必须在 1-65535 之间',
deleteConfirm: "确定要删除代理 '{name}' 吗?使用此代理的账号将被移除代理设置。"
},
......
......@@ -45,19 +45,7 @@
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 dark:text-dark-400 dark:hover:bg-dark-800 dark:hover:text-white"
:title="t('home.viewDocs')"
>
<svg
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25"
/>
</svg>
<Icon name="book" size="md" />
</a>
<!-- Theme Toggle -->
......@@ -66,34 +54,8 @@
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 dark:text-dark-400 dark:hover:bg-dark-800 dark:hover:text-white"
:title="isDark ? t('home.switchToLight') : t('home.switchToDark')"
>
<svg
v-if="isDark"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z"
/>
</svg>
<svg
v-else
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z"
/>
</svg>
<Icon v-if="isDark" name="sun" size="md" />
<Icon v-else name="moon" size="md" />
</button>
<!-- Login / Dashboard Button -->
......@@ -156,19 +118,7 @@
class="btn btn-primary px-8 py-3 text-base shadow-lg shadow-primary-500/30"
>
{{ isAuthenticated ? t('home.goToDashboard') : t('home.getStarted') }}
<svg
class="ml-2 h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3"
/>
</svg>
<Icon name="arrowRight" size="md" class="ml-2" :stroke-width="2" />
</router-link>
</div>
</div>
......@@ -216,19 +166,7 @@
<div
class="inline-flex items-center gap-2.5 rounded-full border border-gray-200/50 bg-white/80 px-5 py-2.5 shadow-sm backdrop-blur-sm dark:border-dark-700/50 dark:bg-dark-800/80"
>
<svg
class="h-4 w-4 text-primary-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M7.5 21L3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5"
/>
</svg>
<Icon name="swap" size="sm" class="text-primary-500" />
<span class="text-sm font-medium text-gray-700 dark:text-dark-200">{{
t('home.tags.subscriptionToApi')
}}</span>
......@@ -236,19 +174,7 @@
<div
class="inline-flex items-center gap-2.5 rounded-full border border-gray-200/50 bg-white/80 px-5 py-2.5 shadow-sm backdrop-blur-sm dark:border-dark-700/50 dark:bg-dark-800/80"
>
<svg
class="h-4 w-4 text-primary-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z"
/>
</svg>
<Icon name="shield" size="sm" class="text-primary-500" />
<span class="text-sm font-medium text-gray-700 dark:text-dark-200">{{
t('home.tags.stickySession')
}}</span>
......@@ -256,19 +182,7 @@
<div
class="inline-flex items-center gap-2.5 rounded-full border border-gray-200/50 bg-white/80 px-5 py-2.5 shadow-sm backdrop-blur-sm dark:border-dark-700/50 dark:bg-dark-800/80"
>
<svg
class="h-4 w-4 text-primary-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z"
/>
</svg>
<Icon name="chart" size="sm" class="text-primary-500" />
<span class="text-sm font-medium text-gray-700 dark:text-dark-200">{{
t('home.tags.realtimeBilling')
}}</span>
......@@ -284,19 +198,7 @@
<div
class="mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-blue-500 to-blue-600 shadow-lg shadow-blue-500/30 transition-transform group-hover:scale-110"
>
<svg
class="h-6 w-6 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z"
/>
</svg>
<Icon name="server" size="lg" class="text-white" />
</div>
<h3 class="mb-2 text-lg font-semibold text-gray-900 dark:text-white">
{{ t('home.features.unifiedGateway') }}
......@@ -493,6 +395,7 @@ import { useI18n } from 'vue-i18n'
import { getPublicSettings } from '@/api/auth'
import { useAuthStore } from '@/stores'
import LocaleSwitcher from '@/components/common/LocaleSwitcher.vue'
import Icon from '@/components/icons/Icon.vue'
import { sanitizeUrl } from '@/utils/url'
const { t } = useI18n()
......
......@@ -54,35 +54,11 @@
<!-- Action Buttons -->
<div class="flex flex-col justify-center gap-3 sm:flex-row">
<button @click="goBack" class="btn btn-secondary">
<svg
class="mr-2 h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18"
/>
</svg>
<Icon name="arrowLeft" size="md" class="mr-2" />
Go Back
</button>
<router-link to="/dashboard" class="btn btn-primary">
<svg
class="mr-2 h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25"
/>
</svg>
<Icon name="home" size="md" class="mr-2" />
Go to Dashboard
</router-link>
</div>
......@@ -104,6 +80,7 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import Icon from '@/components/icons/Icon.vue'
const { t } = useI18n()
const router = useRouter()
......
......@@ -3,15 +3,12 @@
<TablePageLayout>
<template #filters>
<div class="flex flex-wrap-reverse items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<AccountTableFilters
v-model:searchQuery="params.search"
:filters="params"
@change="reload"
@update:searchQuery="debouncedReload"
/>
</div>
<div class="flex-shrink-0">
<AccountTableActions
:loading="loading"
@refresh="load"
......@@ -19,7 +16,6 @@
@create="showCreate = true"
/>
</div>
</div>
</template>
<template #table>
<AccountBulkActionsBar :selected-ids="selIds" @delete="handleBulkDelete" @edit="showBulkEdit = true" @clear="selIds = []" @select-page="selectPage" />
......@@ -201,10 +197,10 @@ const handleViewStats = (a: Account) => { statsAcc.value = a; showStats.value =
const handleReAuth = (a: Account) => { reAuthAcc.value = a; showReAuth.value = true }
const handleRefresh = async (a: Account) => { try { await adminAPI.accounts.refreshCredentials(a.id); load() } catch {} }
const handleResetStatus = async (a: Account) => { try { await adminAPI.accounts.clearError(a.id); appStore.showSuccess(t('common.success')); load() } catch {} }
const handleClearRateLimit = async (a: Account) => { try { await adminAPI.accounts.clearError(a.id); appStore.showSuccess(t('common.success')); load() } catch {} }
const handleClearRateLimit = async (a: Account) => { try { await adminAPI.accounts.clearRateLimit(a.id); appStore.showSuccess(t('common.success')); load() } catch {} }
const handleDelete = (a: Account) => { deletingAcc.value = a; showDeleteDialog.value = true }
const confirmDelete = async () => { if(!deletingAcc.value) return; try { await adminAPI.accounts.delete(deletingAcc.value.id); showDeleteDialog.value = false; deletingAcc.value = null; reload() } catch {} }
const handleToggleSchedulable = async (a: Account) => { togglingSchedulable.value = a.id; try { await adminAPI.accounts.update(a.id, { schedulable: !a.schedulable }); load() } finally { togglingSchedulable.value = null } }
const handleToggleSchedulable = async (a: Account) => { togglingSchedulable.value = a.id; try { await adminAPI.accounts.setSchedulable(a.id, !a.schedulable); load() } finally { togglingSchedulable.value = null } }
const handleShowTempUnsched = (a: Account) => { tempUnschedAcc.value = a; showTempUnsched.value = true }
const handleTempUnschedReset = async () => { if(!tempUnschedAcc.value) return; try { await adminAPI.accounts.clearError(tempUnschedAcc.value.id); showTempUnsched.value = false; tempUnschedAcc.value = null; load() } catch {} }
......
......@@ -13,19 +13,7 @@
<div class="card p-4">
<div class="flex items-center gap-3">
<div class="rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30">
<svg
class="h-5 w-5 text-blue-600 dark:text-blue-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"
/>
</svg>
<Icon name="key" size="md" class="text-blue-600 dark:text-blue-400" :stroke-width="2" />
</div>
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">
......@@ -45,19 +33,7 @@
<div class="card p-4">
<div class="flex items-center gap-3">
<div class="rounded-lg bg-purple-100 p-2 dark:bg-purple-900/30">
<svg
class="h-5 w-5 text-purple-600 dark:text-purple-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z"
/>
</svg>
<Icon name="server" size="md" class="text-purple-600 dark:text-purple-400" :stroke-width="2" />
</div>
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">
......@@ -82,19 +58,7 @@
<div class="card p-4">
<div class="flex items-center gap-3">
<div class="rounded-lg bg-green-100 p-2 dark:bg-green-900/30">
<svg
class="h-5 w-5 text-green-600 dark:text-green-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z"
/>
</svg>
<Icon name="chart" size="md" class="text-green-600 dark:text-green-400" :stroke-width="2" />
</div>
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">
......@@ -114,19 +78,7 @@
<div class="card p-4">
<div class="flex items-center gap-3">
<div class="rounded-lg bg-emerald-100 p-2 dark:bg-emerald-900/30">
<svg
class="h-5 w-5 text-emerald-600 dark:text-emerald-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"
/>
</svg>
<Icon name="userPlus" size="md" class="text-emerald-600 dark:text-emerald-400" :stroke-width="2" />
</div>
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">
......@@ -149,19 +101,7 @@
<div class="card p-4">
<div class="flex items-center gap-3">
<div class="rounded-lg bg-amber-100 p-2 dark:bg-amber-900/30">
<svg
class="h-5 w-5 text-amber-600 dark:text-amber-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="m21 7.5-9-5.25L3 7.5m18 0-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9"
/>
</svg>
<Icon name="cube" size="md" class="text-amber-600 dark:text-amber-400" :stroke-width="2" />
</div>
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">
......@@ -191,19 +131,7 @@
<div class="card p-4">
<div class="flex items-center gap-3">
<div class="rounded-lg bg-indigo-100 p-2 dark:bg-indigo-900/30">
<svg
class="h-5 w-5 text-indigo-600 dark:text-indigo-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125"
/>
</svg>
<Icon name="database" size="md" class="text-indigo-600 dark:text-indigo-400" :stroke-width="2" />
</div>
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">
......@@ -233,19 +161,7 @@
<div class="card p-4">
<div class="flex items-center gap-3">
<div class="rounded-lg bg-violet-100 p-2 dark:bg-violet-900/30">
<svg
class="h-5 w-5 text-violet-600 dark:text-violet-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
<Icon name="bolt" size="md" class="text-violet-600 dark:text-violet-400" :stroke-width="2" />
</div>
<div class="flex-1">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">
......@@ -271,19 +187,7 @@
<div class="card p-4">
<div class="flex items-center gap-3">
<div class="rounded-lg bg-rose-100 p-2 dark:bg-rose-900/30">
<svg
class="h-5 w-5 text-rose-600 dark:text-rose-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<Icon name="clock" size="md" class="text-rose-600 dark:text-rose-400" :stroke-width="2" />
</div>
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">
......@@ -367,6 +271,7 @@ import { adminAPI } from '@/api/admin'
import type { DashboardStats, TrendDataPoint, ModelStat, UserUsageTrendPoint } from '@/types'
import AppLayout from '@/components/layout/AppLayout.vue'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import Icon from '@/components/icons/Icon.vue'
import DateRangePicker from '@/components/common/DateRangePicker.vue'
import Select from '@/components/common/Select.vue'
import ModelDistributionChart from '@/components/charts/ModelDistributionChart.vue'
......
......@@ -5,20 +5,12 @@
<div class="flex flex-col justify-between gap-4 lg:flex-row lg:items-start">
<!-- Left: fuzzy search + filters (can wrap to multiple lines) -->
<div class="flex flex-1 flex-wrap items-center gap-3">
<div class="relative w-full sm:w-72 lg:w-80">
<svg
class="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
<div class="relative w-full sm:w-64">
<Icon
name="search"
size="md"
class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500"
/>
</svg>
<input
v-model="searchQuery"
type="text"
......@@ -57,38 +49,14 @@
class="btn btn-secondary"
:title="t('common.refresh')"
>
<svg
:class="['h-5 w-5', loading ? 'animate-spin' : '']"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
</button>
<button
@click="showCreateModal = true"
class="btn btn-primary"
data-tour="groups-create-btn"
>
<svg
class="mr-2 h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 4.5v15m7.5-7.5h-15"
/>
</svg>
<Icon name="plus" size="md" class="mr-2" />
{{ t('admin.groups.createGroup') }}
</button>
</div>
......@@ -201,38 +169,14 @@
@click="handleEdit(row)"
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400"
>
<svg
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
/>
</svg>
<Icon name="edit" size="sm" />
<span class="text-xs">{{ t('common.edit') }}</span>
</button>
<button
@click="handleDelete(row)"
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
>
<svg
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
/>
</svg>
<Icon name="trash" size="sm" />
<span class="text-xs">{{ t('common.delete') }}</span>
</button>
</div>
......@@ -318,15 +262,12 @@
</label>
<!-- Help Tooltip -->
<div class="group relative inline-flex">
<svg
class="h-3.5 w-3.5 cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<Icon
name="questionCircle"
size="sm"
:stroke-width="2"
class="cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400"
/>
<!-- Tooltip Popover -->
<div class="pointer-events-none absolute bottom-full left-0 z-50 mb-2 w-72 opacity-0 transition-all duration-200 group-hover:pointer-events-auto group-hover:opacity-100">
<div class="rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800">
......@@ -336,7 +277,7 @@
</p>
<div class="rounded bg-gray-800 p-2 dark:bg-gray-700">
<p class="text-xs leading-relaxed text-gray-300">
<span class="text-primary-400">💡 {{ t('admin.groups.exclusiveTooltip.example') }}</span>
<span class="inline-flex items-center gap-1 text-primary-400"><Icon name="lightbulb" size="xs" /> {{ t('admin.groups.exclusiveTooltip.example') }}</span>
{{ t('admin.groups.exclusiveTooltip.exampleContent') }}
</p>
</div>
......@@ -513,15 +454,12 @@
</label>
<!-- Help Tooltip -->
<div class="group relative inline-flex">
<svg
class="h-3.5 w-3.5 cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<Icon
name="questionCircle"
size="sm"
:stroke-width="2"
class="cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400"
/>
<!-- Tooltip Popover -->
<div class="pointer-events-none absolute bottom-full left-0 z-50 mb-2 w-72 opacity-0 transition-all duration-200 group-hover:pointer-events-auto group-hover:opacity-100">
<div class="rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800">
......@@ -531,7 +469,7 @@
</p>
<div class="rounded bg-gray-800 p-2 dark:bg-gray-700">
<p class="text-xs leading-relaxed text-gray-300">
<span class="text-primary-400">💡 {{ t('admin.groups.exclusiveTooltip.example') }}</span>
<span class="inline-flex items-center gap-1 text-primary-400"><Icon name="lightbulb" size="xs" /> {{ t('admin.groups.exclusiveTooltip.example') }}</span>
{{ t('admin.groups.exclusiveTooltip.exampleContent') }}
</p>
</div>
......@@ -691,6 +629,7 @@ import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import Select from '@/components/common/Select.vue'
import PlatformIcon from '@/components/common/PlatformIcon.vue'
import Icon from '@/components/icons/Icon.vue'
const { t } = useI18n()
const appStore = useAppStore()
......@@ -871,6 +810,10 @@ const closeCreateModal = () => {
}
const handleCreateGroup = async () => {
if (!createForm.name.trim()) {
appStore.showError(t('admin.groups.nameRequired'))
return
}
submitting.value = true
try {
await adminAPI.groups.create(createForm)
......@@ -912,6 +855,10 @@ const closeEditModal = () => {
const handleUpdateGroup = async () => {
if (!editingGroup.value) return
if (!editForm.name.trim()) {
appStore.showError(t('admin.groups.nameRequired'))
return
}
submitting.value = true
try {
......
......@@ -7,20 +7,12 @@
<!-- Left: Fuzzy search + filters (wrap to multiple lines) -->
<div class="flex flex-1 flex-wrap items-center gap-3">
<!-- Search -->
<div class="relative w-full sm:flex-1 sm:min-w-[14rem] sm:max-w-md">
<svg
class="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
<div class="relative w-full sm:w-64">
<Icon
name="search"
size="md"
class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500"
/>
</svg>
<input
v-model="searchQuery"
type="text"
......@@ -57,34 +49,10 @@
class="btn btn-secondary"
:title="t('common.refresh')"
>
<svg
:class="['h-5 w-5', loading ? 'animate-spin' : '']"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
</button>
<button @click="showCreateModal = true" class="btn btn-primary">
<svg
class="mr-2 h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 4.5v15m7.5-7.5h-15"
/>
</svg>
<Icon name="plus" size="md" class="mr-2" />
{{ t('admin.proxies.createProxy') }}
</button>
</div>
......@@ -144,58 +112,21 @@
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<svg
v-else
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<Icon v-else name="checkCircle" size="sm" />
<span class="text-xs">{{ t('admin.proxies.testConnection') }}</span>
</button>
<button
@click="handleEdit(row)"
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400"
>
<svg
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
/>
</svg>
<Icon name="edit" size="sm" />
<span class="text-xs">{{ t('common.edit') }}</span>
</button>
<button
@click="handleDelete(row)"
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
>
<svg
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
/>
</svg>
<Icon name="trash" size="sm" />
<span class="text-xs">{{ t('common.delete') }}</span>
</button>
</div>
......@@ -243,15 +174,7 @@
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
]"
>
<svg
class="mr-1.5 inline h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
<Icon name="plus" size="sm" class="mr-1.5 inline" />
{{ t('admin.proxies.standardAdd') }}
</button>
<button
......@@ -367,37 +290,18 @@
<div v-if="batchParseResult.total > 0" class="rounded-lg bg-gray-50 p-4 dark:bg-dark-700">
<div class="flex items-center gap-4 text-sm">
<div class="flex items-center gap-1.5">
<svg
class="h-4 w-4 text-primary-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<Icon name="checkCircle" size="sm" :stroke-width="2" class="text-primary-500" />
<span class="text-gray-700 dark:text-gray-300">
{{ t('admin.proxies.parsedCount', { count: batchParseResult.valid }) }}
</span>
</div>
<div v-if="batchParseResult.invalid > 0" class="flex items-center gap-1.5">
<svg
class="h-4 w-4 text-amber-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"
<Icon
name="exclamationCircle"
size="sm"
:stroke-width="2"
class="text-amber-500"
/>
</svg>
<span class="text-amber-600 dark:text-amber-400">
{{ t('admin.proxies.invalidCount', { count: batchParseResult.invalid }) }}
</span>
......@@ -621,6 +525,7 @@ import BaseDialog from '@/components/common/BaseDialog.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import Select from '@/components/common/Select.vue'
import Icon from '@/components/icons/Icon.vue'
const { t } = useI18n()
const appStore = useAppStore()
......@@ -887,6 +792,18 @@ const handleBatchCreate = async () => {
}
const handleCreateProxy = async () => {
if (!createForm.name.trim()) {
appStore.showError(t('admin.proxies.nameRequired'))
return
}
if (!createForm.host.trim()) {
appStore.showError(t('admin.proxies.hostRequired'))
return
}
if (createForm.port < 1 || createForm.port > 65535) {
appStore.showError(t('admin.proxies.portInvalid'))
return
}
submitting.value = true
try {
await adminAPI.proxies.create({
......@@ -927,6 +844,18 @@ const closeEditModal = () => {
const handleUpdateProxy = async () => {
if (!editingProxy.value) return
if (!editForm.name.trim()) {
appStore.showError(t('admin.proxies.nameRequired'))
return
}
if (!editForm.host.trim()) {
appStore.showError(t('admin.proxies.hostRequired'))
return
}
if (editForm.port < 1 || editForm.port > 65535) {
appStore.showError(t('admin.proxies.portInvalid'))
return
}
submitting.value = true
try {
......
......@@ -9,19 +9,7 @@
class="btn btn-secondary"
:title="t('common.refresh')"
>
<svg
:class="['h-5 w-5', loading ? 'animate-spin' : '']"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
</button>
<button @click="showGenerateDialog = true" class="btn btn-primary">
{{ t('admin.redeem.generateCodes') }}
......@@ -75,20 +63,7 @@
]"
:title="copiedCode === value ? t('admin.redeem.copied') : t('keys.copyToClipboard')"
>
<svg
v-if="copiedCode !== value"
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg>
<Icon v-if="copiedCode !== value" name="copy" size="sm" :stroke-width="2" />
<svg v-else class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
......@@ -341,14 +316,7 @@
@click="closeResultDialog"
class="rounded-lg p-1.5 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-dark-700 dark:hover:text-gray-300"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
<Icon name="x" size="md" :stroke-width="2" />
</button>
</div>
<!-- Content -->
......@@ -373,20 +341,7 @@
copiedAll ? 'btn-success' : 'btn-secondary'
]"
>
<svg
v-if="!copiedAll"
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg>
<Icon v-if="!copiedAll" name="copy" size="sm" :stroke-width="2" />
<svg v-else class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
......@@ -398,14 +353,7 @@
{{ copiedAll ? t('admin.redeem.copied') : t('admin.redeem.copyAll') }}
</button>
<button @click="downloadGeneratedCodes" class="btn btn-primary flex items-center gap-2">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
<Icon name="download" size="sm" :stroke-width="2" />
{{ t('admin.redeem.download') }}
</button>
</div>
......@@ -430,6 +378,7 @@ import DataTable from '@/components/common/DataTable.vue'
import Pagination from '@/components/common/Pagination.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import Select from '@/components/common/Select.vue'
import Icon from '@/components/icons/Icon.vue'
const { t } = useI18n()
const appStore = useAppStore()
......@@ -525,7 +474,8 @@ const filterTypeOptions = computed(() => [
const filterStatusOptions = computed(() => [
{ value: '', label: t('admin.redeem.allStatus') },
{ value: 'unused', label: t('admin.redeem.unused') },
{ value: 'used', label: t('admin.redeem.used') }
{ value: 'used', label: t('admin.redeem.used') },
{ value: 'expired', label: t('admin.redeem.status.expired') }
])
const codes = ref<RedeemCode[]>([])
......
......@@ -24,17 +24,11 @@
class="rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-900/20"
>
<div class="flex items-start">
<svg
class="mt-0.5 h-5 w-5 flex-shrink-0 text-amber-500"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fill-rule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
clip-rule="evenodd"
<Icon
name="exclamationTriangle"
size="md"
class="mt-0.5 flex-shrink-0 text-amber-500"
/>
</svg>
<p class="ml-3 text-sm text-amber-700 dark:text-amber-300">
{{ t('admin.settings.adminApiKey.securityWarning') }}
</p>
......@@ -448,19 +442,7 @@
class="hidden"
@change="handleLogoUpload"
/>
<svg
class="mr-1.5 h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"
/>
</svg>
<Icon name="upload" size="sm" class="mr-1.5" :stroke-width="2" />
{{ t('admin.settings.site.uploadImage') }}
</label>
<button
......@@ -469,19 +451,7 @@
@click="form.site_logo = ''"
class="btn btn-secondary btn-sm text-red-600 hover:text-red-700 dark:text-red-400"
>
<svg
class="mr-1.5 h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
<Icon name="trash" size="sm" class="mr-1.5" :stroke-width="2" />
{{ t('admin.settings.site.remove') }}
</button>
</div>
......@@ -727,6 +697,7 @@ import { useI18n } from 'vue-i18n'
import { adminAPI } from '@/api'
import type { SystemSettings, UpdateSettingsRequest } from '@/api/admin/settings'
import AppLayout from '@/components/layout/AppLayout.vue'
import Icon from '@/components/icons/Icon.vue'
import Toggle from '@/components/common/Toggle.vue'
import { useAppStore } from '@/stores'
......
......@@ -8,22 +8,14 @@
<div class="flex flex-1 flex-wrap items-center gap-3">
<!-- User Search -->
<div
class="relative w-full sm:flex-1 sm:min-w-[14rem] sm:max-w-md"
class="relative w-full sm:w-64"
data-filter-user-search
>
<svg
class="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
<Icon
name="search"
size="md"
class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"
/>
</svg>
<input
v-model="filterUserKeyword"
type="text"
......@@ -39,14 +31,7 @@
class="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
:title="t('common.clear')"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
<Icon name="x" size="sm" :stroke-width="2" />
</button>
<!-- User Dropdown -->
......@@ -106,34 +91,10 @@
class="btn btn-secondary"
:title="t('common.refresh')"
>
<svg
:class="['h-5 w-5', loading ? 'animate-spin' : '']"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
</button>
<button @click="showAssignModal = true" class="btn btn-primary">
<svg
class="mr-2 h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 4.5v15m7.5-7.5h-15"
/>
</svg>
<Icon name="plus" size="md" class="mr-2" />
{{ t('admin.subscriptions.assignSubscription') }}
</button>
</div>
......@@ -343,19 +304,7 @@
@click="handleExtend(row)"
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-green-50 hover:text-green-600 dark:hover:bg-green-900/20 dark:hover:text-green-400"
>
<svg
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<Icon name="clock" size="sm" />
<span class="text-xs">{{ t('admin.subscriptions.extend') }}</span>
</button>
<button
......@@ -363,19 +312,7 @@
@click="handleRevoke(row)"
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
>
<svg
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
/>
</svg>
<Icon name="ban" size="sm" />
<span class="text-xs">{{ t('admin.subscriptions.revoke') }}</span>
</button>
</div>
......@@ -434,14 +371,7 @@
type="button"
class="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
<Icon name="x" size="sm" :stroke-width="2" />
</button>
<!-- User Dropdown -->
<div
......@@ -610,6 +540,7 @@ import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import Select from '@/components/common/Select.vue'
import GroupBadge from '@/components/common/GroupBadge.vue'
import Icon from '@/components/icons/Icon.vue'
const { t } = useI18n()
const appStore = useAppStore()
......@@ -869,7 +800,18 @@ const closeAssignModal = () => {
}
const handleAssignSubscription = async () => {
if (!assignForm.user_id || !assignForm.group_id) return
if (!assignForm.user_id) {
appStore.showError(t('admin.subscriptions.pleaseSelectUser'))
return
}
if (!assignForm.group_id) {
appStore.showError(t('admin.subscriptions.pleaseSelectGroup'))
return
}
if (!assignForm.validity_days || assignForm.validity_days < 1) {
appStore.showError(t('admin.subscriptions.validityDaysRequired'))
return
}
submitting.value = true
try {
......@@ -953,15 +895,17 @@ const isExpiringSoon = (expiresAt: string): boolean => {
return days !== null && days <= 7
}
const getProgressWidth = (used: number, limit: number | null): string => {
const getProgressWidth = (used: number | null | undefined, limit: number | null): string => {
if (!limit || limit === 0) return '0%'
const percentage = Math.min((used / limit) * 100, 100)
const usedValue = used ?? 0
const percentage = Math.min((usedValue / limit) * 100, 100)
return `${percentage}%`
}
const getProgressClass = (used: number, limit: number | null): string => {
const getProgressClass = (used: number | null | undefined, limit: number | null): string => {
if (!limit || limit === 0) return 'bg-gray-400'
const percentage = (used / limit) * 100
const usedValue = used ?? 0
const percentage = (usedValue / limit) * 100
if (percentage >= 90) return 'bg-red-500'
if (percentage >= 70) return 'bg-orange-500'
return 'bg-green-500'
......
......@@ -2,6 +2,21 @@
<AppLayout>
<div class="space-y-6">
<UsageStatsCards :stats="usageStats" />
<!-- Charts Section -->
<div class="space-y-4">
<div class="card p-4">
<div class="flex items-center gap-4">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('admin.dashboard.granularity') }}:</span>
<div class="w-28">
<Select v-model="granularity" :options="granularityOptions" @change="loadChartData" />
</div>
</div>
</div>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<ModelDistributionChart :model-stats="modelStats" :loading="chartsLoading" />
<TokenUsageTrend :trend-data="trendData" :loading="chartsLoading" />
</div>
</div>
<UsageFilters v-model="filters" v-model:startDate="startDate" v-model:endDate="endDate" :exporting="exporting" @change="applyFilters" @reset="resetFilters" @export="exportToExcel" />
<UsageTable :data="usageLogs" :loading="loading" />
<Pagination v-if="pagination.total > 0" :page="pagination.page" :total="pagination.total" :page-size="pagination.page_size" @update:page="handlePageChange" @update:pageSize="handlePageSizeChange" />
......@@ -11,19 +26,24 @@
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, onUnmounted } from 'vue'
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import * as XLSX from 'xlsx'; import { saveAs } from 'file-saver'
import { useAppStore } from '@/stores/app'; import { adminAPI } from '@/api/admin'; import { adminUsageAPI } from '@/api/admin/usage'
import AppLayout from '@/components/layout/AppLayout.vue'; import Pagination from '@/components/common/Pagination.vue'
import AppLayout from '@/components/layout/AppLayout.vue'; import Pagination from '@/components/common/Pagination.vue'; import Select from '@/components/common/Select.vue'
import UsageStatsCards from '@/components/admin/usage/UsageStatsCards.vue'; import UsageFilters from '@/components/admin/usage/UsageFilters.vue'
import UsageTable from '@/components/admin/usage/UsageTable.vue'; import UsageExportProgress from '@/components/admin/usage/UsageExportProgress.vue'
import type { UsageLog } from '@/types'; import type { AdminUsageStatsResponse, AdminUsageQueryParams } from '@/api/admin/usage'
import ModelDistributionChart from '@/components/charts/ModelDistributionChart.vue'; import TokenUsageTrend from '@/components/charts/TokenUsageTrend.vue'
import type { UsageLog, TrendDataPoint, ModelStat } from '@/types'; import type { AdminUsageStatsResponse, AdminUsageQueryParams } from '@/api/admin/usage'
const { t } = useI18n()
const appStore = useAppStore()
const usageStats = ref<AdminUsageStatsResponse | null>(null); const usageLogs = ref<UsageLog[]>([]); const loading = ref(false); const exporting = ref(false)
const trendData = ref<TrendDataPoint[]>([]); const modelStats = ref<ModelStat[]>([]); const chartsLoading = ref(false); const granularity = ref<'day' | 'hour'>('day')
let abortController: AbortController | null = null; let exportAbortController: AbortController | null = null
const exportProgress = reactive({ show: false, progress: 0, current: 0, total: 0, estimatedTime: '' })
const granularityOptions = computed(() => [{ value: 'day', label: t('admin.dashboard.day') }, { value: 'hour', label: t('admin.dashboard.hour') }])
const formatLD = (d: Date) => d.toISOString().split('T')[0]
const now = new Date(); const weekAgo = new Date(Date.now() - 6 * 86400000)
const startDate = ref(formatLD(weekAgo)); const endDate = ref(formatLD(now))
......@@ -38,8 +58,16 @@ const loadLogs = async () => {
} catch {} finally { if(abortController === c) loading.value = false }
}
const loadStats = async () => { try { const s = await adminAPI.usage.getStats(filters.value); usageStats.value = s } catch {} }
const applyFilters = () => { pagination.page = 1; loadLogs(); loadStats() }
const resetFilters = () => { startDate.value = formatLD(weekAgo); endDate.value = formatLD(now); filters.value = { start_date: startDate.value, end_date: endDate.value }; applyFilters() }
const loadChartData = async () => {
chartsLoading.value = true
try {
const params = { start_date: filters.value.start_date || startDate.value, end_date: filters.value.end_date || endDate.value, granularity: granularity.value, user_id: filters.value.user_id }
const [trendRes, modelRes] = await Promise.all([adminAPI.dashboard.getUsageTrend(params), adminAPI.dashboard.getModelStats({ start_date: params.start_date, end_date: params.end_date, user_id: params.user_id })])
trendData.value = trendRes.trend || []; modelStats.value = modelRes.models || []
} catch {} finally { chartsLoading.value = false }
}
const applyFilters = () => { pagination.page = 1; loadLogs(); loadStats(); loadChartData() }
const resetFilters = () => { startDate.value = formatLD(weekAgo); endDate.value = formatLD(now); filters.value = { start_date: startDate.value, end_date: endDate.value }; granularity.value = 'day'; applyFilters() }
const handlePageChange = (p: number) => { pagination.page = p; loadLogs() }
const handlePageSizeChange = (s: number) => { pagination.page_size = s; pagination.page = 1; loadLogs() }
const cancelExport = () => exportAbortController?.abort()
......@@ -65,6 +93,6 @@ const exportToExcel = async () => {
finally { if(exportAbortController === c) { exportAbortController = null; exporting.value = false; exportProgress.show = false } }
}
onMounted(() => { loadLogs(); loadStats() })
onMounted(() => { loadLogs(); loadStats(); loadChartData() })
onUnmounted(() => { abortController?.abort(); exportAbortController?.abort() })
</script>
\ No newline at end of file
This diff is collapsed.
......@@ -19,19 +19,7 @@
>
<div class="flex items-start gap-3">
<div class="flex-shrink-0">
<svg
class="h-5 w-5 text-amber-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"
/>
</svg>
<Icon name="exclamationCircle" size="md" class="text-amber-500" />
</div>
<div class="text-sm text-amber-700 dark:text-amber-400">
<p class="font-medium">{{ t('auth.sessionExpired') }}</p>
......@@ -73,19 +61,7 @@
>
<div class="flex items-start gap-3">
<div class="flex-shrink-0">
<svg
class="h-5 w-5 text-green-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<Icon name="checkCircle" size="md" class="text-green-500" />
</div>
<p class="text-sm text-green-700 dark:text-green-400">
Verification code sent! Please check your inbox.
......@@ -115,19 +91,7 @@
>
<div class="flex items-start gap-3">
<div class="flex-shrink-0">
<svg
class="h-5 w-5 text-red-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"
/>
</svg>
<Icon name="exclamationCircle" size="md" class="text-red-500" />
</div>
<p class="text-sm text-red-700 dark:text-red-400">
{{ errorMessage }}
......@@ -158,20 +122,7 @@
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<svg
v-else
class="mr-2 h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<Icon v-else name="checkCircle" size="md" class="mr-2" />
{{ isLoading ? 'Verifying...' : 'Verify & Create Account' }}
</button>
......@@ -210,19 +161,7 @@
@click="handleBack"
class="flex items-center gap-2 text-gray-500 transition-colors hover:text-gray-700 dark:text-dark-400 dark:hover:text-gray-300"
>
<svg
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18"
/>
</svg>
<Icon name="arrowLeft" size="sm" />
Back to registration
</button>
</template>
......@@ -234,6 +173,7 @@ import { ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { AuthLayout } from '@/components/layout'
import Icon from '@/components/icons/Icon.vue'
import TurnstileWidget from '@/components/TurnstileWidget.vue'
import { useAuthStore, useAppStore } from '@/stores'
import { getPublicSettings, sendVerifyCode } from '@/api/auth'
......
This diff is collapsed.
This diff is collapsed.
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