merge: 合并远程分支并修复代码冲突

合并了远程分支 cb72262 的功能更新,同时保留了 ESLint 修复:

**冲突解决详情:**

1. AccountTableFilters.vue
   -  保留 emit 模式修复(避免 vue/no-mutating-props 错误)
   -  添加第三个筛选器 type(账户类型)
   -  新增 antigravity 平台和 inactive 状态选项

2. UserBalanceModal.vue
   -  保留 console.error 错误日志
   -  添加输入验证(金额校验、余额不足检查)
   -  使用 appStore.showError 向用户显示友好错误

3. AccountsView.vue
   -  保留所有 console.error 错误日志(避免 no-empty 错误)
   -  使用新 API:clearRateLimit 和 setSchedulable

4. UsageView.vue
   -  添加 console.error 错误日志
   -  添加图表功能(模型分布、使用趋势)
   -  添加粒度选择(按天/按小时)
   -  保留 XLSX 动态导入优化

**测试结果:**
-  Go tests: PASS
-  golangci-lint: 0 issues
-  ESLint: 0 errors
-  TypeScript: PASS

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
yangjianbo
2026-01-06 12:50:51 +08:00
121 changed files with 4244 additions and 3233 deletions

View File

@@ -3,12 +3,33 @@
<div v-if="show && position" class="action-menu-content fixed z-[9999] w-52 overflow-hidden rounded-xl bg-white shadow-lg ring-1 ring-black/5 dark:bg-dark-800" :style="{ top: position.top + 'px', left: position.left + 'px' }">
<div class="py-1">
<template v-if="account">
<button @click="$emit('test', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm hover:bg-gray-100"><span class="text-green-500"></span> {{ t('admin.accounts.testConnection') }}</button>
<button @click="$emit('stats', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm hover:bg-gray-100"><span class="text-indigo-500">📊</span> {{ t('admin.accounts.viewStats') }}</button>
<button @click="$emit('test', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-dark-700">
<Icon name="play" size="sm" class="text-green-500" :stroke-width="2" />
{{ t('admin.accounts.testConnection') }}
</button>
<button @click="$emit('stats', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-dark-700">
<Icon name="chart" size="sm" class="text-indigo-500" />
{{ t('admin.accounts.viewStats') }}
</button>
<template v-if="account.type === 'oauth' || account.type === 'setup-token'">
<button @click="$emit('reauth', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm hover:bg-gray-100 text-blue-600">🔗 {{ t('admin.accounts.reAuthorize') }}</button>
<button @click="$emit('refresh-token', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm hover:bg-gray-100 text-purple-600">🔄 {{ t('admin.accounts.refreshToken') }}</button>
<button @click="$emit('reauth', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-blue-600 hover:bg-gray-100 dark:hover:bg-dark-700">
<Icon name="link" size="sm" />
{{ t('admin.accounts.reAuthorize') }}
</button>
<button @click="$emit('refresh-token', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-purple-600 hover:bg-gray-100 dark:hover:bg-dark-700">
<Icon name="refresh" size="sm" />
{{ t('admin.accounts.refreshToken') }}
</button>
</template>
<div v-if="account.status === 'error' || isRateLimited || isOverloaded" class="my-1 border-t border-gray-100 dark:border-dark-700"></div>
<button v-if="account.status === 'error'" @click="$emit('reset-status', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-yellow-600 hover:bg-gray-100 dark:hover:bg-dark-700">
<Icon name="sync" size="sm" />
{{ t('admin.accounts.resetStatus') }}
</button>
<button v-if="isRateLimited || isOverloaded" @click="$emit('clear-rate-limit', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-amber-600 hover:bg-gray-100 dark:hover:bg-dark-700">
<Icon name="clock" size="sm" />
{{ t('admin.accounts.clearRateLimit') }}
</button>
</template>
</div>
</div>
@@ -16,6 +37,14 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
defineProps(['show', 'account', 'position']); defineEmits(['close', 'test', 'stats', 'reauth', 'refresh-token']); const { t } = useI18n()
</script>
import { Icon } from '@/components/icons'
import type { Account } from '@/types'
const props = defineProps<{ show: boolean; account: Account | null; position: { top: number; left: number } | null }>()
defineEmits(['close', 'test', 'stats', 'reauth', 'refresh-token', 'reset-status', 'clear-rate-limit'])
const { t } = useI18n()
const isRateLimited = computed(() => props.account?.rate_limit_reset_at && new Date(props.account.rate_limit_reset_at) > new Date())
const isOverloaded = computed(() => props.account?.overload_until && new Date(props.account.overload_until) > new Date())
</script>

View File

@@ -15,14 +15,7 @@
<div
class="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-primary-500 to-primary-600"
>
<svg class="h-5 w-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
/>
</svg>
<Icon name="chartBar" size="md" class="text-white" />
</div>
<div>
<div class="font-semibold text-gray-900 dark:text-gray-100">{{ account.name }}</div>
@@ -60,19 +53,7 @@
t('admin.accounts.stats.totalCost')
}}</span>
<div class="rounded-lg bg-emerald-100 p-1.5 dark:bg-emerald-900/30">
<svg
class="h-4 w-4 text-emerald-600 dark:text-emerald-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<Icon name="dollar" size="sm" class="text-emerald-600 dark:text-emerald-400" />
</div>
</div>
<p class="text-2xl font-bold text-gray-900 dark:text-white">
@@ -97,19 +78,7 @@
t('admin.accounts.stats.totalRequests')
}}</span>
<div class="rounded-lg bg-blue-100 p-1.5 dark:bg-blue-900/30">
<svg
class="h-4 w-4 text-blue-600 dark:text-blue-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
<Icon name="bolt" size="sm" class="text-blue-600 dark:text-blue-400" />
</div>
</div>
<p class="text-2xl font-bold text-gray-900 dark:text-white">
@@ -129,19 +98,11 @@
t('admin.accounts.stats.avgDailyCost')
}}</span>
<div class="rounded-lg bg-amber-100 p-1.5 dark:bg-amber-900/30">
<svg
class="h-4 w-4 text-amber-600 dark:text-amber-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z"
/>
</svg>
<Icon
name="calculator"
size="sm"
class="text-amber-600 dark:text-amber-400"
/>
</div>
</div>
<p class="text-2xl font-bold text-gray-900 dark:text-white">
@@ -195,19 +156,7 @@
<div class="card p-4">
<div class="mb-3 flex items-center gap-2">
<div class="rounded-lg bg-cyan-100 p-1.5 dark:bg-cyan-900/30">
<svg
class="h-4 w-4 text-cyan-600 dark:text-cyan-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<Icon name="clock" size="sm" class="text-cyan-600 dark:text-cyan-400" />
</div>
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
t('admin.accounts.stats.todayOverview')
@@ -245,19 +194,7 @@
<div class="card p-4">
<div class="mb-3 flex items-center gap-2">
<div class="rounded-lg bg-orange-100 p-1.5 dark:bg-orange-900/30">
<svg
class="h-4 w-4 text-orange-600 dark:text-orange-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17.657 18.657A8 8 0 016.343 7.343S7 9 9 10c0-2 .5-5 2.986-7C14 5 16.09 5.777 17.656 7.343A7.975 7.975 0 0120 13a7.975 7.975 0 01-2.343 5.657z"
/>
</svg>
<Icon name="fire" size="sm" class="text-orange-600 dark:text-orange-400" />
</div>
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
t('admin.accounts.stats.highestCostDay')
@@ -295,19 +232,11 @@
<div class="card p-4">
<div class="mb-3 flex items-center gap-2">
<div class="rounded-lg bg-indigo-100 p-1.5 dark:bg-indigo-900/30">
<svg
class="h-4 w-4 text-indigo-600 dark:text-indigo-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"
/>
</svg>
<Icon
name="trendingUp"
size="sm"
class="text-indigo-600 dark:text-indigo-400"
/>
</div>
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
t('admin.accounts.stats.highestRequestDay')
@@ -348,19 +277,7 @@
<div class="card p-4">
<div class="mb-3 flex items-center gap-2">
<div class="rounded-lg bg-teal-100 p-1.5 dark:bg-teal-900/30">
<svg
class="h-4 w-4 text-teal-600 dark:text-teal-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
/>
</svg>
<Icon name="cube" size="sm" class="text-teal-600 dark:text-teal-400" />
</div>
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
t('admin.accounts.stats.accumulatedTokens')
@@ -390,19 +307,7 @@
<div class="card p-4">
<div class="mb-3 flex items-center gap-2">
<div class="rounded-lg bg-rose-100 p-1.5 dark:bg-rose-900/30">
<svg
class="h-4 w-4 text-rose-600 dark:text-rose-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
<Icon name="bolt" size="sm" class="text-rose-600 dark:text-rose-400" />
</div>
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
t('admin.accounts.stats.performance')
@@ -432,19 +337,11 @@
<div class="card p-4">
<div class="mb-3 flex items-center gap-2">
<div class="rounded-lg bg-lime-100 p-1.5 dark:bg-lime-900/30">
<svg
class="h-4 w-4 text-lime-600 dark:text-lime-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
/>
</svg>
<Icon
name="clipboard"
size="sm"
class="text-lime-600 dark:text-lime-400"
/>
</div>
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
t('admin.accounts.stats.recentActivity')
@@ -504,14 +401,7 @@
v-else-if="!loading"
class="flex flex-col items-center justify-center py-12 text-gray-500 dark:text-gray-400"
>
<svg class="mb-4 h-12 w-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
/>
</svg>
<Icon name="chartBar" size="xl" class="mb-4 h-12 w-12" />
<p class="text-sm">{{ t('admin.accounts.stats.noData') }}</p>
</div>
</div>
@@ -547,6 +437,7 @@ import { Line } from 'vue-chartjs'
import BaseDialog from '@/components/common/BaseDialog.vue'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import ModelDistributionChart from '@/components/charts/ModelDistributionChart.vue'
import Icon from '@/components/icons/Icon.vue'
import { adminAPI } from '@/api/admin'
import type { Account, AccountUsageStatsResponse } from '@/types'

View File

@@ -1,11 +1,19 @@
<template>
<div class="flex max-w-full flex-wrap justify-end gap-3">
<button @click="$emit('refresh')" :disabled="loading" class="btn btn-secondary flex-shrink-0"><svg :class="['h-5 w-5', loading ? 'animate-spin' : '']" fill="none" viewBox="0 0 24 24" stroke="currentColor"><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></button>
<button @click="$emit('sync')" class="btn btn-secondary flex-shrink-0">{{ t('admin.accounts.syncFromCrs') }}</button>
<button @click="$emit('create')" class="btn btn-primary flex-shrink-0">{{ t('admin.accounts.createAccount') }}</button>
<div class="flex flex-wrap items-center gap-3">
<button @click="$emit('refresh')" :disabled="loading" class="btn btn-secondary">
<Icon name="refresh" size="md" :class="[loading ? 'animate-spin' : '']" />
</button>
<button @click="$emit('sync')" class="btn btn-secondary">{{ t('admin.accounts.syncFromCrs') }}</button>
<button @click="$emit('create')" class="btn btn-primary">{{ t('admin.accounts.createAccount') }}</button>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'; defineProps(['loading']); defineEmits(['refresh', 'sync', 'create']); const { t } = useI18n()
import { useI18n } from 'vue-i18n'
import Icon from '@/components/icons/Icon.vue'
defineProps(['loading'])
defineEmits(['refresh', 'sync', 'create'])
const { t } = useI18n()
</script>

View File

@@ -1,17 +1,15 @@
<template>
<div class="flex flex-wrap items-start gap-3">
<div class="min-w-0 flex-1">
<SearchInput
:model-value="searchQuery"
:placeholder="t('admin.accounts.searchAccounts')"
@update:model-value="$emit('update:searchQuery', $event)"
@search="$emit('change')"
/>
</div>
<div class="flex flex-wrap items-center gap-3">
<Select :model-value="filters.platform" class="w-40 flex-shrink-0" :options="pOpts" @update:model-value="updatePlatform" @change="$emit('change')" />
<Select :model-value="filters.status" class="w-40 flex-shrink-0" :options="sOpts" @update:model-value="updateStatus" @change="$emit('change')" />
</div>
<div class="flex flex-wrap items-center gap-3">
<SearchInput
:model-value="searchQuery"
:placeholder="t('admin.accounts.searchAccounts')"
class="w-full sm:w-64"
@update:model-value="$emit('update:searchQuery', $event)"
@search="$emit('change')"
/>
<Select :model-value="filters.platform" class="w-40" :options="pOpts" @update:model-value="updatePlatform" @change="$emit('change')" />
<Select :model-value="filters.type" class="w-40" :options="tOpts" @update:model-value="updateType" @change="$emit('change')" />
<Select :model-value="filters.status" class="w-40" :options="sOpts" @update:model-value="updateStatus" @change="$emit('change')" />
</div>
</template>
@@ -19,7 +17,9 @@
import { computed } from 'vue'; import { useI18n } from 'vue-i18n'; import Select from '@/components/common/Select.vue'; import SearchInput from '@/components/common/SearchInput.vue'
const props = defineProps(['searchQuery', 'filters']); const emit = defineEmits(['update:searchQuery', 'update:filters', 'change']); const { t } = useI18n()
const updatePlatform = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, platform: value }) }
const updateType = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, type: value }) }
const updateStatus = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, status: value }) }
const pOpts = computed(() => [{ value: '', label: t('admin.accounts.allPlatforms') }, { value: 'openai', label: 'OpenAI' }, { value: 'anthropic', label: 'Anthropic' }, { value: 'gemini', label: 'Gemini' }])
const sOpts = computed(() => [{ value: '', label: t('admin.accounts.allStatus') }, { value: 'active', label: t('admin.accounts.status.active') }, { value: 'error', label: t('admin.accounts.status.error') }])
const pOpts = computed(() => [{ value: '', label: t('admin.accounts.allPlatforms') }, { value: 'anthropic', label: 'Anthropic' }, { value: 'openai', label: 'OpenAI' }, { value: 'gemini', label: 'Gemini' }, { value: 'antigravity', label: 'Antigravity' }])
const tOpts = computed(() => [{ value: '', label: t('admin.accounts.allTypes') }, { value: 'oauth', label: t('admin.accounts.oauthType') }, { value: 'setup-token', label: t('admin.accounts.setupToken') }, { value: 'apikey', label: t('admin.accounts.apiKey') }])
const sOpts = computed(() => [{ value: '', label: t('admin.accounts.allStatus') }, { value: 'active', label: t('admin.accounts.status.active') }, { value: 'inactive', label: t('admin.accounts.status.inactive') }, { value: 'error', label: t('admin.accounts.status.error') }])
</script>

View File

@@ -15,14 +15,7 @@
<div
class="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-primary-500 to-primary-600"
>
<svg class="h-5 w-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5.121 17.804A13.937 13.937 0 0112 16c2.5 0 4.847.655 6.879 1.804M15 10a3 3 0 11-6 0 3 3 0 016 0zm6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<Icon name="play" size="md" class="text-white" :stroke-width="2" />
</div>
<div>
<div class="font-semibold text-gray-900 dark:text-gray-100">{{ account.name }}</div>
@@ -70,32 +63,11 @@
>
<!-- Status Line -->
<div v-if="status === 'idle'" class="flex items-center gap-2 text-gray-500">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
<Icon name="play" size="sm" :stroke-width="2" />
<span>{{ t('admin.accounts.readyToTest') }}</span>
</div>
<div v-else-if="status === 'connecting'" class="flex items-center gap-2 text-yellow-400">
<svg class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<Icon name="refresh" size="sm" class="animate-spin" :stroke-width="2" />
<span>{{ t('admin.accounts.connectingToApi') }}</span>
</div>
@@ -114,28 +86,14 @@
v-if="status === 'success'"
class="mt-3 flex items-center gap-2 border-t border-gray-700 pt-3 text-green-400"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<Icon name="check" size="sm" :stroke-width="2" />
<span>{{ t('admin.accounts.testCompleted') }}</span>
</div>
<div
v-else-if="status === 'error'"
class="mt-3 flex items-center gap-2 border-t border-gray-700 pt-3 text-red-400"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<Icon name="x" size="sm" :stroke-width="2" />
<span>{{ errorMessage }}</span>
</div>
</div>
@@ -147,14 +105,7 @@
class="absolute right-2 top-2 rounded-lg bg-gray-800/80 p-1.5 text-gray-400 opacity-0 transition-all hover:bg-gray-700 hover:text-white group-hover:opacity-100"
:title="t('admin.accounts.copyOutput')"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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 name="link" size="sm" :stroke-width="2" />
</button>
</div>
@@ -162,26 +113,12 @@
<div class="flex items-center justify-between px-1 text-xs text-gray-500 dark:text-gray-400">
<div class="flex items-center gap-3">
<span class="flex items-center gap-1">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"
/>
</svg>
<Icon name="grid" size="sm" :stroke-width="2" />
{{ t('admin.accounts.testModel') }}
</span>
</div>
<span class="flex items-center gap-1">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"
/>
</svg>
<Icon name="chat" size="sm" :stroke-width="2" />
{{ t('admin.accounts.testPrompt') }}
</span>
</div>
@@ -210,54 +147,15 @@
: 'bg-primary-500 text-white hover:bg-primary-600'
]"
>
<svg
<Icon
v-if="status === 'connecting'"
class="h-4 w-4 animate-spin"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<svg
v-else-if="status === 'idle'"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<svg v-else class="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 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
name="refresh"
size="sm"
class="animate-spin"
:stroke-width="2"
/>
<Icon v-else-if="status === 'idle'" name="play" size="sm" :stroke-width="2" />
<Icon v-else name="refresh" size="sm" :stroke-width="2" />
<span>
{{
status === 'connecting'
@@ -278,6 +176,7 @@ import { ref, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseDialog from '@/components/common/BaseDialog.vue'
import Select from '@/components/common/Select.vue'
import { Icon } from '@/components/icons'
import { useClipboard } from '@/composables/useClipboard'
import { adminAPI } from '@/api/admin'
import type { Account, ClaudeModel } from '@/types'

View File

@@ -23,19 +23,7 @@
: 'from-orange-500 to-orange-600'
]"
>
<svg
class="h-5 w-5 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"
/>
</svg>
<Icon name="sparkles" size="md" class="text-white" />
</div>
<div>
<span class="block font-semibold text-gray-900 dark:text-white">{{
@@ -107,9 +95,7 @@
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
]"
>
<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="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" />
</div>
<div class="min-w-0">
<span class="block text-sm font-medium text-gray-900 dark:text-white">Google One</span>
@@ -135,19 +121,7 @@
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
]"
>
<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="M2.25 15a4.5 4.5 0 004.5 4.5H18a3.75 3.75 0 001.332-7.257 3 3 0 00-3.758-3.848 5.25 5.25 0 00-10.233 2.33A4.502 4.502 0 002.25 15z"
/>
</svg>
<Icon name="cloud" size="sm" />
</div>
<div class="min-w-0">
<span class="block text-sm font-medium text-gray-900 dark:text-white">
@@ -179,19 +153,7 @@
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
]"
>
<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="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"
/>
</svg>
<Icon name="sparkles" size="sm" />
</div>
<div class="min-w-0">
<span class="block text-sm font-medium text-gray-900 dark:text-white">
@@ -295,6 +257,7 @@ import { useGeminiOAuth } from '@/composables/useGeminiOAuth'
import { useAntigravityOAuth } from '@/composables/useAntigravityOAuth'
import type { Account } from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue'
import Icon from '@/components/icons/Icon.vue'
import OAuthAuthorizationFlow from '@/components/account/OAuthAuthorizationFlow.vue'
// Type for exposed OAuthAuthorizationFlow component

View File

@@ -1,7 +1,9 @@
<template>
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
<div class="card p-4 flex items-center gap-3">
<div class="rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30 text-blue-600"><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="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg></div>
<div class="rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30 text-blue-600">
<Icon name="document" size="md" />
</div>
<div><p class="text-xs font-medium text-gray-500">{{ t('usage.totalRequests') }}</p><p class="text-xl font-bold">{{ stats?.total_requests?.toLocaleString() || '0' }}</p></div>
</div>
<div class="card p-4 flex items-center gap-3">
@@ -9,19 +11,36 @@
<div><p class="text-xs font-medium text-gray-500">{{ t('usage.totalTokens') }}</p><p class="text-xl font-bold">{{ formatTokens(stats?.total_tokens || 0) }}</p></div>
</div>
<div class="card p-4 flex items-center gap-3">
<div class="rounded-lg bg-green-100 p-2 dark:bg-green-900/30 text-green-600"><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="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg></div>
<div class="rounded-lg bg-green-100 p-2 dark:bg-green-900/30 text-green-600">
<Icon name="dollar" size="md" />
</div>
<div><p class="text-xs font-medium text-gray-500">{{ t('usage.totalCost') }}</p><p class="text-xl font-bold text-green-600">${{ (stats?.total_actual_cost || 0).toFixed(4) }}</p></div>
</div>
<div class="card p-4 flex items-center gap-3">
<div class="rounded-lg bg-purple-100 p-2 dark:bg-purple-900/30 text-purple-600"><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="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" /></svg></div>
<div class="rounded-lg bg-purple-100 p-2 dark:bg-purple-900/30 text-purple-600">
<Icon name="clock" size="md" />
</div>
<div><p class="text-xs font-medium text-gray-500">{{ t('usage.avgDuration') }}</p><p class="text-xl font-bold">{{ formatDuration(stats?.average_duration_ms || 0) }}</p></div>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'; import type { AdminUsageStatsResponse } from '@/api/admin/usage'
defineProps<{ stats: AdminUsageStatsResponse | null }>(); const { t } = useI18n()
const formatDuration = (ms: number) => ms < 1000 ? `${ms.toFixed(0)}ms` : `${(ms/1000).toFixed(2)}s`
const formatTokens = (v: number) => { if (v >= 1e9) return (v/1e9).toFixed(2) + 'B'; if (v >= 1e6) return (v/1e6).toFixed(2) + 'M'; if (v >= 1e3) return (v/1e3).toFixed(2) + 'K'; return v.toLocaleString() }
</script>
import { useI18n } from 'vue-i18n'
import type { AdminUsageStatsResponse } from '@/api/admin/usage'
import Icon from '@/components/icons/Icon.vue'
defineProps<{ stats: AdminUsageStatsResponse | null }>()
const { t } = useI18n()
const formatDuration = (ms: number) =>
ms < 1000 ? `${ms.toFixed(0)}ms` : `${(ms / 1000).toFixed(2)}s`
const formatTokens = (value: number) => {
if (value >= 1e9) return (value / 1e9).toFixed(2) + 'B'
if (value >= 1e6) return (value / 1e6).toFixed(2) + 'M'
if (value >= 1e3) return (value / 1e3).toFixed(2) + 'K'
return value.toLocaleString()
}
</script>

View File

@@ -1,22 +1,163 @@
<template>
<div class="card overflow-hidden"><div class="overflow-auto">
<DataTable :columns="cols" :data="data" :loading="loading">
<template #cell-user="{ row }"><div class="text-sm"><span class="font-medium text-gray-900 dark:text-white">{{ row.user?.email || '-' }}</span><span class="ml-1 text-xs text-gray-400">#{{ row.user_id }}</span></div></template>
<template #cell-model="{ value }"><span class="font-medium">{{ value }}</span></template>
<template #cell-tokens="{ row }"><div class="text-sm">In: {{ row.input_tokens.toLocaleString() }} / Out: {{ row.output_tokens.toLocaleString() }}</div></template>
<template #cell-cost="{ row }"><span class="font-medium text-green-600">${{ row.actual_cost.toFixed(6) }}</span></template>
<template #cell-created_at="{ value }"><span class="text-sm text-gray-500">{{ formatDateTime(value) }}</span></template>
<template #empty><EmptyState :message="t('usage.noRecords')" /></template>
</DataTable>
</div></div>
<div class="card overflow-hidden">
<div class="overflow-auto">
<DataTable :columns="cols" :data="data" :loading="loading">
<template #cell-user="{ row }">
<div class="text-sm">
<span class="font-medium text-gray-900 dark:text-white">{{ row.user?.email || '-' }}</span>
<span class="ml-1 text-gray-500 dark:text-gray-400">#{{ row.user_id }}</span>
</div>
</template>
<template #cell-api_key="{ row }">
<span class="text-sm text-gray-900 dark:text-white">{{ row.api_key?.name || '-' }}</span>
</template>
<template #cell-account="{ row }">
<span class="text-sm text-gray-900 dark:text-white">{{ row.account?.name || '-' }}</span>
</template>
<template #cell-model="{ value }">
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
</template>
<template #cell-group="{ row }">
<span v-if="row.group" class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-200">
{{ row.group.name }}
</span>
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
</template>
<template #cell-stream="{ row }">
<span class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium" :class="row.stream ? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200' : 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200'">
{{ row.stream ? t('usage.stream') : t('usage.sync') }}
</span>
</template>
<template #cell-tokens="{ row }">
<!-- 图片生成请求 -->
<div v-if="row.image_count > 0" class="flex items-center gap-1.5">
<svg class="h-4 w-4 text-indigo-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span class="font-medium text-gray-900 dark:text-white">{{ row.image_count }}{{ t('usage.imageUnit') }}</span>
<span class="text-gray-400">({{ row.image_size || '2K' }})</span>
</div>
<!-- Token 请求 -->
<div v-else class="space-y-1 text-sm">
<div class="flex items-center gap-2">
<div class="inline-flex items-center gap-1">
<Icon name="arrowDown" size="sm" class="h-3.5 w-3.5 text-emerald-500" />
<span class="font-medium text-gray-900 dark:text-white">{{ row.input_tokens?.toLocaleString() || 0 }}</span>
</div>
<div class="inline-flex items-center gap-1">
<Icon name="arrowUp" size="sm" class="h-3.5 w-3.5 text-violet-500" />
<span class="font-medium text-gray-900 dark:text-white">{{ row.output_tokens?.toLocaleString() || 0 }}</span>
</div>
</div>
<div v-if="row.cache_read_tokens > 0 || row.cache_creation_tokens > 0" class="flex items-center gap-2">
<div v-if="row.cache_read_tokens > 0" class="inline-flex items-center gap-1">
<svg class="h-3.5 w-3.5 text-sky-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" /></svg>
<span class="font-medium text-sky-600 dark:text-sky-400">{{ formatCacheTokens(row.cache_read_tokens) }}</span>
</div>
<div v-if="row.cache_creation_tokens > 0" class="inline-flex items-center gap-1">
<svg class="h-3.5 w-3.5 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg>
<span class="font-medium text-amber-600 dark:text-amber-400">{{ formatCacheTokens(row.cache_creation_tokens) }}</span>
</div>
</div>
</div>
</template>
<template #cell-cost="{ row }">
<span class="font-medium text-green-600 dark:text-green-400">${{ row.actual_cost?.toFixed(6) || '0.000000' }}</span>
</template>
<template #cell-billing_type="{ row }">
<span class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium" :class="row.billing_type === 1 ? 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200' : 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200'">
{{ row.billing_type === 1 ? t('usage.subscription') : t('usage.balance') }}
</span>
</template>
<template #cell-first_token="{ row }">
<span v-if="row.first_token_ms != null" class="text-sm text-gray-600 dark:text-gray-400">{{ formatDuration(row.first_token_ms) }}</span>
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
</template>
<template #cell-duration="{ row }">
<span class="text-sm text-gray-600 dark:text-gray-400">{{ formatDuration(row.duration_ms) }}</span>
</template>
<template #cell-created_at="{ value }">
<span class="text-sm text-gray-600 dark:text-gray-400">{{ formatDateTime(value) }}</span>
</template>
<template #cell-request_id="{ row }">
<div v-if="row.request_id" class="flex items-center gap-1.5 max-w-[120px]">
<span class="font-mono text-xs text-gray-500 dark:text-gray-400 truncate" :title="row.request_id">{{ row.request_id }}</span>
<button @click="copyRequestId(row.request_id)" class="flex-shrink-0 rounded p-0.5 transition-colors hover:bg-gray-100 dark:hover:bg-dark-700" :class="copiedRequestId === row.request_id ? 'text-green-500' : 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'" :title="copiedRequestId === row.request_id ? t('keys.copied') : t('keys.copyToClipboard')">
<svg v-if="copiedRequestId === row.request_id" class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" /></svg>
<Icon v-else name="copy" size="sm" class="h-3.5 w-3.5" />
</button>
</div>
<span v-else class="text-gray-400 dark:text-gray-500">-</span>
</template>
<template #empty><EmptyState :message="t('usage.noRecords')" /></template>
</DataTable>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'; import { useI18n } from 'vue-i18n'; import { formatDateTime } from '@/utils/format'; import DataTable from '@/components/common/DataTable.vue'; import EmptyState from '@/components/common/EmptyState.vue'
defineProps(['data', 'loading']); const { t } = useI18n()
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatDateTime } from '@/utils/format'
import { useAppStore } from '@/stores/app'
import DataTable from '@/components/common/DataTable.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import Icon from '@/components/icons/Icon.vue'
defineProps(['data', 'loading'])
const { t } = useI18n()
const appStore = useAppStore()
const copiedRequestId = ref<string | null>(null)
const cols = computed(() => [
{ key: 'user', label: t('admin.usage.user') }, { key: 'model', label: t('usage.model'), sortable: true },
{ key: 'tokens', label: t('usage.tokens') }, { key: 'cost', label: t('usage.cost') },
{ key: 'created_at', label: t('usage.time'), sortable: true }
{ key: 'user', label: t('admin.usage.user'), sortable: false },
{ key: 'api_key', label: t('usage.apiKeyFilter'), sortable: false },
{ key: 'account', label: t('admin.usage.account'), sortable: false },
{ key: 'model', label: t('usage.model'), sortable: true },
{ key: 'group', label: t('admin.usage.group'), sortable: false },
{ key: 'stream', label: t('usage.type'), sortable: false },
{ key: 'tokens', label: t('usage.tokens'), sortable: false },
{ key: 'cost', label: t('usage.cost'), sortable: false },
{ key: 'billing_type', label: t('usage.billingType'), sortable: false },
{ key: 'first_token', label: t('usage.firstToken'), sortable: false },
{ key: 'duration', label: t('usage.duration'), sortable: false },
{ key: 'created_at', label: t('usage.time'), sortable: true },
{ key: 'request_id', label: t('admin.usage.requestId'), sortable: false }
])
</script>
const formatCacheTokens = (tokens: number): string => {
if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(1)}M`
if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}K`
return tokens.toString()
}
const formatDuration = (ms: number | null | undefined): string => {
if (ms == null) return '-'
if (ms < 1000) return `${ms}ms`
return `${(ms / 1000).toFixed(2)}s`
}
const copyRequestId = async (requestId: string) => {
try {
await navigator.clipboard.writeText(requestId)
copiedRequestId.value = requestId
appStore.showSuccess(t('admin.usage.requestIdCopied'))
setTimeout(() => { copiedRequestId.value = null }, 2000)
} catch {
appStore.showError(t('common.copyFailed'))
}
}
</script>

View File

@@ -52,7 +52,7 @@ const load = async () => { loading.value = true; try { const res = await adminAP
const handleSave = async () => {
if (!props.user) return; submitting.value = true
try {
await adminAPI.users.update(props.user.id, { allowed_groups: selectedIds.value.length > 0 ? selectedIds.value : null })
await adminAPI.users.update(props.user.id, { allowed_groups: selectedIds.value })
appStore.showSuccess(t('admin.users.allowedGroupsUpdated')); emit('success'); emit('close')
} catch (error) { console.error('Failed to update allowed groups:', error) } finally { submitting.value = false }
}

View File

@@ -37,10 +37,22 @@ watch(() => props.show, (v) => { if(v) { form.amount = 0; form.notes = '' } })
const calculateNewBalance = () => (props.user ? (props.operation === 'add' ? props.user.balance + form.amount : props.user.balance - form.amount) : 0)
const handleBalanceSubmit = async () => {
if (!props.user) return; submitting.value = true
if (!props.user) return
if (!form.amount || form.amount <= 0) {
appStore.showError(t('admin.users.amountRequired'))
return
}
if (props.operation === 'subtract' && form.amount > props.user.balance) {
appStore.showError(t('admin.users.insufficientBalance'))
return
}
submitting.value = true
try {
await adminAPI.users.updateBalance(props.user.id, form.amount, props.operation, form.notes)
appStore.showSuccess(t('common.success')); emit('success'); emit('close')
} catch (error) { console.error('Failed to update balance:', error) } finally { submitting.value = false }
} catch (e: any) {
console.error('Failed to update balance:', e)
appStore.showError(e.response?.data?.detail || t('common.error'))
} finally { submitting.value = false }
}
</script>
</script>

View File

@@ -17,7 +17,7 @@
<input v-model="form.password" type="text" required class="input pr-10" :placeholder="t('admin.users.enterPassword')" />
</div>
<button type="button" @click="generateRandomPassword" class="btn btn-secondary px-3">
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" 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" />
</button>
</div>
</div>
@@ -52,6 +52,7 @@ import { reactive, watch } from 'vue'
import { useI18n } from 'vue-i18n'; import { adminAPI } from '@/api/admin'
import { useForm } from '@/composables/useForm'
import BaseDialog from '@/components/common/BaseDialog.vue'
import Icon from '@/components/icons/Icon.vue'
const props = defineProps<{ show: boolean }>()
const emit = defineEmits(['close', 'success']); const { t } = useI18n()

View File

@@ -21,7 +21,7 @@
</button>
</div>
<button type="button" @click="generatePassword" class="btn btn-secondary px-3">
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" 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" />
</button>
</div>
</div>
@@ -59,6 +59,7 @@ import { adminAPI } from '@/api/admin'
import type { User, UserAttributeValuesMap } from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue'
import UserAttributeForm from '@/components/user/UserAttributeForm.vue'
import Icon from '@/components/icons/Icon.vue'
const props = defineProps<{ show: boolean, user: User | null }>()
const emit = defineEmits(['close', 'success'])
@@ -86,6 +87,14 @@ const copyPassword = async () => {
}
const handleUpdateUser = async () => {
if (!props.user) return
if (!form.email.trim()) {
appStore.showError(t('admin.users.emailRequired'))
return
}
if (form.concurrency < 1) {
appStore.showError(t('admin.users.concurrencyMin'))
return
}
submitting.value = true
try {
const data: any = { email: form.email, username: form.username, notes: form.notes, concurrency: form.concurrency }
@@ -98,4 +107,4 @@ const handleUpdateUser = async () => {
appStore.showError(e.response?.data?.detail || t('admin.users.failedToUpdate'))
} finally { submitting.value = false }
}
</script>
</script>