mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
feat(usage): 记录并展示失败请求(用户端+管理端)
- 记录失败请求并在用户端/管理端展示;分类下拉改用统一 Select 组件 - 模型过滤改后端 ILIKE 模糊匹配;新增「Key 名称」列(含已删除标记)与按 Key 过滤;时间列移至末列 - 用户可见「已删除 key 失败请求」:OpsErrorLogFilter 加 MatchDeletedKeyOwner,用户侧归属 放宽为 (user_id OR deleted_key_owner_user_id),让 key 原所有者能看到删除 key 后继续请求 导致的认证失败记录(他人仍 NotFound,不泄露存在性) - 迁移 148:ops_error_logs 用户+时间索引 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed by
QTom
co-authored by
Claude Opus 4.8
parent
ddf063352a
commit
cfb195c7b2
@@ -4398,6 +4398,35 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Usage Records Settings -->
|
||||
<div class="card">
|
||||
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('admin.settings.usageRecords.title') }}
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.usageRecords.description') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-4 p-6">
|
||||
<!-- User error requests visibility -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.settings.user_error_view.label') }}
|
||||
</label>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.user_error_view.description') }}
|
||||
</p>
|
||||
</div>
|
||||
<label class="toggle">
|
||||
<input v-model="form.allow_user_view_error_requests" type="checkbox" />
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- /Tab: Gateway — Claude Code, Scheduling -->
|
||||
|
||||
@@ -7190,6 +7219,8 @@ const form = reactive<SettingsForm>({
|
||||
available_channels_enabled: false,
|
||||
// Affiliate (邀请返利) feature switch
|
||||
affiliate_enabled: false,
|
||||
// Allow user view error requests
|
||||
allow_user_view_error_requests: false,
|
||||
});
|
||||
|
||||
const authSourceDefaults = reactive<AuthSourceDefaultsState>(
|
||||
@@ -8331,6 +8362,7 @@ async function saveSettings() {
|
||||
available_channels_enabled: form.available_channels_enabled,
|
||||
// Affiliate (邀请返利) feature switch
|
||||
affiliate_enabled: form.affiliate_enabled,
|
||||
allow_user_view_error_requests: form.allow_user_view_error_requests,
|
||||
};
|
||||
|
||||
// 仅当 openai_fast_policy_settings 已成功从后端加载时才回写,
|
||||
|
||||
@@ -100,17 +100,36 @@
|
||||
</div>
|
||||
</template>
|
||||
</UsageFilters>
|
||||
<UsageTable
|
||||
:data="usageLogs"
|
||||
:loading="loading"
|
||||
:columns="visibleColumns"
|
||||
:server-side-sort="true"
|
||||
:default-sort-key="'created_at'"
|
||||
:default-sort-order="'desc'"
|
||||
@sort="handleSort"
|
||||
@userClick="handleUserClick"
|
||||
/>
|
||||
<Pagination v-if="pagination.total > 0" :page="pagination.page" :total="pagination.total" :page-size="pagination.page_size" @update:page="handlePageChange" @update:pageSize="handlePageSizeChange" />
|
||||
<div class="mb-4 flex gap-2 border-b border-gray-200 dark:border-dark-700">
|
||||
<button class="tab" :class="{ 'tab-active': activeTab === 'usage' }" @click="activeTab = 'usage'">
|
||||
{{ t('usage.tabs.usage') }}
|
||||
</button>
|
||||
<button class="tab" :class="{ 'tab-active': activeTab === 'errors' }" @click="switchToErrorsTab">
|
||||
{{ t('usage.tabs.errors') }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-show="activeTab === 'usage'">
|
||||
<UsageTable
|
||||
:data="usageLogs"
|
||||
:loading="loading"
|
||||
:columns="visibleColumns"
|
||||
:server-side-sort="true"
|
||||
:default-sort-key="'created_at'"
|
||||
:default-sort-order="'desc'"
|
||||
@sort="handleSort"
|
||||
@userClick="handleUserClick"
|
||||
/>
|
||||
<Pagination v-if="pagination.total > 0" :page="pagination.page" :total="pagination.total" :page-size="pagination.page_size" @update:page="handlePageChange" @update:pageSize="handlePageSizeChange" />
|
||||
</div>
|
||||
<div v-show="activeTab === 'errors'">
|
||||
<OpsErrorLogTable
|
||||
:rows="errRows" :total="errTotal" :loading="errLoading"
|
||||
:page="errPage" :page-size="errPageSize"
|
||||
@openErrorDetail="openError"
|
||||
@update:page="onErrPage"
|
||||
@update:pageSize="onErrPageSize" />
|
||||
<OpsErrorDetailModal v-model:show="showErrorModal" :error-id="selectedErrorId" :error-type="'request'" />
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
<UsageExportProgress :show="exportProgress.show" :progress="exportProgress.progress" :current="exportProgress.current" :total="exportProgress.total" :estimated-time="exportProgress.estimatedTime" @cancel="cancelExport" />
|
||||
@@ -144,6 +163,10 @@ import UsageStatsCards from '@/components/admin/usage/UsageStatsCards.vue'; impo
|
||||
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 UserBalanceHistoryModal from '@/components/admin/user/UserBalanceHistoryModal.vue'
|
||||
import OpsErrorLogTable from '@/views/admin/ops/components/OpsErrorLogTable.vue'
|
||||
import OpsErrorDetailModal from '@/views/admin/ops/components/OpsErrorDetailModal.vue'
|
||||
import { listErrorLogs } from '@/api/admin/ops'
|
||||
import type { OpsErrorLog } from '@/api/admin/ops'
|
||||
import ModelDistributionChart from '@/components/charts/ModelDistributionChart.vue'; import GroupDistributionChart from '@/components/charts/GroupDistributionChart.vue'; import TokenUsageTrend from '@/components/charts/TokenUsageTrend.vue'
|
||||
import EndpointDistributionChart from '@/components/charts/EndpointDistributionChart.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
@@ -432,6 +455,12 @@ const applyFilters = () => {
|
||||
loadStats()
|
||||
loadModelStats(modelDistributionSource.value, true)
|
||||
loadChartData()
|
||||
errPage.value = 1
|
||||
if (activeTab.value === 'errors') {
|
||||
loadAdminErrors()
|
||||
} else {
|
||||
errRows.value = []
|
||||
}
|
||||
}
|
||||
const refreshData = () => {
|
||||
invalidateModelStatsCache()
|
||||
@@ -439,6 +468,7 @@ const refreshData = () => {
|
||||
loadStats(true)
|
||||
loadModelStats(modelDistributionSource.value, true)
|
||||
loadChartData()
|
||||
if (activeTab.value === 'errors') loadAdminErrors()
|
||||
}
|
||||
const resetFilters = () => {
|
||||
const range = getLast24HoursRangeDates()
|
||||
@@ -592,6 +622,47 @@ const loadSavedColumns = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Error tab state
|
||||
const activeTab = ref<'usage' | 'errors'>('usage')
|
||||
const errRows = ref<OpsErrorLog[]>([])
|
||||
const errLoading = ref(false)
|
||||
const errPage = ref(1)
|
||||
const errPageSize = ref(20)
|
||||
const errTotal = ref(0)
|
||||
const showErrorModal = ref(false)
|
||||
const selectedErrorId = ref<number | null>(null)
|
||||
|
||||
// 注意:'YYYY-MM-DDT00:00:00' 无时区后缀,按本地时区解析后再转 UTC——与页面其它日期处理语义一致,刻意如此,勿改成 'T00:00:00Z'
|
||||
const toRFC3339 = (d: string | undefined, endOfDay = false): string | undefined =>
|
||||
d ? new Date(d + (endOfDay ? 'T23:59:59.999' : 'T00:00:00')).toISOString() : undefined
|
||||
|
||||
const loadAdminErrors = async () => {
|
||||
errLoading.value = true
|
||||
try {
|
||||
const resp = await listErrorLogs({
|
||||
page: errPage.value,
|
||||
page_size: errPageSize.value,
|
||||
view: 'all',
|
||||
start_time: toRFC3339(filters.value.start_date),
|
||||
end_time: toRFC3339(filters.value.end_date, true),
|
||||
user_id: filters.value.user_id ?? undefined,
|
||||
api_key_id: filters.value.api_key_id ?? undefined,
|
||||
})
|
||||
errRows.value = resp.items
|
||||
errTotal.value = resp.total
|
||||
} catch (error) {
|
||||
console.error('Failed to load admin errors:', error)
|
||||
appStore.showError(t('usage.errors.failedToLoad'))
|
||||
} finally {
|
||||
errLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onErrPage = (p: number) => { errPage.value = p; loadAdminErrors() }
|
||||
const onErrPageSize = (s: number) => { errPageSize.value = s; errPage.value = 1; loadAdminErrors() }
|
||||
const openError = (id: number) => { selectedErrorId.value = id; showErrorModal.value = true }
|
||||
const switchToErrorsTab = () => { activeTab.value = 'errors'; if (errRows.value.length === 0) loadAdminErrors() }
|
||||
|
||||
const showColumnDropdown = ref(false)
|
||||
const columnDropdownRef = ref<HTMLElement | null>(null)
|
||||
|
||||
|
||||
@@ -149,7 +149,19 @@
|
||||
</template>
|
||||
|
||||
<template #table>
|
||||
<DataTable
|
||||
<!-- Tab 切换栏 -->
|
||||
<div v-if="errorViewEnabled" class="mb-0 flex gap-2 border-b border-gray-200 px-4 pt-3 dark:border-dark-700">
|
||||
<button class="tab" :class="{ 'tab-active': activeTab === 'usage' }" @click="activeTab = 'usage'">
|
||||
{{ t('usage.tabs.usage') }}
|
||||
</button>
|
||||
<button class="tab" :class="{ 'tab-active': activeTab === 'errors' }" @click="switchToErrors">
|
||||
{{ t('usage.tabs.errors') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 用量明细表 -->
|
||||
<div v-show="activeTab === 'usage'" class="flex min-h-0 flex-1 flex-col">
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:data="usageLogs"
|
||||
:loading="loading"
|
||||
@@ -332,11 +344,27 @@
|
||||
<EmptyState :message="t('usage.noRecords')" />
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<!-- 错误请求表 -->
|
||||
<div v-if="errorViewEnabled" v-show="activeTab === 'errors'" class="flex min-h-0 flex-1 flex-col">
|
||||
<UserErrorRequestsTable
|
||||
:rows="errorRows"
|
||||
:total="errorTotal"
|
||||
:loading="errorLoading"
|
||||
:page="errorPage"
|
||||
:page-size="errorPageSize"
|
||||
:api-keys="apiKeys"
|
||||
@filter="onErrorFilter"
|
||||
@update:page="onErrorPage"
|
||||
@update:pageSize="onErrorPageSize"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #pagination>
|
||||
<Pagination
|
||||
v-if="pagination.total > 0"
|
||||
v-if="pagination.total > 0 && activeTab === 'usage'"
|
||||
:page="pagination.page"
|
||||
:total="pagination.total"
|
||||
:page-size="pagination.page_size"
|
||||
@@ -550,7 +578,8 @@ import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import DateRangePicker from '@/components/common/DateRangePicker.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import type { UsageLog, ApiKey, UsageQueryParams, UsageStatsResponse } from '@/types'
|
||||
import UserErrorRequestsTable from '@/components/user/UserErrorRequestsTable.vue'
|
||||
import type { UsageLog, ApiKey, UsageQueryParams, UsageStatsResponse, UserErrorRequest } from '@/types'
|
||||
import type { Column } from '@/components/common/types'
|
||||
import { formatDateTime, formatReasoningEffort } from '@/utils/format'
|
||||
import { getPersistedPageSize } from '@/composables/usePersistedPageSize'
|
||||
@@ -653,6 +682,12 @@ const onDateRangeChange = (range: {
|
||||
filters.value.start_date = range.startDate
|
||||
filters.value.end_date = range.endDate
|
||||
applyFilters()
|
||||
errorPage.value = 1
|
||||
if (activeTab.value === 'errors') {
|
||||
loadErrors()
|
||||
} else {
|
||||
errorRows.value = [] // 失效,下次切到 errors tab 时按新日期重新加载
|
||||
}
|
||||
}
|
||||
|
||||
const pagination = reactive({
|
||||
@@ -988,6 +1023,52 @@ const hideTokenTooltip = () => {
|
||||
tokenTooltipData.value = null
|
||||
}
|
||||
|
||||
// ── Error Requests Tab ──────────────────────────────────────────────────────
|
||||
const activeTab = ref<'usage' | 'errors'>('usage')
|
||||
const errorViewEnabled = computed(() => appStore.cachedPublicSettings?.allow_user_view_error_requests ?? false)
|
||||
|
||||
const errorRows = ref<UserErrorRequest[]>([])
|
||||
const errorLoading = ref(false)
|
||||
const errorPage = ref(1)
|
||||
const errorPageSize = ref(20)
|
||||
const errorTotal = ref(0)
|
||||
const errorFilter = ref<{ model: string; category: string; api_key_id: number | null }>({ model: '', category: '', api_key_id: null })
|
||||
|
||||
const loadErrors = async () => {
|
||||
errorLoading.value = true
|
||||
try {
|
||||
const resp = await usageAPI.listMyErrorRequests({
|
||||
page: errorPage.value,
|
||||
page_size: errorPageSize.value,
|
||||
start_date: startDate.value,
|
||||
end_date: endDate.value,
|
||||
model: errorFilter.value.model || undefined,
|
||||
category: errorFilter.value.category || undefined,
|
||||
api_key_id: errorFilter.value.api_key_id ?? undefined,
|
||||
})
|
||||
errorRows.value = resp.items
|
||||
errorTotal.value = resp.total
|
||||
} catch (error) {
|
||||
console.error('[UsageView] loadErrors failed:', error)
|
||||
appStore.showError(t('usage.errors.failedToLoad'))
|
||||
} finally {
|
||||
errorLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onErrorFilter = (f: { model: string; category: string; api_key_id: number | null }) => {
|
||||
errorFilter.value = f
|
||||
errorPage.value = 1
|
||||
loadErrors()
|
||||
}
|
||||
const onErrorPage = (p: number) => { errorPage.value = p; loadErrors() }
|
||||
const onErrorPageSize = (s: number) => { errorPageSize.value = s; errorPage.value = 1; loadErrors() }
|
||||
|
||||
const switchToErrors = () => {
|
||||
activeTab.value = 'errors'
|
||||
if (errorRows.value.length === 0) loadErrors()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadApiKeys()
|
||||
loadUsageLogs()
|
||||
|
||||
Reference in New Issue
Block a user