Merge branch 'main' into test

This commit is contained in:
yangjianbo
2026-02-04 20:35:09 +08:00
90 changed files with 5735 additions and 749 deletions

View File

@@ -526,6 +526,107 @@
</div>
</div>
<!-- 支持的模型系列 antigravity 平台 -->
<div v-if="createForm.platform === 'antigravity'" class="border-t pt-4">
<div class="mb-1.5 flex items-center gap-1">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.groups.supportedScopes.title') }}
</label>
<!-- Help Tooltip -->
<div class="group relative inline-flex">
<Icon
name="questionCircle"
size="sm"
:stroke-width="2"
class="cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400"
/>
<div class="pointer-events-none absolute bottom-full left-0 z-50 mb-2 w-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">
<p class="text-xs leading-relaxed text-gray-300">
{{ t('admin.groups.supportedScopes.tooltip') }}
</p>
<div class="absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"></div>
</div>
</div>
</div>
</div>
<div class="space-y-2">
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
:checked="createForm.supported_model_scopes.includes('claude')"
@change="toggleCreateScope('claude')"
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-dark-600 dark:bg-dark-700"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">{{ t('admin.groups.supportedScopes.claude') }}</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
:checked="createForm.supported_model_scopes.includes('gemini_text')"
@change="toggleCreateScope('gemini_text')"
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-dark-600 dark:bg-dark-700"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">{{ t('admin.groups.supportedScopes.geminiText') }}</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
:checked="createForm.supported_model_scopes.includes('gemini_image')"
@change="toggleCreateScope('gemini_image')"
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-dark-600 dark:bg-dark-700"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">{{ t('admin.groups.supportedScopes.geminiImage') }}</span>
</label>
</div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.groups.supportedScopes.hint') }}</p>
</div>
<!-- MCP XML 协议注入 antigravity 平台 -->
<div v-if="createForm.platform === 'antigravity'" class="border-t pt-4">
<div class="mb-1.5 flex items-center gap-1">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.groups.mcpXml.title') }}
</label>
<div class="group relative inline-flex">
<Icon
name="questionCircle"
size="sm"
:stroke-width="2"
class="cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400"
/>
<div class="pointer-events-none absolute bottom-full left-0 z-50 mb-2 w-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">
<p class="text-xs leading-relaxed text-gray-300">
{{ t('admin.groups.mcpXml.tooltip') }}
</p>
<div class="absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"></div>
</div>
</div>
</div>
</div>
<div class="flex items-center gap-3">
<button
type="button"
@click="createForm.mcp_xml_inject = !createForm.mcp_xml_inject"
:class="[
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
createForm.mcp_xml_inject ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600'
]"
>
<span
:class="[
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
createForm.mcp_xml_inject ? 'translate-x-6' : 'translate-x-1'
]"
/>
</button>
<span class="text-sm text-gray-500 dark:text-gray-400">
{{ createForm.mcp_xml_inject ? t('admin.groups.mcpXml.enabled') : t('admin.groups.mcpXml.disabled') }}
</span>
</div>
</div>
<!-- Claude Code 客户端限制 anthropic 平台 -->
<div v-if="createForm.platform === 'anthropic'" class="border-t pt-4">
<div class="mb-1.5 flex items-center gap-1">
@@ -582,6 +683,20 @@
</div>
</div>
<!-- 无效请求兜底 anthropic/antigravity 平台且非订阅分组 -->
<div
v-if="['anthropic', 'antigravity'].includes(createForm.platform) && createForm.subscription_type !== 'subscription'"
class="border-t pt-4"
>
<label class="input-label">{{ t('admin.groups.invalidRequestFallback.title') }}</label>
<Select
v-model="createForm.fallback_group_id_on_invalid_request"
:options="invalidRequestFallbackOptions"
:placeholder="t('admin.groups.invalidRequestFallback.noFallback')"
/>
<p class="input-hint">{{ t('admin.groups.invalidRequestFallback.hint') }}</p>
</div>
<!-- 模型路由配置 anthropic 平台 -->
<div v-if="createForm.platform === 'anthropic'" class="border-t pt-4">
<div class="mb-1.5 flex items-center gap-1">
@@ -1091,6 +1206,107 @@
</div>
</div>
<!-- 支持的模型系列 antigravity 平台 -->
<div v-if="editForm.platform === 'antigravity'" class="border-t pt-4">
<div class="mb-1.5 flex items-center gap-1">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.groups.supportedScopes.title') }}
</label>
<!-- Help Tooltip -->
<div class="group relative inline-flex">
<Icon
name="questionCircle"
size="sm"
:stroke-width="2"
class="cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400"
/>
<div class="pointer-events-none absolute bottom-full left-0 z-50 mb-2 w-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">
<p class="text-xs leading-relaxed text-gray-300">
{{ t('admin.groups.supportedScopes.tooltip') }}
</p>
<div class="absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"></div>
</div>
</div>
</div>
</div>
<div class="space-y-2">
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
:checked="editForm.supported_model_scopes.includes('claude')"
@change="toggleEditScope('claude')"
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-dark-600 dark:bg-dark-700"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">{{ t('admin.groups.supportedScopes.claude') }}</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
:checked="editForm.supported_model_scopes.includes('gemini_text')"
@change="toggleEditScope('gemini_text')"
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-dark-600 dark:bg-dark-700"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">{{ t('admin.groups.supportedScopes.geminiText') }}</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
:checked="editForm.supported_model_scopes.includes('gemini_image')"
@change="toggleEditScope('gemini_image')"
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-dark-600 dark:bg-dark-700"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">{{ t('admin.groups.supportedScopes.geminiImage') }}</span>
</label>
</div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.groups.supportedScopes.hint') }}</p>
</div>
<!-- MCP XML 协议注入 antigravity 平台 -->
<div v-if="editForm.platform === 'antigravity'" class="border-t pt-4">
<div class="mb-1.5 flex items-center gap-1">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.groups.mcpXml.title') }}
</label>
<div class="group relative inline-flex">
<Icon
name="questionCircle"
size="sm"
:stroke-width="2"
class="cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400"
/>
<div class="pointer-events-none absolute bottom-full left-0 z-50 mb-2 w-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">
<p class="text-xs leading-relaxed text-gray-300">
{{ t('admin.groups.mcpXml.tooltip') }}
</p>
<div class="absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"></div>
</div>
</div>
</div>
</div>
<div class="flex items-center gap-3">
<button
type="button"
@click="editForm.mcp_xml_inject = !editForm.mcp_xml_inject"
:class="[
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
editForm.mcp_xml_inject ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600'
]"
>
<span
:class="[
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
editForm.mcp_xml_inject ? 'translate-x-6' : 'translate-x-1'
]"
/>
</button>
<span class="text-sm text-gray-500 dark:text-gray-400">
{{ editForm.mcp_xml_inject ? t('admin.groups.mcpXml.enabled') : t('admin.groups.mcpXml.disabled') }}
</span>
</div>
</div>
<!-- Claude Code 客户端限制 anthropic 平台 -->
<div v-if="editForm.platform === 'anthropic'" class="border-t pt-4">
<div class="mb-1.5 flex items-center gap-1">
@@ -1147,6 +1363,20 @@
</div>
</div>
<!-- 无效请求兜底 anthropic/antigravity 平台且非订阅分组 -->
<div
v-if="['anthropic', 'antigravity'].includes(editForm.platform) && editForm.subscription_type !== 'subscription'"
class="border-t pt-4"
>
<label class="input-label">{{ t('admin.groups.invalidRequestFallback.title') }}</label>
<Select
v-model="editForm.fallback_group_id_on_invalid_request"
:options="invalidRequestFallbackOptionsForEdit"
:placeholder="t('admin.groups.invalidRequestFallback.noFallback')"
/>
<p class="input-hint">{{ t('admin.groups.invalidRequestFallback.hint') }}</p>
</div>
<!-- 模型路由配置 anthropic 平台 -->
<div v-if="editForm.platform === 'anthropic'" class="border-t pt-4">
<div class="mb-1.5 flex items-center gap-1">
@@ -1447,6 +1677,44 @@ const fallbackGroupOptionsForEdit = computed(() => {
return options
})
// 无效请求兜底分组选项(创建时)- 仅包含 anthropic 平台、非订阅且未配置兜底的分组
const invalidRequestFallbackOptions = computed(() => {
const options: { value: number | null; label: string }[] = [
{ value: null, label: t('admin.groups.invalidRequestFallback.noFallback') }
]
const eligibleGroups = groups.value.filter(
(g) =>
g.platform === 'anthropic' &&
g.status === 'active' &&
g.subscription_type !== 'subscription' &&
g.fallback_group_id_on_invalid_request === null
)
eligibleGroups.forEach((g) => {
options.push({ value: g.id, label: g.name })
})
return options
})
// 无效请求兜底分组选项(编辑时)- 排除自身
const invalidRequestFallbackOptionsForEdit = computed(() => {
const options: { value: number | null; label: string }[] = [
{ value: null, label: t('admin.groups.invalidRequestFallback.noFallback') }
]
const currentId = editingGroup.value?.id
const eligibleGroups = groups.value.filter(
(g) =>
g.platform === 'anthropic' &&
g.status === 'active' &&
g.subscription_type !== 'subscription' &&
g.fallback_group_id_on_invalid_request === null &&
g.id !== currentId
)
eligibleGroups.forEach((g) => {
options.push({ value: g.id, label: g.name })
})
return options
})
// 复制账号的源分组选项(创建时)- 仅包含相同平台且有账号的分组
const copyAccountsGroupOptions = computed(() => {
const eligibleGroups = groups.value.filter(
@@ -1516,8 +1784,13 @@ const createForm = reactive({
// Claude Code 客户端限制(仅 anthropic 平台使用)
claude_code_only: false,
fallback_group_id: null as number | null,
fallback_group_id_on_invalid_request: null as number | null,
// 模型路由开关
model_routing_enabled: false,
// 支持的模型系列(仅 antigravity 平台)
supported_model_scopes: ['claude', 'gemini_text', 'gemini_image'] as string[],
// MCP XML 协议注入开关(仅 antigravity 平台)
mcp_xml_inject: true,
// 从分组复制账号
copy_accounts_from_group_ids: [] as number[]
})
@@ -1589,6 +1862,26 @@ const removeSelectedAccount = (ruleIndex: number, accountId: number, isEdit: boo
rule.accounts = rule.accounts.filter(a => a.id !== accountId)
}
// 切换创建表单的模型系列选择
const toggleCreateScope = (scope: string) => {
const idx = createForm.supported_model_scopes.indexOf(scope)
if (idx === -1) {
createForm.supported_model_scopes.push(scope)
} else {
createForm.supported_model_scopes.splice(idx, 1)
}
}
// 切换编辑表单的模型系列选择
const toggleEditScope = (scope: string) => {
const idx = editForm.supported_model_scopes.indexOf(scope)
if (idx === -1) {
editForm.supported_model_scopes.push(scope)
} else {
editForm.supported_model_scopes.splice(idx, 1)
}
}
// 处理账号搜索输入框聚焦
const onAccountSearchFocus = (ruleIndex: number, isEdit: boolean = false) => {
const key = `${isEdit ? 'edit' : 'create'}-${ruleIndex}`
@@ -1694,8 +1987,13 @@ const editForm = reactive({
// Claude Code 客户端限制(仅 anthropic 平台使用)
claude_code_only: false,
fallback_group_id: null as number | null,
fallback_group_id_on_invalid_request: null as number | null,
// 模型路由开关
model_routing_enabled: false,
// 支持的模型系列(仅 antigravity 平台)
supported_model_scopes: ['claude', 'gemini_text', 'gemini_image'] as string[],
// MCP XML 协议注入开关(仅 antigravity 平台)
mcp_xml_inject: true,
// 从分组复制账号
copy_accounts_from_group_ids: [] as number[]
})
@@ -1783,6 +2081,9 @@ const closeCreateModal = () => {
createForm.sora_video_price_per_request_hd = null
createForm.claude_code_only = false
createForm.fallback_group_id = null
createForm.fallback_group_id_on_invalid_request = null
createForm.supported_model_scopes = ['claude', 'gemini_text', 'gemini_image']
createForm.mcp_xml_inject = true
createForm.copy_accounts_from_group_ids = []
createModelRoutingRules.value = []
}
@@ -1837,7 +2138,10 @@ const handleEdit = async (group: AdminGroup) => {
editForm.sora_video_price_per_request_hd = group.sora_video_price_per_request_hd
editForm.claude_code_only = group.claude_code_only || false
editForm.fallback_group_id = group.fallback_group_id
editForm.fallback_group_id_on_invalid_request = group.fallback_group_id_on_invalid_request
editForm.model_routing_enabled = group.model_routing_enabled || false
editForm.supported_model_scopes = group.supported_model_scopes || ['claude', 'gemini_text', 'gemini_image']
editForm.mcp_xml_inject = group.mcp_xml_inject ?? true
editForm.copy_accounts_from_group_ids = [] // 复制账号字段每次编辑时重置为空
// 加载模型路由规则(异步加载账号名称)
editModelRoutingRules.value = await convertApiFormatToRoutingRules(group.model_routing)
@@ -1864,6 +2168,10 @@ const handleUpdateGroup = async () => {
const payload = {
...editForm,
fallback_group_id: editForm.fallback_group_id === null ? 0 : editForm.fallback_group_id,
fallback_group_id_on_invalid_request:
editForm.fallback_group_id_on_invalid_request === null
? 0
: editForm.fallback_group_id_on_invalid_request,
model_routing: convertRoutingRulesToApiFormat(editModelRoutingRules.value)
}
await adminAPI.groups.update(editingGroup.value.id, payload)
@@ -1904,6 +2212,16 @@ watch(
(newVal) => {
if (newVal === 'subscription') {
createForm.is_exclusive = true
createForm.fallback_group_id_on_invalid_request = null
}
}
)
watch(
() => createForm.platform,
(newVal) => {
if (!['anthropic', 'antigravity'].includes(newVal)) {
createForm.fallback_group_id_on_invalid_request = null
}
}
)

View File

@@ -353,7 +353,6 @@
</div>
<Toggle v-model="form.invitation_code_enabled" />
</div>
<!-- Password Reset - Only show when email verification is enabled -->
<div
v-if="form.email_verify_enabled"

View File

@@ -35,13 +35,13 @@
<script setup lang="ts">
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { saveAs } from 'file-saver'
import { useAppStore } from '@/stores/app'; import { adminAPI } from '@/api/admin'; import { adminUsageAPI } from '@/api/admin/usage'
import { formatReasoningEffort } from '@/utils/format'
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 UsageCleanupDialog from '@/components/admin/usage/UsageCleanupDialog.vue'
import { saveAs } from 'file-saver'
import { useAppStore } from '@/stores/app'; import { adminAPI } from '@/api/admin'; import { adminUsageAPI } from '@/api/admin/usage'
import { formatReasoningEffort } from '@/utils/format'
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 UsageCleanupDialog from '@/components/admin/usage/UsageCleanupDialog.vue'
import ModelDistributionChart from '@/components/charts/ModelDistributionChart.vue'; import TokenUsageTrend from '@/components/charts/TokenUsageTrend.vue'
import type { AdminUsageLog, TrendDataPoint, ModelStat } from '@/types'; import type { AdminUsageStatsResponse, AdminUsageQueryParams } from '@/api/admin/usage'

View File

@@ -40,10 +40,18 @@
/>
<!-- Row: Concurrency + Throughput -->
<div v-if="opsEnabled && !(loading && !hasLoadedOnce)" class="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div v-if="opsEnabled && !(loading && !hasLoadedOnce)" class="grid grid-cols-1 gap-6 lg:grid-cols-4">
<div class="lg:col-span-1 min-h-[360px]">
<OpsConcurrencyCard :platform-filter="platform" :group-id-filter="groupId" :refresh-token="dashboardRefreshToken" />
</div>
<div class="lg:col-span-1 min-h-[360px]">
<OpsSwitchRateTrendChart
:points="switchTrend?.points ?? []"
:loading="loadingSwitchTrend"
:time-range="switchTrendTimeRange"
:fullscreen="isFullscreen"
/>
</div>
<div class="lg:col-span-2 min-h-[360px]">
<OpsThroughputTrendChart
:points="throughputTrend?.points ?? []"
@@ -138,6 +146,7 @@ import OpsErrorDetailsModal from './components/OpsErrorDetailsModal.vue'
import OpsErrorTrendChart from './components/OpsErrorTrendChart.vue'
import OpsLatencyChart from './components/OpsLatencyChart.vue'
import OpsThroughputTrendChart from './components/OpsThroughputTrendChart.vue'
import OpsSwitchRateTrendChart from './components/OpsSwitchRateTrendChart.vue'
import OpsAlertEventsCard from './components/OpsAlertEventsCard.vue'
import OpsRequestDetailsModal, { type OpsRequestDetailsPreset } from './components/OpsRequestDetailsModal.vue'
import OpsSettingsDialog from './components/OpsSettingsDialog.vue'
@@ -168,6 +177,9 @@ const groupId = ref<number | null>(null)
const queryMode = ref<QueryMode>('auto')
const customStartTime = ref<string | null>(null)
const customEndTime = ref<string | null>(null)
const switchTrendWindowHours = 5
const switchTrendTimeRange = `${switchTrendWindowHours}h`
const switchTrendWindowMs = switchTrendWindowHours * 60 * 60 * 1000
const QUERY_KEYS = {
timeRange: 'tr',
@@ -322,6 +334,9 @@ const metricThresholds = ref<OpsMetricThresholds | null>(null)
const throughputTrend = ref<OpsThroughputTrendResponse | null>(null)
const loadingTrend = ref(false)
const switchTrend = ref<OpsThroughputTrendResponse | null>(null)
const loadingSwitchTrend = ref(false)
const latencyHistogram = ref<OpsLatencyHistogramResponse | null>(null)
const loadingLatency = ref(false)
@@ -491,6 +506,19 @@ function buildApiParams() {
return params
}
function buildSwitchTrendParams() {
const params: any = {
platform: platform.value || undefined,
group_id: groupId.value ?? undefined,
mode: queryMode.value
}
const endTime = new Date()
const startTime = new Date(endTime.getTime() - switchTrendWindowMs)
params.start_time = startTime.toISOString()
params.end_time = endTime.toISOString()
return params
}
async function refreshOverviewWithCancel(fetchSeq: number, signal: AbortSignal) {
if (!opsEnabled.value) return
try {
@@ -504,6 +532,24 @@ async function refreshOverviewWithCancel(fetchSeq: number, signal: AbortSignal)
}
}
async function refreshSwitchTrendWithCancel(fetchSeq: number, signal: AbortSignal) {
if (!opsEnabled.value) return
loadingSwitchTrend.value = true
try {
const data = await opsAPI.getThroughputTrend(buildSwitchTrendParams(), { signal })
if (fetchSeq !== dashboardFetchSeq) return
switchTrend.value = data
} catch (err: any) {
if (fetchSeq !== dashboardFetchSeq || isCanceledRequest(err)) return
switchTrend.value = null
appStore.showError(err?.message || t('admin.ops.failedToLoadSwitchTrend'))
} finally {
if (fetchSeq === dashboardFetchSeq) {
loadingSwitchTrend.value = false
}
}
}
async function refreshThroughputTrendWithCancel(fetchSeq: number, signal: AbortSignal) {
if (!opsEnabled.value) return
loadingTrend.value = true
@@ -600,6 +646,7 @@ async function fetchData() {
await Promise.all([
refreshOverviewWithCancel(fetchSeq, dashboardFetchController.signal),
refreshThroughputTrendWithCancel(fetchSeq, dashboardFetchController.signal),
refreshSwitchTrendWithCancel(fetchSeq, dashboardFetchController.signal),
refreshLatencyHistogramWithCancel(fetchSeq, dashboardFetchController.signal),
refreshErrorTrendWithCancel(fetchSeq, dashboardFetchController.signal),
refreshErrorDistributionWithCancel(fetchSeq, dashboardFetchController.signal)

View File

@@ -50,7 +50,11 @@ const props = withDefaults(defineProps<Props>(), {
</div>
<!-- Row: Concurrency + Throughput (matches OpsDashboard.vue) -->
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div class="grid grid-cols-1 gap-6 lg:grid-cols-4">
<div :class="['min-h-[360px] rounded-3xl bg-white shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700 lg:col-span-1', props.fullscreen ? 'p-8' : 'p-6']">
<div class="h-4 w-44 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div>
<div class="mt-6 h-72 animate-pulse rounded-2xl bg-gray-100 dark:bg-dark-700/70"></div>
</div>
<div :class="['min-h-[360px] rounded-3xl bg-white shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700 lg:col-span-1', props.fullscreen ? 'p-8' : 'p-6']">
<div class="h-4 w-44 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div>
<div class="mt-6 h-72 animate-pulse rounded-2xl bg-gray-100 dark:bg-dark-700/70"></div>
@@ -96,4 +100,3 @@ const props = withDefaults(defineProps<Props>(), {
</div>
</div>
</template>

View File

@@ -0,0 +1,150 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import {
Chart as ChartJS,
CategoryScale,
Filler,
Legend,
LineElement,
LinearScale,
PointElement,
Title,
Tooltip
} from 'chart.js'
import { Line } from 'vue-chartjs'
import type { OpsThroughputTrendPoint } from '@/api/admin/ops'
import type { ChartState } from '../types'
import { formatHistoryLabel, sumNumbers } from '../utils/opsFormatters'
import HelpTooltip from '@/components/common/HelpTooltip.vue'
import EmptyState from '@/components/common/EmptyState.vue'
ChartJS.register(Title, Tooltip, Legend, LineElement, LinearScale, PointElement, CategoryScale, Filler)
interface Props {
points: OpsThroughputTrendPoint[]
loading: boolean
timeRange: string
fullscreen?: boolean
}
const props = defineProps<Props>()
const { t } = useI18n()
const isDarkMode = computed(() => document.documentElement.classList.contains('dark'))
const colors = computed(() => ({
teal: '#14b8a6',
tealAlpha: '#14b8a620',
grid: isDarkMode.value ? '#374151' : '#f3f4f6',
text: isDarkMode.value ? '#9ca3af' : '#6b7280'
}))
const totalRequests = computed(() => sumNumbers(props.points.map((p) => p.request_count)))
const chartData = computed(() => {
if (!props.points.length || totalRequests.value <= 0) return null
return {
labels: props.points.map((p) => formatHistoryLabel(p.bucket_start, props.timeRange)),
datasets: [
{
label: t('admin.ops.switchRate'),
data: props.points.map((p) => {
const requests = p.request_count ?? 0
const switches = p.switch_count ?? 0
if (requests <= 0) return 0
return switches / requests
}),
borderColor: colors.value.teal,
backgroundColor: colors.value.tealAlpha,
fill: true,
tension: 0.35,
pointRadius: 0,
pointHitRadius: 10
}
]
}
})
const state = computed<ChartState>(() => {
if (chartData.value) return 'ready'
if (props.loading) return 'loading'
return 'empty'
})
const options = computed(() => {
const c = colors.value
return {
responsive: true,
maintainAspectRatio: false,
interaction: { intersect: false, mode: 'index' as const },
plugins: {
legend: {
position: 'top' as const,
align: 'end' as const,
labels: { color: c.text, usePointStyle: true, boxWidth: 6, font: { size: 10 } }
},
tooltip: {
backgroundColor: isDarkMode.value ? '#1f2937' : '#ffffff',
titleColor: isDarkMode.value ? '#f3f4f6' : '#111827',
bodyColor: isDarkMode.value ? '#d1d5db' : '#4b5563',
borderColor: c.grid,
borderWidth: 1,
padding: 10,
displayColors: true,
callbacks: {
label: (context: any) => {
const value = typeof context?.parsed?.y === 'number' ? context.parsed.y : 0
return `${t('admin.ops.switchRate')}: ${value.toFixed(3)}`
}
}
}
},
scales: {
x: {
type: 'category' as const,
grid: { display: false },
ticks: {
color: c.text,
font: { size: 10 },
maxTicksLimit: 8,
autoSkip: true,
autoSkipPadding: 10
}
},
y: {
type: 'linear' as const,
display: true,
position: 'left' as const,
grid: { color: c.grid, borderDash: [4, 4] },
ticks: {
color: c.text,
font: { size: 10 },
callback: (value: any) => Number(value).toFixed(3)
}
}
}
}
})
</script>
<template>
<div class="flex h-full flex-col rounded-3xl bg-white p-6 shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700">
<div class="mb-4 flex shrink-0 items-center justify-between">
<h3 class="flex items-center gap-2 text-sm font-bold text-gray-900 dark:text-white">
<svg class="h-4 w-4 text-teal-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h10M7 12h6m-6 5h3" />
</svg>
{{ t('admin.ops.switchRateTrend') }}
<HelpTooltip v-if="!props.fullscreen" :content="t('admin.ops.tooltips.switchRateTrend')" />
</h3>
</div>
<div class="min-h-0 flex-1">
<Line v-if="state === 'ready' && chartData" :data="chartData" :options="options" />
<div v-else class="flex h-full items-center justify-center">
<div v-if="state === 'loading'" class="animate-pulse text-sm text-gray-400">{{ t('common.loading') }}</div>
<EmptyState v-else :title="t('common.noData')" :description="t('admin.ops.charts.emptyRequest')" />
</div>
</div>
</div>
</template>

View File

@@ -91,6 +91,18 @@
</div>
</div>
<div class="flex items-center justify-between rounded-xl border border-gray-200 p-3 dark:border-dark-700">
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white">
{{ t("setup.redis.enableTls") }}
</p>
<p class="text-xs text-gray-500 dark:text-dark-400">
{{ t("setup.redis.enableTlsHint") }}
</p>
</div>
<Toggle v-model="formData.redis.enable_tls" />
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="input-label">{{ t('setup.database.username') }}</label>

View File

@@ -108,12 +108,53 @@
${{ (usageStats[row.id]?.total_actual_cost ?? 0).toFixed(4) }}
</span>
</div>
<!-- Quota progress (if quota is set) -->
<div v-if="row.quota > 0" class="mt-1.5">
<div class="flex items-center gap-1.5">
<span class="text-gray-500 dark:text-gray-400">{{ t('keys.quota') }}:</span>
<span :class="[
'font-medium',
row.quota_used >= row.quota ? 'text-red-500' :
row.quota_used >= row.quota * 0.8 ? 'text-yellow-500' :
'text-gray-900 dark:text-white'
]">
${{ row.quota_used?.toFixed(2) || '0.00' }} / ${{ row.quota?.toFixed(2) }}
</span>
</div>
<div class="mt-1 h-1.5 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-dark-600">
<div
:class="[
'h-full rounded-full transition-all',
row.quota_used >= row.quota ? 'bg-red-500' :
row.quota_used >= row.quota * 0.8 ? 'bg-yellow-500' :
'bg-primary-500'
]"
:style="{ width: Math.min((row.quota_used / row.quota) * 100, 100) + '%' }"
/>
</div>
</div>
</div>
</template>
<template #cell-expires_at="{ value }">
<span v-if="value" :class="[
'text-sm',
new Date(value) < new Date() ? 'text-red-500 dark:text-red-400' : 'text-gray-500 dark:text-dark-400'
]">
{{ formatDateTime(value) }}
</span>
<span v-else class="text-sm text-gray-400 dark:text-dark-500">{{ t('keys.noExpiration') }}</span>
</template>
<template #cell-status="{ value }">
<span :class="['badge', value === 'active' ? 'badge-success' : 'badge-gray']">
{{ t('admin.accounts.status.' + value) }}
<span :class="[
'badge',
value === 'active' ? 'badge-success' :
value === 'quota_exhausted' ? 'badge-warning' :
value === 'expired' ? 'badge-danger' :
'badge-gray'
]">
{{ t('keys.status.' + value) }}
</span>
</template>
@@ -334,6 +375,145 @@
</div>
</div>
</div>
<!-- Quota Limit Section -->
<div class="space-y-3">
<label class="input-label">{{ t('keys.quotaLimit') }}</label>
<!-- Switch commented out - always show input, 0 = unlimited
<div class="flex items-center justify-between">
<label class="input-label mb-0">{{ t('keys.quotaLimit') }}</label>
<button
type="button"
@click="formData.enable_quota = !formData.enable_quota"
:class="[
'relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none',
formData.enable_quota ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]"
>
<span
:class="[
'pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
formData.enable_quota ? 'translate-x-4' : 'translate-x-0'
]"
/>
</button>
</div>
-->
<div class="space-y-4">
<div>
<div class="relative">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">$</span>
<input
v-model.number="formData.quota"
type="number"
step="0.01"
min="0"
class="input pl-7"
:placeholder="t('keys.quotaAmountPlaceholder')"
/>
</div>
<p class="input-hint">{{ t('keys.quotaAmountHint') }}</p>
</div>
<!-- Quota used display (only in edit mode) -->
<div v-if="showEditModal && selectedKey && selectedKey.quota > 0">
<label class="input-label">{{ t('keys.quotaUsed') }}</label>
<div class="flex items-center gap-2">
<div class="flex-1 rounded-lg bg-gray-100 px-3 py-2 dark:bg-dark-700">
<span class="font-medium text-gray-900 dark:text-white">
${{ selectedKey.quota_used?.toFixed(4) || '0.0000' }}
</span>
<span class="mx-2 text-gray-400">/</span>
<span class="text-gray-500 dark:text-gray-400">
${{ selectedKey.quota?.toFixed(2) || '0.00' }}
</span>
</div>
<button
type="button"
@click="confirmResetQuota"
class="btn btn-secondary text-sm"
:title="t('keys.resetQuotaUsed')"
>
{{ t('keys.reset') }}
</button>
</div>
</div>
</div>
</div>
<!-- Expiration Section -->
<div class="space-y-3">
<div class="flex items-center justify-between">
<label class="input-label mb-0">{{ t('keys.expiration') }}</label>
<button
type="button"
@click="formData.enable_expiration = !formData.enable_expiration"
:class="[
'relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none',
formData.enable_expiration ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]"
>
<span
:class="[
'pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
formData.enable_expiration ? 'translate-x-4' : 'translate-x-0'
]"
/>
</button>
</div>
<div v-if="formData.enable_expiration" class="space-y-4 pt-2">
<!-- Quick select buttons (for both create and edit mode) -->
<div class="flex flex-wrap gap-2">
<button
v-for="days in ['7', '30', '90']"
:key="days"
type="button"
@click="setExpirationDays(parseInt(days))"
:class="[
'rounded-lg px-3 py-1.5 text-sm transition-colors',
formData.expiration_preset === days
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-700 dark:text-gray-400 dark:hover:bg-dark-600'
]"
>
{{ showEditModal ? t('keys.extendDays', { days }) : t('keys.expiresInDays', { days }) }}
</button>
<button
type="button"
@click="formData.expiration_preset = 'custom'"
:class="[
'rounded-lg px-3 py-1.5 text-sm transition-colors',
formData.expiration_preset === 'custom'
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-700 dark:text-gray-400 dark:hover:bg-dark-600'
]"
>
{{ t('keys.customDate') }}
</button>
</div>
<!-- Date picker (always show for precise adjustment) -->
<div>
<label class="input-label">{{ t('keys.expirationDate') }}</label>
<input
v-model="formData.expiration_date"
type="datetime-local"
class="input"
/>
<p class="input-hint">{{ t('keys.expirationDateHint') }}</p>
</div>
<!-- Current expiration display (only in edit mode) -->
<div v-if="showEditModal && selectedKey?.expires_at" class="text-sm">
<span class="text-gray-500 dark:text-gray-400">{{ t('keys.currentExpiration') }}: </span>
<span class="font-medium text-gray-900 dark:text-white">
{{ formatDateTime(selectedKey.expires_at) }}
</span>
</div>
</div>
</div>
</form>
<template #footer>
<div class="flex justify-end gap-3">
@@ -391,6 +571,18 @@
@cancel="showDeleteDialog = false"
/>
<!-- Reset Quota Confirmation Dialog -->
<ConfirmDialog
:show="showResetQuotaDialog"
:title="t('keys.resetQuotaTitle')"
:message="t('keys.resetQuotaConfirmMessage', { name: selectedKey?.name, used: selectedKey?.quota_used?.toFixed(4) })"
:confirm-text="t('keys.reset')"
:cancel-text="t('common.cancel')"
:danger="true"
@confirm="resetQuotaUsed"
@cancel="showResetQuotaDialog = false"
/>
<!-- Use Key Modal -->
<UseKeyModal
:show="showUseKeyModal"
@@ -514,6 +706,13 @@ import type { Column } from '@/components/common/types'
import type { BatchApiKeyUsageStats } from '@/api/usage'
import { formatDateTime } from '@/utils/format'
// Helper to format date for datetime-local input
const formatDateTimeLocal = (isoDate: string): string => {
const date = new Date(isoDate)
const pad = (n: number) => n.toString().padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
}
interface GroupOption {
value: number
label: string
@@ -532,6 +731,7 @@ const columns = computed<Column[]>(() => [
{ key: 'key', label: t('keys.apiKey'), sortable: false },
{ key: 'group', label: t('keys.group'), sortable: false },
{ key: 'usage', label: t('keys.usage'), sortable: false },
{ key: 'expires_at', label: t('keys.expiresAt'), sortable: true },
{ key: 'status', label: t('common.status'), sortable: true },
{ key: 'created_at', label: t('keys.created'), sortable: true },
{ key: 'actions', label: t('common.actions'), sortable: false }
@@ -553,6 +753,7 @@ const pagination = ref({
const showCreateModal = ref(false)
const showEditModal = ref(false)
const showDeleteDialog = ref(false)
const showResetQuotaDialog = ref(false)
const showUseKeyModal = ref(false)
const showCcsClientSelect = ref(false)
const pendingCcsRow = ref<ApiKey | null>(null)
@@ -587,7 +788,13 @@ const formData = ref({
custom_key: '',
enable_ip_restriction: false,
ip_whitelist: '',
ip_blacklist: ''
ip_blacklist: '',
// Quota settings (empty = unlimited)
enable_quota: false,
quota: null as number | null,
enable_expiration: false,
expiration_preset: '30' as '7' | '30' | '90' | 'custom',
expiration_date: ''
})
// 自定义Key验证
@@ -724,15 +931,21 @@ const handlePageSizeChange = (pageSize: number) => {
const editKey = (key: ApiKey) => {
selectedKey.value = key
const hasIPRestriction = (key.ip_whitelist?.length > 0) || (key.ip_blacklist?.length > 0)
const hasExpiration = !!key.expires_at
formData.value = {
name: key.name,
group_id: key.group_id,
status: key.status,
status: key.status === 'quota_exhausted' || key.status === 'expired' ? 'inactive' : key.status,
use_custom_key: false,
custom_key: '',
enable_ip_restriction: hasIPRestriction,
ip_whitelist: (key.ip_whitelist || []).join('\n'),
ip_blacklist: (key.ip_blacklist || []).join('\n')
ip_blacklist: (key.ip_blacklist || []).join('\n'),
enable_quota: key.quota > 0,
quota: key.quota > 0 ? key.quota : null,
enable_expiration: hasExpiration,
expiration_preset: 'custom',
expiration_date: key.expires_at ? formatDateTimeLocal(key.expires_at) : ''
}
showEditModal.value = true
}
@@ -820,6 +1033,28 @@ const handleSubmit = async () => {
const ipWhitelist = formData.value.enable_ip_restriction ? parseIPList(formData.value.ip_whitelist) : []
const ipBlacklist = formData.value.enable_ip_restriction ? parseIPList(formData.value.ip_blacklist) : []
// Calculate quota value (null/empty/0 = unlimited, stored as 0)
const quota = formData.value.quota && formData.value.quota > 0 ? formData.value.quota : 0
// Calculate expiration
let expiresInDays: number | undefined
let expiresAt: string | null | undefined
if (formData.value.enable_expiration && formData.value.expiration_date) {
if (!showEditModal.value) {
// Create mode: calculate days from date
const expDate = new Date(formData.value.expiration_date)
const now = new Date()
const diffDays = Math.ceil((expDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
expiresInDays = diffDays > 0 ? diffDays : 1
} else {
// Edit mode: use custom date directly
expiresAt = new Date(formData.value.expiration_date).toISOString()
}
} else if (showEditModal.value) {
// Edit mode: if expiration disabled or date cleared, send empty string to clear
expiresAt = ''
}
submitting.value = true
try {
if (showEditModal.value && selectedKey.value) {
@@ -828,12 +1063,22 @@ const handleSubmit = async () => {
group_id: formData.value.group_id,
status: formData.value.status,
ip_whitelist: ipWhitelist,
ip_blacklist: ipBlacklist
ip_blacklist: ipBlacklist,
quota: quota,
expires_at: expiresAt
})
appStore.showSuccess(t('keys.keyUpdatedSuccess'))
} else {
const customKey = formData.value.use_custom_key ? formData.value.custom_key : undefined
await keysAPI.create(formData.value.name, formData.value.group_id, customKey, ipWhitelist, ipBlacklist)
await keysAPI.create(
formData.value.name,
formData.value.group_id,
customKey,
ipWhitelist,
ipBlacklist,
quota,
expiresInDays
)
appStore.showSuccess(t('keys.keyCreatedSuccess'))
// Only advance tour if active, on submit step, and creation succeeded
if (onboardingStore.isCurrentStep('[data-tour="key-form-submit"]')) {
@@ -883,7 +1128,42 @@ const closeModals = () => {
custom_key: '',
enable_ip_restriction: false,
ip_whitelist: '',
ip_blacklist: ''
ip_blacklist: '',
enable_quota: false,
quota: null,
enable_expiration: false,
expiration_preset: '30',
expiration_date: ''
}
}
// Show reset quota confirmation dialog
const confirmResetQuota = () => {
showResetQuotaDialog.value = true
}
// Set expiration date based on quick select days
const setExpirationDays = (days: number) => {
formData.value.expiration_preset = days.toString() as '7' | '30' | '90'
const expDate = new Date()
expDate.setDate(expDate.getDate() + days)
formData.value.expiration_date = formatDateTimeLocal(expDate.toISOString())
}
// Reset quota used for an API key
const resetQuotaUsed = async () => {
if (!selectedKey.value) return
showResetQuotaDialog.value = false
try {
await keysAPI.update(selectedKey.value.id, { reset_quota: true })
appStore.showSuccess(t('keys.quotaResetSuccess'))
// Update local state
if (selectedKey.value) {
selectedKey.value.quota_used = 0
}
} catch (error: any) {
const errorMsg = error.response?.data?.detail || t('keys.failedToResetQuota')
appStore.showError(errorMsg)
}
}