mirror of
https://github.com/fawney19/Aether.git
synced 2026-01-08 18:52: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:
19
frontend/package-lock.json
generated
19
frontend/package-lock.json
generated
@@ -262,6 +262,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
@@ -305,6 +306,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -1598,6 +1600,7 @@
|
|||||||
"integrity": "sha512-GKBNHjoNw3Kra1Qg5UXttsY5kiWMEfoHq2TmXb+b1rcm6N7B3wTrFYIf/oSZ1xNQ+hVVijgLkiDZh7jRRsh+Gw==",
|
"integrity": "sha512-GKBNHjoNw3Kra1Qg5UXttsY5kiWMEfoHq2TmXb+b1rcm6N7B3wTrFYIf/oSZ1xNQ+hVVijgLkiDZh7jRRsh+Gw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.10.0"
|
"undici-types": "~7.10.0"
|
||||||
}
|
}
|
||||||
@@ -1676,6 +1679,7 @@
|
|||||||
"integrity": "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==",
|
"integrity": "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.49.0",
|
"@typescript-eslint/scope-manager": "8.49.0",
|
||||||
"@typescript-eslint/types": "8.49.0",
|
"@typescript-eslint/types": "8.49.0",
|
||||||
@@ -2004,6 +2008,7 @@
|
|||||||
"integrity": "sha512-oWtNM89Np+YsQO3ttT5i1Aer/0xbzQzp66NzuJn/U16bB7MnvSzdLKXgk1kkMLYyKSSzA2ajzqMkYheaE9opuQ==",
|
"integrity": "sha512-oWtNM89Np+YsQO3ttT5i1Aer/0xbzQzp66NzuJn/U16bB7MnvSzdLKXgk1kkMLYyKSSzA2ajzqMkYheaE9opuQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/utils": "4.0.10",
|
"@vitest/utils": "4.0.10",
|
||||||
"fflate": "^0.8.2",
|
"fflate": "^0.8.2",
|
||||||
@@ -2301,6 +2306,7 @@
|
|||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -2602,6 +2608,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.8.2",
|
"baseline-browser-mapping": "^2.8.2",
|
||||||
"caniuse-lite": "^1.0.30001741",
|
"caniuse-lite": "^1.0.30001741",
|
||||||
@@ -2718,6 +2725,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz",
|
||||||
"integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==",
|
"integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kurkle/color": "^0.3.0"
|
"@kurkle/color": "^0.3.0"
|
||||||
},
|
},
|
||||||
@@ -2940,6 +2948,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||||
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
"url": "https://github.com/sponsors/kossnocorp"
|
"url": "https://github.com/sponsors/kossnocorp"
|
||||||
@@ -3192,6 +3201,7 @@
|
|||||||
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
|
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
@@ -4091,6 +4101,7 @@
|
|||||||
"integrity": "sha512-454TI39PeRDW1LgpyLPyURtB4Zx1tklSr6+OFOipsxGUH1WMTvk6C65JQdrj455+DP2uJ1+veBEHTGFKWVLFoA==",
|
"integrity": "sha512-454TI39PeRDW1LgpyLPyURtB4Zx1tklSr6+OFOipsxGUH1WMTvk6C65JQdrj455+DP2uJ1+veBEHTGFKWVLFoA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@acemir/cssom": "^0.9.23",
|
"@acemir/cssom": "^0.9.23",
|
||||||
"@asamuzakjp/dom-selector": "^6.7.4",
|
"@asamuzakjp/dom-selector": "^6.7.4",
|
||||||
@@ -4655,6 +4666,7 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -4722,6 +4734,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -5752,6 +5765,7 @@
|
|||||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -5845,6 +5859,7 @@
|
|||||||
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.27.0",
|
"esbuild": "^0.27.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
@@ -5920,6 +5935,7 @@
|
|||||||
"integrity": "sha512-2Fqty3MM9CDwOVet/jaQalYlbcjATZwPYGcqpiYQqgQ/dLC7GuHdISKgTYIVF/kaishKxLzleKWWfbSDklyIKg==",
|
"integrity": "sha512-2Fqty3MM9CDwOVet/jaQalYlbcjATZwPYGcqpiYQqgQ/dLC7GuHdISKgTYIVF/kaishKxLzleKWWfbSDklyIKg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/expect": "4.0.10",
|
"@vitest/expect": "4.0.10",
|
||||||
"@vitest/mocker": "4.0.10",
|
"@vitest/mocker": "4.0.10",
|
||||||
@@ -6004,6 +6020,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.21.tgz",
|
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.21.tgz",
|
||||||
"integrity": "sha512-xxf9rum9KtOdwdRkiApWL+9hZEMWE90FHh8yS1+KJAiWYh+iGWV1FquPjoO9VUHQ+VIhsCXNNyZ5Sf4++RVZBA==",
|
"integrity": "sha512-xxf9rum9KtOdwdRkiApWL+9hZEMWE90FHh8yS1+KJAiWYh+iGWV1FquPjoO9VUHQ+VIhsCXNNyZ5Sf4++RVZBA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-dom": "3.5.21",
|
"@vue/compiler-dom": "3.5.21",
|
||||||
"@vue/compiler-sfc": "3.5.21",
|
"@vue/compiler-sfc": "3.5.21",
|
||||||
@@ -6036,7 +6053,6 @@
|
|||||||
"integrity": "sha512-CydUvFOQKD928UzZhTp4pr2vWz1L+H99t7Pkln2QSPdvmURT0MoC4wUccfCnuEaihNsu9aYYyk+bep8rlfkUXw==",
|
"integrity": "sha512-CydUvFOQKD928UzZhTp4pr2vWz1L+H99t7Pkln2QSPdvmURT0MoC4wUccfCnuEaihNsu9aYYyk+bep8rlfkUXw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"debug": "^4.4.0",
|
"debug": "^4.4.0",
|
||||||
"eslint-scope": "^8.2.0",
|
"eslint-scope": "^8.2.0",
|
||||||
@@ -6061,7 +6077,6 @@
|
|||||||
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
|
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -62,6 +62,11 @@ export interface UsageRecordDetail {
|
|||||||
cache_creation_price_per_1m?: number
|
cache_creation_price_per_1m?: number
|
||||||
cache_read_price_per_1m?: number
|
cache_read_price_per_1m?: number
|
||||||
price_per_request?: number // 按次计费价格
|
price_per_request?: number // 按次计费价格
|
||||||
|
api_key?: {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
display: string
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 模型统计接口
|
// 模型统计接口
|
||||||
@@ -192,6 +197,7 @@ export const meApi = {
|
|||||||
async getUsage(params?: {
|
async getUsage(params?: {
|
||||||
start_date?: string
|
start_date?: string
|
||||||
end_date?: string
|
end_date?: string
|
||||||
|
search?: string // 通用搜索:密钥名、模型名
|
||||||
limit?: number
|
limit?: number
|
||||||
offset?: number
|
offset?: number
|
||||||
}): Promise<UsageResponse> {
|
}): Promise<UsageResponse> {
|
||||||
|
|||||||
@@ -164,9 +164,9 @@ export const usageApi = {
|
|||||||
async getAllUsageRecords(params?: {
|
async getAllUsageRecords(params?: {
|
||||||
start_date?: string
|
start_date?: string
|
||||||
end_date?: string
|
end_date?: string
|
||||||
|
search?: string // 通用搜索:用户名、密钥名、模型名、提供商名
|
||||||
user_id?: string // UUID
|
user_id?: string // UUID
|
||||||
username?: string
|
username?: string
|
||||||
user_api_key_name?: string
|
|
||||||
model?: string
|
model?: string
|
||||||
provider?: string
|
provider?: string
|
||||||
status?: string // 'stream' | 'standard' | 'error'
|
status?: string // 'stream' | 'standard' | 'error'
|
||||||
|
|||||||
@@ -32,6 +32,17 @@
|
|||||||
<!-- 分隔线 -->
|
<!-- 分隔线 -->
|
||||||
<div class="hidden sm:block h-4 w-px bg-border" />
|
<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
|
<Select
|
||||||
v-if="isAdmin && availableUsers.length > 0"
|
v-if="isAdmin && availableUsers.length > 0"
|
||||||
@@ -56,20 +67,6 @@
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</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
|
<Select
|
||||||
v-model:open="filterModelSelectOpen"
|
v-model:open="filterModelSelectOpen"
|
||||||
@@ -178,6 +175,12 @@
|
|||||||
>
|
>
|
||||||
用户
|
用户
|
||||||
</TableHead>
|
</TableHead>
|
||||||
|
<TableHead
|
||||||
|
v-if="!isAdmin"
|
||||||
|
class="h-12 font-semibold w-[100px]"
|
||||||
|
>
|
||||||
|
密钥
|
||||||
|
</TableHead>
|
||||||
<TableHead class="h-12 font-semibold w-[140px]">
|
<TableHead class="h-12 font-semibold w-[140px]">
|
||||||
模型
|
模型
|
||||||
</TableHead>
|
</TableHead>
|
||||||
@@ -210,7 +213,7 @@
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
<TableRow v-if="records.length === 0">
|
<TableRow v-if="records.length === 0">
|
||||||
<TableCell
|
<TableCell
|
||||||
:colspan="isAdmin ? 9 : 7"
|
:colspan="isAdmin ? 9 : 8"
|
||||||
class="text-center py-12 text-muted-foreground"
|
class="text-center py-12 text-muted-foreground"
|
||||||
>
|
>
|
||||||
暂无请求记录
|
暂无请求记录
|
||||||
@@ -245,6 +248,22 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</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
|
<TableCell
|
||||||
class="font-medium py-4 w-[140px]"
|
class="font-medium py-4 w-[140px]"
|
||||||
:title="getModelTooltip(record)"
|
:title="getModelTooltip(record)"
|
||||||
@@ -497,8 +516,8 @@ const props = defineProps<{
|
|||||||
// 时间段
|
// 时间段
|
||||||
selectedPeriod: string
|
selectedPeriod: string
|
||||||
// 筛选
|
// 筛选
|
||||||
|
filterSearch: string
|
||||||
filterUser: string
|
filterUser: string
|
||||||
filterKeyName: string
|
|
||||||
filterModel: string
|
filterModel: string
|
||||||
filterProvider: string
|
filterProvider: string
|
||||||
filterStatus: string
|
filterStatus: string
|
||||||
@@ -516,8 +535,8 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:selectedPeriod': [value: string]
|
'update:selectedPeriod': [value: string]
|
||||||
|
'update:filterSearch': [value: string]
|
||||||
'update:filterUser': [value: string]
|
'update:filterUser': [value: string]
|
||||||
'update:filterKeyName': [value: string]
|
|
||||||
'update:filterModel': [value: string]
|
'update:filterModel': [value: string]
|
||||||
'update:filterProvider': [value: string]
|
'update:filterProvider': [value: string]
|
||||||
'update:filterStatus': [value: string]
|
'update:filterStatus': [value: string]
|
||||||
@@ -535,20 +554,20 @@ const filterModelSelectOpen = ref(false)
|
|||||||
const filterProviderSelectOpen = ref(false)
|
const filterProviderSelectOpen = ref(false)
|
||||||
const filterStatusSelectOpen = ref(false)
|
const filterStatusSelectOpen = ref(false)
|
||||||
|
|
||||||
// Key 名称筛选(输入防抖)
|
// 通用搜索(输入防抖)
|
||||||
const localKeyName = ref(props.filterKeyName)
|
const localSearch = ref(props.filterSearch)
|
||||||
let keyNameDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
watch(() => props.filterKeyName, (value) => {
|
watch(() => props.filterSearch, (value) => {
|
||||||
if (value !== localKeyName.value) {
|
if (value !== localSearch.value) {
|
||||||
localKeyName.value = value
|
localSearch.value = value
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(localKeyName, (value) => {
|
watch(localSearch, (value) => {
|
||||||
if (keyNameDebounceTimer) clearTimeout(keyNameDebounceTimer)
|
if (searchDebounceTimer) clearTimeout(searchDebounceTimer)
|
||||||
keyNameDebounceTimer = setTimeout(() => {
|
searchDebounceTimer = setTimeout(() => {
|
||||||
emit('update:filterKeyName', value)
|
emit('update:filterSearch', value)
|
||||||
}, 300)
|
}, 300)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -619,9 +638,9 @@ function handleRowClick(event: MouseEvent, id: string) {
|
|||||||
// 组件卸载时清理
|
// 组件卸载时清理
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
stopTimer()
|
stopTimer()
|
||||||
if (keyNameDebounceTimer) {
|
if (searchDebounceTimer) {
|
||||||
clearTimeout(keyNameDebounceTimer)
|
clearTimeout(searchDebounceTimer)
|
||||||
keyNameDebounceTimer = null
|
searchDebounceTimer = null
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ export interface PaginationParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface FilterParams {
|
export interface FilterParams {
|
||||||
|
search?: string
|
||||||
user_id?: string
|
user_id?: string
|
||||||
user_api_key_name?: string
|
|
||||||
model?: string
|
model?: string
|
||||||
provider?: string
|
provider?: string
|
||||||
status?: string
|
status?: string
|
||||||
@@ -235,11 +235,6 @@ export function useUsageData(options: UseUsageDataOptions) {
|
|||||||
pagination: PaginationParams,
|
pagination: PaginationParams,
|
||||||
filters?: FilterParams
|
filters?: FilterParams
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!isAdminPage.value) {
|
|
||||||
// 用户页面不需要分页加载,记录已在 loadStats 中获取
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
isLoadingRecords.value = true
|
isLoadingRecords.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -253,27 +248,34 @@ export function useUsageData(options: UseUsageDataOptions) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 添加筛选条件
|
// 添加筛选条件
|
||||||
if (filters?.user_id) {
|
if (filters?.search?.trim()) {
|
||||||
params.user_id = filters.user_id
|
params.search = filters.search.trim()
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await usageApi.getAllUsageRecords(params)
|
if (isAdminPage.value) {
|
||||||
|
// 管理员页面:使用管理员 API
|
||||||
currentRecords.value = (response.records || []) as UsageRecord[]
|
if (filters?.user_id) {
|
||||||
totalRecords.value = response.total || 0
|
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) {
|
} catch (error) {
|
||||||
log.error('加载记录失败:', error)
|
log.error('加载记录失败:', error)
|
||||||
currentRecords.value = []
|
currentRecords.value = []
|
||||||
|
|||||||
@@ -845,10 +845,19 @@ const mockHandlers: Record<string, (config: AxiosRequestConfig) => Promise<Axios
|
|||||||
const limit = parseInt(params.limit) || 20
|
const limit = parseInt(params.limit) || 20
|
||||||
const offset = parseInt(params.offset) || 0
|
const offset = parseInt(params.offset) || 0
|
||||||
|
|
||||||
// 用户 API Key 名称筛选(注意:不是 Provider Key)
|
// 通用搜索:用户名、密钥名、模型名、提供商名
|
||||||
if (typeof params.user_api_key_name === 'string' && params.user_api_key_name.trim()) {
|
// 支持空格分隔的组合搜索,多个关键词之间是 AND 关系
|
||||||
const keyword = params.user_api_key_name.trim().toLowerCase()
|
if (typeof params.search === 'string' && params.search.trim()) {
|
||||||
records = records.filter(r => (r.api_key?.name || '').toLowerCase().includes(keyword))
|
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({
|
return createMockResponse({
|
||||||
|
|||||||
@@ -56,8 +56,8 @@
|
|||||||
:show-actual-cost="authStore.isAdmin"
|
:show-actual-cost="authStore.isAdmin"
|
||||||
:loading="isLoadingRecords"
|
:loading="isLoadingRecords"
|
||||||
:selected-period="selectedPeriod"
|
:selected-period="selectedPeriod"
|
||||||
|
:filter-search="filterSearch"
|
||||||
:filter-user="filterUser"
|
:filter-user="filterUser"
|
||||||
:filter-key-name="filterKeyName"
|
|
||||||
:filter-model="filterModel"
|
:filter-model="filterModel"
|
||||||
:filter-provider="filterProvider"
|
:filter-provider="filterProvider"
|
||||||
:filter-status="filterStatus"
|
:filter-status="filterStatus"
|
||||||
@@ -70,8 +70,8 @@
|
|||||||
:page-size-options="pageSizeOptions"
|
:page-size-options="pageSizeOptions"
|
||||||
:auto-refresh="globalAutoRefresh"
|
:auto-refresh="globalAutoRefresh"
|
||||||
@update:selected-period="handlePeriodChange"
|
@update:selected-period="handlePeriodChange"
|
||||||
|
@update:filter-search="handleFilterSearchChange"
|
||||||
@update:filter-user="handleFilterUserChange"
|
@update:filter-user="handleFilterUserChange"
|
||||||
@update:filter-key-name="handleFilterKeyNameChange"
|
|
||||||
@update:filter-model="handleFilterModelChange"
|
@update:filter-model="handleFilterModelChange"
|
||||||
@update:filter-provider="handleFilterProviderChange"
|
@update:filter-provider="handleFilterProviderChange"
|
||||||
@update:filter-status="handleFilterStatusChange"
|
@update:filter-status="handleFilterStatusChange"
|
||||||
@@ -135,8 +135,8 @@ const pageSize = ref(20)
|
|||||||
const pageSizeOptions = [10, 20, 50, 100]
|
const pageSizeOptions = [10, 20, 50, 100]
|
||||||
|
|
||||||
// 筛选状态
|
// 筛选状态
|
||||||
|
const filterSearch = ref('')
|
||||||
const filterUser = ref('__all__')
|
const filterUser = ref('__all__')
|
||||||
const filterKeyName = ref('')
|
|
||||||
const filterModel = ref('__all__')
|
const filterModel = ref('__all__')
|
||||||
const filterProvider = ref('__all__')
|
const filterProvider = ref('__all__')
|
||||||
const filterStatus = ref<FilterStatusValue>('__all__')
|
const filterStatus = ref<FilterStatusValue>('__all__')
|
||||||
@@ -395,14 +395,17 @@ onMounted(async () => {
|
|||||||
// 热力图加载失败不提示,因为 UI 已显示占位符
|
// 热力图加载失败不提示,因为 UI 已显示占位符
|
||||||
}
|
}
|
||||||
|
|
||||||
// 管理员页面加载用户列表和第一页记录
|
// 加载记录和用户列表
|
||||||
if (isAdminPage.value) {
|
if (isAdminPage.value) {
|
||||||
// 并行加载用户列表和记录
|
// 管理员页面:并行加载用户列表和记录
|
||||||
const [users] = await Promise.all([
|
const [users] = await Promise.all([
|
||||||
usersApi.getAllUsers(),
|
usersApi.getAllUsers(),
|
||||||
loadRecords({ page: currentPage.value, pageSize: pageSize.value }, getCurrentFilters())
|
loadRecords({ page: currentPage.value, pageSize: pageSize.value }, getCurrentFilters())
|
||||||
])
|
])
|
||||||
availableUsers.value = users.map(u => ({ id: u.id, username: u.username, email: u.email }))
|
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)
|
const dateRange = getDateRangeFromPeriod(selectedPeriod.value)
|
||||||
await loadStats(dateRange)
|
await loadStats(dateRange)
|
||||||
|
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters())
|
||||||
if (isAdminPage.value) {
|
|
||||||
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理分页变化
|
// 处理分页变化
|
||||||
async function handlePageChange(page: number) {
|
async function handlePageChange(page: number) {
|
||||||
currentPage.value = page
|
currentPage.value = page
|
||||||
|
await loadRecords({ page, pageSize: pageSize.value }, getCurrentFilters())
|
||||||
if (isAdminPage.value) {
|
|
||||||
await loadRecords({ page, pageSize: pageSize.value }, getCurrentFilters())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理每页大小变化
|
// 处理每页大小变化
|
||||||
async function handlePageSizeChange(size: number) {
|
async function handlePageSizeChange(size: number) {
|
||||||
pageSize.value = size
|
pageSize.value = size
|
||||||
currentPage.value = 1 // 重置到第一页
|
currentPage.value = 1 // 重置到第一页
|
||||||
|
await loadRecords({ page: 1, pageSize: size }, getCurrentFilters())
|
||||||
if (isAdminPage.value) {
|
|
||||||
await loadRecords({ page: 1, pageSize: size }, getCurrentFilters())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取当前筛选参数
|
// 获取当前筛选参数
|
||||||
function getCurrentFilters() {
|
function getCurrentFilters() {
|
||||||
return {
|
return {
|
||||||
|
search: filterSearch.value.trim() || undefined,
|
||||||
user_id: filterUser.value !== '__all__' ? filterUser.value : 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,
|
model: filterModel.value !== '__all__' ? filterModel.value : undefined,
|
||||||
provider: filterProvider.value !== '__all__' ? filterProvider.value : undefined,
|
provider: filterProvider.value !== '__all__' ? filterProvider.value : undefined,
|
||||||
status: filterStatus.value !== '__all__' ? filterStatus.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) {
|
async function handleFilterUserChange(value: string) {
|
||||||
filterUser.value = value
|
filterUser.value = value
|
||||||
currentPage.value = 1 // 重置到第一页
|
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) {
|
async function handleFilterModelChange(value: string) {
|
||||||
filterModel.value = value
|
filterModel.value = value
|
||||||
currentPage.value = 1 // 重置到第一页
|
currentPage.value = 1 // 重置到第一页
|
||||||
@@ -499,10 +491,7 @@ async function handleFilterStatusChange(value: string) {
|
|||||||
async function refreshData() {
|
async function refreshData() {
|
||||||
const dateRange = getDateRangeFromPeriod(selectedPeriod.value)
|
const dateRange = getDateRangeFromPeriod(selectedPeriod.value)
|
||||||
await loadStats(dateRange)
|
await loadStats(dateRange)
|
||||||
|
await loadRecords({ page: currentPage.value, pageSize: pageSize.value }, getCurrentFilters())
|
||||||
if (isAdminPage.value) {
|
|
||||||
await loadRecords({ page: currentPage.value, pageSize: pageSize.value }, getCurrentFilters())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 显示请求详情
|
// 显示请求详情
|
||||||
|
|||||||
@@ -92,9 +92,9 @@ async def get_usage_records(
|
|||||||
request: Request,
|
request: Request,
|
||||||
start_date: Optional[datetime] = None,
|
start_date: Optional[datetime] = None,
|
||||||
end_date: Optional[datetime] = None,
|
end_date: Optional[datetime] = None,
|
||||||
|
search: Optional[str] = None, # 通用搜索:用户名、密钥名、模型名、提供商名
|
||||||
user_id: Optional[str] = None,
|
user_id: Optional[str] = None,
|
||||||
username: Optional[str] = None,
|
username: Optional[str] = None,
|
||||||
user_api_key_name: Optional[str] = None,
|
|
||||||
model: Optional[str] = None,
|
model: Optional[str] = None,
|
||||||
provider: Optional[str] = None,
|
provider: Optional[str] = None,
|
||||||
status: Optional[str] = None, # stream, standard, error
|
status: Optional[str] = None, # stream, standard, error
|
||||||
@@ -105,9 +105,9 @@ async def get_usage_records(
|
|||||||
adapter = AdminUsageRecordsAdapter(
|
adapter = AdminUsageRecordsAdapter(
|
||||||
start_date=start_date,
|
start_date=start_date,
|
||||||
end_date=end_date,
|
end_date=end_date,
|
||||||
|
search=search,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
username=username,
|
username=username,
|
||||||
user_api_key_name=user_api_key_name,
|
|
||||||
model=model,
|
model=model,
|
||||||
provider=provider,
|
provider=provider,
|
||||||
status=status,
|
status=status,
|
||||||
@@ -502,9 +502,9 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
|
|||||||
self,
|
self,
|
||||||
start_date: Optional[datetime],
|
start_date: Optional[datetime],
|
||||||
end_date: Optional[datetime],
|
end_date: Optional[datetime],
|
||||||
|
search: Optional[str],
|
||||||
user_id: Optional[str],
|
user_id: Optional[str],
|
||||||
username: Optional[str],
|
username: Optional[str],
|
||||||
user_api_key_name: Optional[str],
|
|
||||||
model: Optional[str],
|
model: Optional[str],
|
||||||
provider: Optional[str],
|
provider: Optional[str],
|
||||||
status: Optional[str],
|
status: Optional[str],
|
||||||
@@ -513,9 +513,9 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
|
|||||||
):
|
):
|
||||||
self.start_date = start_date
|
self.start_date = start_date
|
||||||
self.end_date = end_date
|
self.end_date = end_date
|
||||||
|
self.search = search
|
||||||
self.user_id = user_id
|
self.user_id = user_id
|
||||||
self.username = username
|
self.username = username
|
||||||
self.user_api_key_name = user_api_key_name
|
|
||||||
self.model = model
|
self.model = model
|
||||||
self.provider = provider
|
self.provider = provider
|
||||||
self.status = status
|
self.status = status
|
||||||
@@ -523,6 +523,10 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
|
|||||||
self.offset = offset
|
self.offset = offset
|
||||||
|
|
||||||
async def handle(self, context): # type: ignore[override]
|
async def handle(self, context): # type: ignore[override]
|
||||||
|
from sqlalchemy import or_
|
||||||
|
|
||||||
|
from src.utils.database_helpers import escape_like_pattern, safe_truncate_escaped
|
||||||
|
|
||||||
db = context.db
|
db = context.db
|
||||||
query = (
|
query = (
|
||||||
db.query(Usage, User, ProviderEndpoint, ProviderAPIKey, ApiKey)
|
db.query(Usage, User, ProviderEndpoint, ProviderAPIKey, ApiKey)
|
||||||
@@ -531,21 +535,42 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
|
|||||||
.outerjoin(ProviderAPIKey, Usage.provider_api_key_id == ProviderAPIKey.id)
|
.outerjoin(ProviderAPIKey, Usage.provider_api_key_id == ProviderAPIKey.id)
|
||||||
.outerjoin(ApiKey, Usage.api_key_id == ApiKey.id)
|
.outerjoin(ApiKey, Usage.api_key_id == ApiKey.id)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 如果需要按 Provider 名称搜索/筛选,统一在这里 JOIN
|
||||||
|
if self.search or self.provider:
|
||||||
|
query = query.join(Provider, Usage.provider_id == Provider.id, isouter=True)
|
||||||
|
|
||||||
|
# 通用搜索:用户名、密钥名、模型名、提供商名
|
||||||
|
# 支持空格分隔的组合搜索,多个关键词之间是 AND 关系
|
||||||
|
# 限制:最多 10 个关键词,转义后每个关键词最长 100 字符
|
||||||
|
if self.search:
|
||||||
|
keywords = [kw for kw in self.search.strip().split() if kw][:10]
|
||||||
|
for keyword in keywords:
|
||||||
|
escaped = safe_truncate_escaped(escape_like_pattern(keyword), 100)
|
||||||
|
search_pattern = f"%{escaped}%"
|
||||||
|
query = query.filter(
|
||||||
|
or_(
|
||||||
|
User.username.ilike(search_pattern, escape="\\"),
|
||||||
|
ApiKey.name.ilike(search_pattern, escape="\\"),
|
||||||
|
Usage.model.ilike(search_pattern, escape="\\"),
|
||||||
|
Provider.name.ilike(search_pattern, escape="\\"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
if self.user_id:
|
if self.user_id:
|
||||||
query = query.filter(Usage.user_id == self.user_id)
|
query = query.filter(Usage.user_id == self.user_id)
|
||||||
if self.username:
|
if self.username:
|
||||||
# 支持用户名模糊搜索
|
# 支持用户名模糊搜索
|
||||||
query = query.filter(User.username.ilike(f"%{self.username}%"))
|
escaped = escape_like_pattern(self.username)
|
||||||
if self.user_api_key_name:
|
query = query.filter(User.username.ilike(f"%{escaped}%", escape="\\"))
|
||||||
# 支持用户 API Key 名称模糊搜索(注意:不是 Provider Key)
|
|
||||||
query = query.filter(ApiKey.name.ilike(f"%{self.user_api_key_name}%"))
|
|
||||||
if self.model:
|
if self.model:
|
||||||
# 支持模型名模糊搜索
|
# 支持模型名模糊搜索
|
||||||
query = query.filter(Usage.model.ilike(f"%{self.model}%"))
|
escaped = escape_like_pattern(self.model)
|
||||||
|
query = query.filter(Usage.model.ilike(f"%{escaped}%", escape="\\"))
|
||||||
if self.provider:
|
if self.provider:
|
||||||
# 支持提供商名称搜索(通过 Provider 表)
|
# 支持提供商名称搜索
|
||||||
query = query.join(Provider, Usage.provider_id == Provider.id, isouter=True)
|
escaped = escape_like_pattern(self.provider)
|
||||||
query = query.filter(Provider.name.ilike(f"%{self.provider}%"))
|
query = query.filter(Provider.name.ilike(f"%{escaped}%", escape="\\"))
|
||||||
if self.status:
|
if self.status:
|
||||||
# 状态筛选
|
# 状态筛选
|
||||||
# 旧的筛选值(基于 is_stream 和 status_code):stream, standard, error
|
# 旧的筛选值(基于 is_stream 和 status_code):stream, standard, error
|
||||||
@@ -603,9 +628,9 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
|
|||||||
action="usage_records",
|
action="usage_records",
|
||||||
start_date=self.start_date.isoformat() if self.start_date else None,
|
start_date=self.start_date.isoformat() if self.start_date else None,
|
||||||
end_date=self.end_date.isoformat() if self.end_date else None,
|
end_date=self.end_date.isoformat() if self.end_date else None,
|
||||||
|
search=self.search,
|
||||||
user_id=self.user_id,
|
user_id=self.user_id,
|
||||||
username=self.username,
|
username=self.username,
|
||||||
user_api_key_name=self.user_api_key_name,
|
|
||||||
model=self.model,
|
model=self.model,
|
||||||
provider=self.provider,
|
provider=self.provider,
|
||||||
status=self.status,
|
status=self.status,
|
||||||
|
|||||||
@@ -104,11 +104,14 @@ async def get_my_usage(
|
|||||||
request: Request,
|
request: Request,
|
||||||
start_date: Optional[datetime] = None,
|
start_date: Optional[datetime] = None,
|
||||||
end_date: Optional[datetime] = None,
|
end_date: Optional[datetime] = None,
|
||||||
|
search: Optional[str] = None, # 通用搜索:密钥名、模型名
|
||||||
limit: int = Query(100, ge=1, le=200, description="每页记录数,默认100,最大200"),
|
limit: int = Query(100, ge=1, le=200, description="每页记录数,默认100,最大200"),
|
||||||
offset: int = Query(0, ge=0, le=2000, description="偏移量,用于分页,最大2000"),
|
offset: int = Query(0, ge=0, le=2000, description="偏移量,用于分页,最大2000"),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
adapter = GetUsageAdapter(start_date=start_date, end_date=end_date, limit=limit, offset=offset)
|
adapter = GetUsageAdapter(
|
||||||
|
start_date=start_date, end_date=end_date, search=search, limit=limit, offset=offset
|
||||||
|
)
|
||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
@@ -487,10 +490,15 @@ class ToggleMyApiKeyAdapter(AuthenticatedApiAdapter):
|
|||||||
class GetUsageAdapter(AuthenticatedApiAdapter):
|
class GetUsageAdapter(AuthenticatedApiAdapter):
|
||||||
start_date: Optional[datetime]
|
start_date: Optional[datetime]
|
||||||
end_date: Optional[datetime]
|
end_date: Optional[datetime]
|
||||||
|
search: Optional[str] = None
|
||||||
limit: int = 100
|
limit: int = 100
|
||||||
offset: int = 0
|
offset: int = 0
|
||||||
|
|
||||||
async def handle(self, context): # type: ignore[override]
|
async def handle(self, context): # type: ignore[override]
|
||||||
|
from sqlalchemy import or_
|
||||||
|
|
||||||
|
from src.utils.database_helpers import escape_like_pattern, safe_truncate_escaped
|
||||||
|
|
||||||
db = context.db
|
db = context.db
|
||||||
user = context.user
|
user = context.user
|
||||||
summary_list = UsageService.get_usage_summary(
|
summary_list = UsageService.get_usage_summary(
|
||||||
@@ -595,12 +603,30 @@ class GetUsageAdapter(AuthenticatedApiAdapter):
|
|||||||
})
|
})
|
||||||
summary_by_provider = sorted(summary_by_provider, key=lambda x: x["requests"], reverse=True)
|
summary_by_provider = sorted(summary_by_provider, key=lambda x: x["requests"], reverse=True)
|
||||||
|
|
||||||
query = db.query(Usage).filter(Usage.user_id == user.id)
|
query = (
|
||||||
|
db.query(Usage, ApiKey)
|
||||||
|
.outerjoin(ApiKey, Usage.api_key_id == ApiKey.id)
|
||||||
|
.filter(Usage.user_id == user.id)
|
||||||
|
)
|
||||||
if self.start_date:
|
if self.start_date:
|
||||||
query = query.filter(Usage.created_at >= self.start_date)
|
query = query.filter(Usage.created_at >= self.start_date)
|
||||||
if self.end_date:
|
if self.end_date:
|
||||||
query = query.filter(Usage.created_at <= self.end_date)
|
query = query.filter(Usage.created_at <= self.end_date)
|
||||||
|
|
||||||
|
# 通用搜索:密钥名、模型名
|
||||||
|
# 支持空格分隔的组合搜索,多个关键词之间是 AND 关系
|
||||||
|
if self.search and self.search.strip():
|
||||||
|
keywords = [kw for kw in self.search.strip().split() if kw][:10]
|
||||||
|
for keyword in keywords:
|
||||||
|
escaped = safe_truncate_escaped(escape_like_pattern(keyword), 100)
|
||||||
|
search_pattern = f"%{escaped}%"
|
||||||
|
query = query.filter(
|
||||||
|
or_(
|
||||||
|
ApiKey.name.ilike(search_pattern, escape="\\"),
|
||||||
|
Usage.model.ilike(search_pattern, escape="\\"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# 计算总数用于分页
|
# 计算总数用于分页
|
||||||
total_records = query.count()
|
total_records = query.count()
|
||||||
usage_records = query.order_by(Usage.created_at.desc()).offset(self.offset).limit(self.limit).all()
|
usage_records = query.order_by(Usage.created_at.desc()).offset(self.offset).limit(self.limit).all()
|
||||||
@@ -659,8 +685,17 @@ class GetUsageAdapter(AuthenticatedApiAdapter):
|
|||||||
"output_price_per_1m": r.output_price_per_1m,
|
"output_price_per_1m": r.output_price_per_1m,
|
||||||
"cache_creation_price_per_1m": r.cache_creation_price_per_1m,
|
"cache_creation_price_per_1m": r.cache_creation_price_per_1m,
|
||||||
"cache_read_price_per_1m": r.cache_read_price_per_1m,
|
"cache_read_price_per_1m": r.cache_read_price_per_1m,
|
||||||
|
"api_key": (
|
||||||
|
{
|
||||||
|
"id": str(api_key.id),
|
||||||
|
"name": api_key.name,
|
||||||
|
"display": api_key.get_display_key(),
|
||||||
|
}
|
||||||
|
if api_key
|
||||||
|
else None
|
||||||
|
),
|
||||||
}
|
}
|
||||||
for r in usage_records
|
for r, api_key in usage_records
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -668,7 +703,7 @@ class GetUsageAdapter(AuthenticatedApiAdapter):
|
|||||||
if user.role == "admin":
|
if user.role == "admin":
|
||||||
response_data["total_actual_cost"] = total_actual_cost
|
response_data["total_actual_cost"] = total_actual_cost
|
||||||
# 为每条记录添加真实成本和倍率信息
|
# 为每条记录添加真实成本和倍率信息
|
||||||
for i, r in enumerate(usage_records):
|
for i, (r, _) in enumerate(usage_records):
|
||||||
# 确保字段有值,避免前端显示 -
|
# 确保字段有值,避免前端显示 -
|
||||||
actual_cost = (
|
actual_cost = (
|
||||||
r.actual_total_cost_usd if r.actual_total_cost_usd is not None else 0.0
|
r.actual_total_cost_usd if r.actual_total_cost_usd is not None else 0.0
|
||||||
|
|||||||
@@ -7,6 +7,59 @@ from typing import Any
|
|||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
|
|
||||||
|
|
||||||
|
def escape_like_pattern(pattern: str) -> str:
|
||||||
|
"""
|
||||||
|
转义 SQL LIKE 语句中的特殊字符(%、_、\\)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pattern: 原始搜索模式
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
转义后的模式,可安全用于 LIKE 查询(需配合 escape="\\\\")
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
>>> escape_like_pattern("hello_world%test")
|
||||||
|
'hello\\\\_world\\\\%test'
|
||||||
|
"""
|
||||||
|
return pattern.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||||
|
|
||||||
|
|
||||||
|
def safe_truncate_escaped(escaped: str, max_len: int) -> str:
|
||||||
|
"""
|
||||||
|
安全截断已转义的字符串,避免截断在转义序列中间
|
||||||
|
|
||||||
|
转义后的字符串中,反斜杠总是成对出现(\\\\)或作为转义符(\\%, \\_)。
|
||||||
|
如果在某个位置截断导致末尾有奇数个反斜杠,说明截断发生在转义序列中间,
|
||||||
|
需要去掉最后一个反斜杠以保持转义完整性。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
escaped: 已经过 escape_like_pattern 处理的字符串
|
||||||
|
max_len: 最大长度
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
截断后的字符串,保证不会破坏转义序列
|
||||||
|
"""
|
||||||
|
if len(escaped) <= max_len:
|
||||||
|
return escaped
|
||||||
|
|
||||||
|
truncated = escaped[:max_len]
|
||||||
|
|
||||||
|
# 统计末尾连续的反斜杠数量
|
||||||
|
trailing_backslashes = 0
|
||||||
|
for i in range(len(truncated) - 1, -1, -1):
|
||||||
|
if truncated[i] == "\\":
|
||||||
|
trailing_backslashes += 1
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
# 如果末尾反斜杠数量为奇数,说明截断在转义序列中间
|
||||||
|
# 需要去掉最后一个反斜杠
|
||||||
|
if trailing_backslashes % 2 == 1:
|
||||||
|
truncated = truncated[:-1]
|
||||||
|
|
||||||
|
return truncated
|
||||||
|
|
||||||
|
|
||||||
def date_trunc_portable(dialect_name: str, interval: str, column: Any) -> Any:
|
def date_trunc_portable(dialect_name: str, interval: str, column: Any) -> Any:
|
||||||
"""
|
"""
|
||||||
跨数据库的日期截断函数
|
跨数据库的日期截断函数
|
||||||
|
|||||||
Reference in New Issue
Block a user