mirror of
https://github.com/fawney19/Aether.git
synced 2026-01-11 03:58:28 +08:00
feat: 用户用量页面支持分页、搜索和密钥信息展示
- 用户用量API增加search参数支持密钥名、模型名搜索 - 用户用量API返回api_key信息(id、name、display) - 用户页面记录表格增加密钥列显示 - 前端统一管理员和用户页面的分页/搜索逻辑 - 后端LIKE查询增加特殊字符转义防止SQL注入 - 添加escape_like_pattern和safe_truncate_escaped工具函数
This commit is contained in:
@@ -62,6 +62,11 @@ export interface UsageRecordDetail {
|
||||
cache_creation_price_per_1m?: number
|
||||
cache_read_price_per_1m?: number
|
||||
price_per_request?: number // 按次计费价格
|
||||
api_key?: {
|
||||
id: string
|
||||
name: string
|
||||
display: string
|
||||
}
|
||||
}
|
||||
|
||||
// 模型统计接口
|
||||
@@ -192,6 +197,7 @@ export const meApi = {
|
||||
async getUsage(params?: {
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
search?: string // 通用搜索:密钥名、模型名
|
||||
limit?: number
|
||||
offset?: number
|
||||
}): Promise<UsageResponse> {
|
||||
|
||||
@@ -164,9 +164,9 @@ export const usageApi = {
|
||||
async getAllUsageRecords(params?: {
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
search?: string // 通用搜索:用户名、密钥名、模型名、提供商名
|
||||
user_id?: string // UUID
|
||||
username?: string
|
||||
user_api_key_name?: string
|
||||
model?: string
|
||||
provider?: string
|
||||
status?: string // 'stream' | 'standard' | 'error'
|
||||
|
||||
@@ -32,6 +32,17 @@
|
||||
<!-- 分隔线 -->
|
||||
<div class="hidden sm:block h-4 w-px bg-border" />
|
||||
|
||||
<!-- 通用搜索 -->
|
||||
<div class="relative">
|
||||
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground z-10 pointer-events-none" />
|
||||
<Input
|
||||
id="usage-records-search"
|
||||
v-model="localSearch"
|
||||
:placeholder="isAdmin ? '搜索用户/密钥/模型/提供商' : '搜索密钥/模型'"
|
||||
class="w-32 sm:w-48 h-8 text-xs border-border/60 pl-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 用户筛选(仅管理员可见) -->
|
||||
<Select
|
||||
v-if="isAdmin && availableUsers.length > 0"
|
||||
@@ -56,20 +67,6 @@
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<!-- Key 名称筛选(请求使用的用户 Key,仅管理员可见) -->
|
||||
<div
|
||||
v-if="isAdmin"
|
||||
class="relative"
|
||||
>
|
||||
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground z-10 pointer-events-none" />
|
||||
<Input
|
||||
id="usage-records-key-name"
|
||||
v-model="localKeyName"
|
||||
placeholder="搜索密钥名称"
|
||||
class="w-24 sm:w-40 h-8 text-xs border-border/60 pl-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 模型筛选 -->
|
||||
<Select
|
||||
v-model:open="filterModelSelectOpen"
|
||||
@@ -178,6 +175,12 @@
|
||||
>
|
||||
用户
|
||||
</TableHead>
|
||||
<TableHead
|
||||
v-if="!isAdmin"
|
||||
class="h-12 font-semibold w-[100px]"
|
||||
>
|
||||
密钥
|
||||
</TableHead>
|
||||
<TableHead class="h-12 font-semibold w-[140px]">
|
||||
模型
|
||||
</TableHead>
|
||||
@@ -210,7 +213,7 @@
|
||||
<TableBody>
|
||||
<TableRow v-if="records.length === 0">
|
||||
<TableCell
|
||||
:colspan="isAdmin ? 9 : 7"
|
||||
:colspan="isAdmin ? 9 : 8"
|
||||
class="text-center py-12 text-muted-foreground"
|
||||
>
|
||||
暂无请求记录
|
||||
@@ -245,6 +248,22 @@
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<!-- 用户页面的密钥列 -->
|
||||
<TableCell
|
||||
v-if="!isAdmin"
|
||||
class="py-4 w-[100px]"
|
||||
:title="record.api_key?.name || '-'"
|
||||
>
|
||||
<div class="flex flex-col text-xs gap-0.5">
|
||||
<span class="truncate">{{ record.api_key?.name || '-' }}</span>
|
||||
<span
|
||||
v-if="record.api_key?.display"
|
||||
class="text-muted-foreground truncate"
|
||||
>
|
||||
{{ record.api_key.display }}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
class="font-medium py-4 w-[140px]"
|
||||
:title="getModelTooltip(record)"
|
||||
@@ -497,8 +516,8 @@ const props = defineProps<{
|
||||
// 时间段
|
||||
selectedPeriod: string
|
||||
// 筛选
|
||||
filterSearch: string
|
||||
filterUser: string
|
||||
filterKeyName: string
|
||||
filterModel: string
|
||||
filterProvider: string
|
||||
filterStatus: string
|
||||
@@ -516,8 +535,8 @@ const props = defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:selectedPeriod': [value: string]
|
||||
'update:filterSearch': [value: string]
|
||||
'update:filterUser': [value: string]
|
||||
'update:filterKeyName': [value: string]
|
||||
'update:filterModel': [value: string]
|
||||
'update:filterProvider': [value: string]
|
||||
'update:filterStatus': [value: string]
|
||||
@@ -535,20 +554,20 @@ const filterModelSelectOpen = ref(false)
|
||||
const filterProviderSelectOpen = ref(false)
|
||||
const filterStatusSelectOpen = ref(false)
|
||||
|
||||
// Key 名称筛选(输入防抖)
|
||||
const localKeyName = ref(props.filterKeyName)
|
||||
let keyNameDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
// 通用搜索(输入防抖)
|
||||
const localSearch = ref(props.filterSearch)
|
||||
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
watch(() => props.filterKeyName, (value) => {
|
||||
if (value !== localKeyName.value) {
|
||||
localKeyName.value = value
|
||||
watch(() => props.filterSearch, (value) => {
|
||||
if (value !== localSearch.value) {
|
||||
localSearch.value = value
|
||||
}
|
||||
})
|
||||
|
||||
watch(localKeyName, (value) => {
|
||||
if (keyNameDebounceTimer) clearTimeout(keyNameDebounceTimer)
|
||||
keyNameDebounceTimer = setTimeout(() => {
|
||||
emit('update:filterKeyName', value)
|
||||
watch(localSearch, (value) => {
|
||||
if (searchDebounceTimer) clearTimeout(searchDebounceTimer)
|
||||
searchDebounceTimer = setTimeout(() => {
|
||||
emit('update:filterSearch', value)
|
||||
}, 300)
|
||||
})
|
||||
|
||||
@@ -619,9 +638,9 @@ function handleRowClick(event: MouseEvent, id: string) {
|
||||
// 组件卸载时清理
|
||||
onUnmounted(() => {
|
||||
stopTimer()
|
||||
if (keyNameDebounceTimer) {
|
||||
clearTimeout(keyNameDebounceTimer)
|
||||
keyNameDebounceTimer = null
|
||||
if (searchDebounceTimer) {
|
||||
clearTimeout(searchDebounceTimer)
|
||||
searchDebounceTimer = null
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -23,8 +23,8 @@ export interface PaginationParams {
|
||||
}
|
||||
|
||||
export interface FilterParams {
|
||||
search?: string
|
||||
user_id?: string
|
||||
user_api_key_name?: string
|
||||
model?: string
|
||||
provider?: string
|
||||
status?: string
|
||||
@@ -235,11 +235,6 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
pagination: PaginationParams,
|
||||
filters?: FilterParams
|
||||
): Promise<void> {
|
||||
if (!isAdminPage.value) {
|
||||
// 用户页面不需要分页加载,记录已在 loadStats 中获取
|
||||
return
|
||||
}
|
||||
|
||||
isLoadingRecords.value = true
|
||||
|
||||
try {
|
||||
@@ -253,27 +248,34 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
}
|
||||
|
||||
// 添加筛选条件
|
||||
if (filters?.user_id) {
|
||||
params.user_id = filters.user_id
|
||||
}
|
||||
if (filters?.user_api_key_name) {
|
||||
params.user_api_key_name = filters.user_api_key_name
|
||||
}
|
||||
if (filters?.model) {
|
||||
params.model = filters.model
|
||||
}
|
||||
if (filters?.provider) {
|
||||
params.provider = filters.provider
|
||||
}
|
||||
if (filters?.status) {
|
||||
params.status = filters.status
|
||||
if (filters?.search?.trim()) {
|
||||
params.search = filters.search.trim()
|
||||
}
|
||||
|
||||
const response = await usageApi.getAllUsageRecords(params)
|
||||
|
||||
currentRecords.value = (response.records || []) as UsageRecord[]
|
||||
totalRecords.value = response.total || 0
|
||||
if (isAdminPage.value) {
|
||||
// 管理员页面:使用管理员 API
|
||||
if (filters?.user_id) {
|
||||
params.user_id = filters.user_id
|
||||
}
|
||||
if (filters?.model) {
|
||||
params.model = filters.model
|
||||
}
|
||||
if (filters?.provider) {
|
||||
params.provider = filters.provider
|
||||
}
|
||||
if (filters?.status) {
|
||||
params.status = filters.status
|
||||
}
|
||||
|
||||
const response = await usageApi.getAllUsageRecords(params)
|
||||
currentRecords.value = (response.records || []) as UsageRecord[]
|
||||
totalRecords.value = response.total || 0
|
||||
} else {
|
||||
// 用户页面:使用用户 API
|
||||
const userData = await meApi.getUsage(params)
|
||||
currentRecords.value = (userData.records || []) as UsageRecord[]
|
||||
totalRecords.value = userData.pagination?.total || currentRecords.value.length
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('加载记录失败:', error)
|
||||
currentRecords.value = []
|
||||
|
||||
@@ -845,10 +845,19 @@ const mockHandlers: Record<string, (config: AxiosRequestConfig) => Promise<Axios
|
||||
const limit = parseInt(params.limit) || 20
|
||||
const offset = parseInt(params.offset) || 0
|
||||
|
||||
// 用户 API Key 名称筛选(注意:不是 Provider Key)
|
||||
if (typeof params.user_api_key_name === 'string' && params.user_api_key_name.trim()) {
|
||||
const keyword = params.user_api_key_name.trim().toLowerCase()
|
||||
records = records.filter(r => (r.api_key?.name || '').toLowerCase().includes(keyword))
|
||||
// 通用搜索:用户名、密钥名、模型名、提供商名
|
||||
// 支持空格分隔的组合搜索,多个关键词之间是 AND 关系
|
||||
if (typeof params.search === 'string' && params.search.trim()) {
|
||||
const keywords = params.search.trim().toLowerCase().split(/\s+/)
|
||||
records = records.filter(r => {
|
||||
// 每个关键词都要匹配至少一个字段
|
||||
return keywords.every((keyword: string) =>
|
||||
(r.username || '').toLowerCase().includes(keyword) ||
|
||||
(r.api_key?.name || '').toLowerCase().includes(keyword) ||
|
||||
(r.model || '').toLowerCase().includes(keyword) ||
|
||||
(r.provider || '').toLowerCase().includes(keyword)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return createMockResponse({
|
||||
|
||||
@@ -56,8 +56,8 @@
|
||||
:show-actual-cost="authStore.isAdmin"
|
||||
:loading="isLoadingRecords"
|
||||
:selected-period="selectedPeriod"
|
||||
:filter-search="filterSearch"
|
||||
:filter-user="filterUser"
|
||||
:filter-key-name="filterKeyName"
|
||||
:filter-model="filterModel"
|
||||
:filter-provider="filterProvider"
|
||||
:filter-status="filterStatus"
|
||||
@@ -70,8 +70,8 @@
|
||||
:page-size-options="pageSizeOptions"
|
||||
:auto-refresh="globalAutoRefresh"
|
||||
@update:selected-period="handlePeriodChange"
|
||||
@update:filter-search="handleFilterSearchChange"
|
||||
@update:filter-user="handleFilterUserChange"
|
||||
@update:filter-key-name="handleFilterKeyNameChange"
|
||||
@update:filter-model="handleFilterModelChange"
|
||||
@update:filter-provider="handleFilterProviderChange"
|
||||
@update:filter-status="handleFilterStatusChange"
|
||||
@@ -135,8 +135,8 @@ const pageSize = ref(20)
|
||||
const pageSizeOptions = [10, 20, 50, 100]
|
||||
|
||||
// 筛选状态
|
||||
const filterSearch = ref('')
|
||||
const filterUser = ref('__all__')
|
||||
const filterKeyName = ref('')
|
||||
const filterModel = ref('__all__')
|
||||
const filterProvider = ref('__all__')
|
||||
const filterStatus = ref<FilterStatusValue>('__all__')
|
||||
@@ -395,14 +395,17 @@ onMounted(async () => {
|
||||
// 热力图加载失败不提示,因为 UI 已显示占位符
|
||||
}
|
||||
|
||||
// 管理员页面加载用户列表和第一页记录
|
||||
// 加载记录和用户列表
|
||||
if (isAdminPage.value) {
|
||||
// 并行加载用户列表和记录
|
||||
// 管理员页面:并行加载用户列表和记录
|
||||
const [users] = await Promise.all([
|
||||
usersApi.getAllUsers(),
|
||||
loadRecords({ page: currentPage.value, pageSize: pageSize.value }, getCurrentFilters())
|
||||
])
|
||||
availableUsers.value = users.map(u => ({ id: u.id, username: u.username, email: u.email }))
|
||||
} else {
|
||||
// 用户页面:加载记录
|
||||
await loadRecords({ page: currentPage.value, pageSize: pageSize.value }, getCurrentFilters())
|
||||
}
|
||||
})
|
||||
|
||||
@@ -413,36 +416,27 @@ async function handlePeriodChange(value: string) {
|
||||
|
||||
const dateRange = getDateRangeFromPeriod(selectedPeriod.value)
|
||||
await loadStats(dateRange)
|
||||
|
||||
if (isAdminPage.value) {
|
||||
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters())
|
||||
}
|
||||
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters())
|
||||
}
|
||||
|
||||
// 处理分页变化
|
||||
async function handlePageChange(page: number) {
|
||||
currentPage.value = page
|
||||
|
||||
if (isAdminPage.value) {
|
||||
await loadRecords({ page, pageSize: pageSize.value }, getCurrentFilters())
|
||||
}
|
||||
await loadRecords({ page, pageSize: pageSize.value }, getCurrentFilters())
|
||||
}
|
||||
|
||||
// 处理每页大小变化
|
||||
async function handlePageSizeChange(size: number) {
|
||||
pageSize.value = size
|
||||
currentPage.value = 1 // 重置到第一页
|
||||
|
||||
if (isAdminPage.value) {
|
||||
await loadRecords({ page: 1, pageSize: size }, getCurrentFilters())
|
||||
}
|
||||
await loadRecords({ page: 1, pageSize: size }, getCurrentFilters())
|
||||
}
|
||||
|
||||
// 获取当前筛选参数
|
||||
function getCurrentFilters() {
|
||||
return {
|
||||
search: filterSearch.value.trim() || undefined,
|
||||
user_id: filterUser.value !== '__all__' ? filterUser.value : undefined,
|
||||
user_api_key_name: filterKeyName.value.trim() ? filterKeyName.value.trim() : undefined,
|
||||
model: filterModel.value !== '__all__' ? filterModel.value : undefined,
|
||||
provider: filterProvider.value !== '__all__' ? filterProvider.value : undefined,
|
||||
status: filterStatus.value !== '__all__' ? filterStatus.value : undefined
|
||||
@@ -450,6 +444,13 @@ function getCurrentFilters() {
|
||||
}
|
||||
|
||||
// 处理筛选变化
|
||||
async function handleFilterSearchChange(value: string) {
|
||||
filterSearch.value = value
|
||||
currentPage.value = 1
|
||||
|
||||
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters())
|
||||
}
|
||||
|
||||
async function handleFilterUserChange(value: string) {
|
||||
filterUser.value = value
|
||||
currentPage.value = 1 // 重置到第一页
|
||||
@@ -459,15 +460,6 @@ async function handleFilterUserChange(value: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFilterKeyNameChange(value: string) {
|
||||
filterKeyName.value = value
|
||||
currentPage.value = 1
|
||||
|
||||
if (isAdminPage.value) {
|
||||
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters())
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFilterModelChange(value: string) {
|
||||
filterModel.value = value
|
||||
currentPage.value = 1 // 重置到第一页
|
||||
@@ -499,10 +491,7 @@ async function handleFilterStatusChange(value: string) {
|
||||
async function refreshData() {
|
||||
const dateRange = getDateRangeFromPeriod(selectedPeriod.value)
|
||||
await loadStats(dateRange)
|
||||
|
||||
if (isAdminPage.value) {
|
||||
await loadRecords({ page: currentPage.value, pageSize: pageSize.value }, getCurrentFilters())
|
||||
}
|
||||
await loadRecords({ page: currentPage.value, pageSize: pageSize.value }, getCurrentFilters())
|
||||
}
|
||||
|
||||
// 显示请求详情
|
||||
|
||||
Reference in New Issue
Block a user