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