Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
0f8d42c5
Unverified
Commit
0f8d42c5
authored
Jan 19, 2026
by
Wesley Liddick
Committed by
GitHub
Jan 19, 2026
Browse files
Merge pull request #327 from mt21625457/main
feat(usage): 添加清理任务与统计过滤
parents
03c75787
2a94cc76
Changes
67
Hide whitespace changes
Inline
Side-by-side
frontend/src/components/admin/usage/UsageCleanupDialog.vue
0 → 100644
View file @
0f8d42c5
<
template
>
<BaseDialog
:show=
"show"
:title=
"t('admin.usage.cleanup.title')"
width=
"wide"
@
close=
"handleClose"
>
<div
class=
"space-y-4"
>
<UsageFilters
v-model=
"localFilters"
v-model:startDate=
"localStartDate"
v-model:endDate=
"localEndDate"
:exporting=
"false"
:show-actions=
"false"
@
change=
"noop"
/>
<div
class=
"rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200"
>
{{
t
(
'
admin.usage.cleanup.warning
'
)
}}
</div>
<div
class=
"rounded-xl border border-gray-200 p-4 dark:border-dark-700"
>
<div
class=
"flex items-center justify-between"
>
<h4
class=
"text-sm font-semibold text-gray-700 dark:text-gray-200"
>
{{
t
(
'
admin.usage.cleanup.recentTasks
'
)
}}
</h4>
<button
type=
"button"
class=
"btn btn-ghost btn-sm"
@
click=
"loadTasks"
>
{{
t
(
'
common.refresh
'
)
}}
</button>
</div>
<div
class=
"mt-3 space-y-2"
>
<div
v-if=
"tasksLoading"
class=
"text-sm text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.usage.cleanup.loadingTasks
'
)
}}
</div>
<div
v-else-if=
"tasks.length === 0"
class=
"text-sm text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.usage.cleanup.noTasks
'
)
}}
</div>
<div
v-else
class=
"space-y-2"
>
<div
v-for=
"task in tasks"
:key=
"task.id"
class=
"flex flex-col gap-2 rounded-lg border border-gray-100 px-3 py-2 text-sm text-gray-600 dark:border-dark-700 dark:text-gray-300"
>
<div
class=
"flex flex-wrap items-center justify-between gap-2"
>
<div
class=
"flex items-center gap-2"
>
<span
:class=
"statusClass(task.status)"
class=
"rounded-full px-2 py-0.5 text-xs font-semibold"
>
{{
statusLabel
(
task
.
status
)
}}
</span>
<span
class=
"text-xs text-gray-400"
>
#
{{
task
.
id
}}
</span>
<button
v-if=
"canCancel(task)"
type=
"button"
class=
"btn btn-ghost btn-xs text-rose-600 hover:text-rose-700 dark:text-rose-300"
@
click=
"openCancelConfirm(task)"
>
{{
t
(
'
admin.usage.cleanup.cancel
'
)
}}
</button>
</div>
<div
class=
"text-xs text-gray-400"
>
{{
formatDateTime
(
task
.
created_at
)
}}
</div>
</div>
<div
class=
"flex flex-wrap items-center gap-4 text-xs text-gray-500 dark:text-gray-400"
>
<span>
{{
t
(
'
admin.usage.cleanup.range
'
)
}}
:
{{
formatRange
(
task
)
}}
</span>
<span>
{{
t
(
'
admin.usage.cleanup.deletedRows
'
)
}}
:
{{
task
.
deleted_rows
.
toLocaleString
()
}}
</span>
</div>
<div
v-if=
"task.error_message"
class=
"text-xs text-rose-500"
>
{{
task
.
error_message
}}
</div>
</div>
</div>
</div>
<Pagination
v-if=
"tasksTotal > tasksPageSize"
class=
"mt-4"
:total=
"tasksTotal"
:page=
"tasksPage"
:page-size=
"tasksPageSize"
:page-size-options=
"[5]"
:show-page-size-selector=
"false"
:show-jump=
"true"
@
update:page=
"handleTaskPageChange"
@
update:pageSize=
"handleTaskPageSizeChange"
/>
</div>
</div>
<template
#footer
>
<div
class=
"flex justify-end gap-3"
>
<button
type=
"button"
class=
"btn btn-secondary"
@
click=
"handleClose"
>
{{
t
(
'
common.cancel
'
)
}}
</button>
<button
type=
"button"
class=
"btn btn-danger"
:disabled=
"submitting"
@
click=
"openConfirm"
>
{{
submitting
?
t
(
'
admin.usage.cleanup.submitting
'
)
:
t
(
'
admin.usage.cleanup.submit
'
)
}}
</button>
</div>
</
template
>
</BaseDialog>
<ConfirmDialog
:show=
"confirmVisible"
:title=
"t('admin.usage.cleanup.confirmTitle')"
:message=
"t('admin.usage.cleanup.confirmMessage')"
:confirm-text=
"t('admin.usage.cleanup.confirmSubmit')"
danger
@
confirm=
"submitCleanup"
@
cancel=
"confirmVisible = false"
/>
<ConfirmDialog
:show=
"cancelConfirmVisible"
:title=
"t('admin.usage.cleanup.cancelConfirmTitle')"
:message=
"t('admin.usage.cleanup.cancelConfirmMessage')"
:confirm-text=
"t('admin.usage.cleanup.cancelConfirm')"
danger
@
confirm=
"cancelTask"
@
cancel=
"cancelConfirmVisible = false"
/>
</template>
<
script
setup
lang=
"ts"
>
import
{
ref
,
watch
,
onUnmounted
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
import
ConfirmDialog
from
'
@/components/common/ConfirmDialog.vue
'
import
Pagination
from
'
@/components/common/Pagination.vue
'
import
UsageFilters
from
'
@/components/admin/usage/UsageFilters.vue
'
import
{
adminUsageAPI
}
from
'
@/api/admin/usage
'
import
type
{
AdminUsageQueryParams
,
UsageCleanupTask
,
CreateUsageCleanupTaskRequest
}
from
'
@/api/admin/usage
'
interface
Props
{
show
:
boolean
filters
:
AdminUsageQueryParams
startDate
:
string
endDate
:
string
}
const
props
=
defineProps
<
Props
>
()
const
emit
=
defineEmits
([
'
close
'
])
const
{
t
}
=
useI18n
()
const
appStore
=
useAppStore
()
const
localFilters
=
ref
<
AdminUsageQueryParams
>
({})
const
localStartDate
=
ref
(
''
)
const
localEndDate
=
ref
(
''
)
const
tasks
=
ref
<
UsageCleanupTask
[]
>
([])
const
tasksLoading
=
ref
(
false
)
const
tasksPage
=
ref
(
1
)
const
tasksPageSize
=
ref
(
5
)
const
tasksTotal
=
ref
(
0
)
const
submitting
=
ref
(
false
)
const
confirmVisible
=
ref
(
false
)
const
cancelConfirmVisible
=
ref
(
false
)
const
canceling
=
ref
(
false
)
const
cancelTarget
=
ref
<
UsageCleanupTask
|
null
>
(
null
)
let
pollTimer
:
number
|
null
=
null
const
noop
=
()
=>
{}
const
resetFilters
=
()
=>
{
localFilters
.
value
=
{
...
props
.
filters
}
localStartDate
.
value
=
props
.
startDate
localEndDate
.
value
=
props
.
endDate
localFilters
.
value
.
start_date
=
localStartDate
.
value
localFilters
.
value
.
end_date
=
localEndDate
.
value
tasksPage
.
value
=
1
tasksTotal
.
value
=
0
}
const
startPolling
=
()
=>
{
stopPolling
()
pollTimer
=
window
.
setInterval
(()
=>
{
loadTasks
()
},
10000
)
}
const
stopPolling
=
()
=>
{
if
(
pollTimer
!==
null
)
{
window
.
clearInterval
(
pollTimer
)
pollTimer
=
null
}
}
const
handleClose
=
()
=>
{
stopPolling
()
confirmVisible
.
value
=
false
cancelConfirmVisible
.
value
=
false
canceling
.
value
=
false
cancelTarget
.
value
=
null
submitting
.
value
=
false
emit
(
'
close
'
)
}
const
statusLabel
=
(
status
:
string
)
=>
{
const
map
:
Record
<
string
,
string
>
=
{
pending
:
t
(
'
admin.usage.cleanup.status.pending
'
),
running
:
t
(
'
admin.usage.cleanup.status.running
'
),
succeeded
:
t
(
'
admin.usage.cleanup.status.succeeded
'
),
failed
:
t
(
'
admin.usage.cleanup.status.failed
'
),
canceled
:
t
(
'
admin.usage.cleanup.status.canceled
'
)
}
return
map
[
status
]
||
status
}
const
statusClass
=
(
status
:
string
)
=>
{
const
map
:
Record
<
string
,
string
>
=
{
pending
:
'
bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-200
'
,
running
:
'
bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-200
'
,
succeeded
:
'
bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-200
'
,
failed
:
'
bg-rose-100 text-rose-700 dark:bg-rose-500/20 dark:text-rose-200
'
,
canceled
:
'
bg-gray-200 text-gray-600 dark:bg-dark-600 dark:text-gray-300
'
}
return
map
[
status
]
||
'
bg-gray-100 text-gray-600
'
}
const
formatDateTime
=
(
value
?:
string
|
null
)
=>
{
if
(
!
value
)
return
'
--
'
const
date
=
new
Date
(
value
)
if
(
Number
.
isNaN
(
date
.
getTime
()))
return
value
return
date
.
toLocaleString
()
}
const
formatRange
=
(
task
:
UsageCleanupTask
)
=>
{
const
start
=
formatDateTime
(
task
.
filters
.
start_time
)
const
end
=
formatDateTime
(
task
.
filters
.
end_time
)
return
`
${
start
}
~
${
end
}
`
}
const
getUserTimezone
=
()
=>
{
try
{
return
Intl
.
DateTimeFormat
().
resolvedOptions
().
timeZone
}
catch
{
return
'
UTC
'
}
}
const
loadTasks
=
async
()
=>
{
if
(
!
props
.
show
)
return
tasksLoading
.
value
=
true
try
{
const
res
=
await
adminUsageAPI
.
listCleanupTasks
({
page
:
tasksPage
.
value
,
page_size
:
tasksPageSize
.
value
})
tasks
.
value
=
res
.
items
||
[]
tasksTotal
.
value
=
res
.
total
||
0
if
(
res
.
page
)
{
tasksPage
.
value
=
res
.
page
}
if
(
res
.
page_size
)
{
tasksPageSize
.
value
=
res
.
page_size
}
}
catch
(
error
)
{
console
.
error
(
'
Failed to load cleanup tasks:
'
,
error
)
appStore
.
showError
(
t
(
'
admin.usage.cleanup.loadFailed
'
))
}
finally
{
tasksLoading
.
value
=
false
}
}
const
handleTaskPageChange
=
(
page
:
number
)
=>
{
tasksPage
.
value
=
page
loadTasks
()
}
const
handleTaskPageSizeChange
=
(
size
:
number
)
=>
{
if
(
!
Number
.
isFinite
(
size
)
||
size
<=
0
)
return
tasksPageSize
.
value
=
size
tasksPage
.
value
=
1
loadTasks
()
}
const
openConfirm
=
()
=>
{
confirmVisible
.
value
=
true
}
const
canCancel
=
(
task
:
UsageCleanupTask
)
=>
{
return
task
.
status
===
'
pending
'
||
task
.
status
===
'
running
'
}
const
openCancelConfirm
=
(
task
:
UsageCleanupTask
)
=>
{
cancelTarget
.
value
=
task
cancelConfirmVisible
.
value
=
true
}
const
buildPayload
=
():
CreateUsageCleanupTaskRequest
|
null
=>
{
if
(
!
localStartDate
.
value
||
!
localEndDate
.
value
)
{
appStore
.
showError
(
t
(
'
admin.usage.cleanup.missingRange
'
))
return
null
}
const
payload
:
CreateUsageCleanupTaskRequest
=
{
start_date
:
localStartDate
.
value
,
end_date
:
localEndDate
.
value
,
timezone
:
getUserTimezone
()
}
if
(
localFilters
.
value
.
user_id
&&
localFilters
.
value
.
user_id
>
0
)
{
payload
.
user_id
=
localFilters
.
value
.
user_id
}
if
(
localFilters
.
value
.
api_key_id
&&
localFilters
.
value
.
api_key_id
>
0
)
{
payload
.
api_key_id
=
localFilters
.
value
.
api_key_id
}
if
(
localFilters
.
value
.
account_id
&&
localFilters
.
value
.
account_id
>
0
)
{
payload
.
account_id
=
localFilters
.
value
.
account_id
}
if
(
localFilters
.
value
.
group_id
&&
localFilters
.
value
.
group_id
>
0
)
{
payload
.
group_id
=
localFilters
.
value
.
group_id
}
if
(
localFilters
.
value
.
model
)
{
payload
.
model
=
localFilters
.
value
.
model
}
if
(
localFilters
.
value
.
stream
!==
null
&&
localFilters
.
value
.
stream
!==
undefined
)
{
payload
.
stream
=
localFilters
.
value
.
stream
}
if
(
localFilters
.
value
.
billing_type
!==
null
&&
localFilters
.
value
.
billing_type
!==
undefined
)
{
payload
.
billing_type
=
localFilters
.
value
.
billing_type
}
return
payload
}
const
submitCleanup
=
async
()
=>
{
const
payload
=
buildPayload
()
if
(
!
payload
)
{
confirmVisible
.
value
=
false
return
}
submitting
.
value
=
true
confirmVisible
.
value
=
false
try
{
await
adminUsageAPI
.
createCleanupTask
(
payload
)
appStore
.
showSuccess
(
t
(
'
admin.usage.cleanup.submitSuccess
'
))
loadTasks
()
}
catch
(
error
)
{
console
.
error
(
'
Failed to create cleanup task:
'
,
error
)
appStore
.
showError
(
t
(
'
admin.usage.cleanup.submitFailed
'
))
}
finally
{
submitting
.
value
=
false
}
}
const
cancelTask
=
async
()
=>
{
const
task
=
cancelTarget
.
value
if
(
!
task
)
{
cancelConfirmVisible
.
value
=
false
return
}
canceling
.
value
=
true
cancelConfirmVisible
.
value
=
false
try
{
await
adminUsageAPI
.
cancelCleanupTask
(
task
.
id
)
appStore
.
showSuccess
(
t
(
'
admin.usage.cleanup.cancelSuccess
'
))
loadTasks
()
}
catch
(
error
)
{
console
.
error
(
'
Failed to cancel cleanup task:
'
,
error
)
appStore
.
showError
(
t
(
'
admin.usage.cleanup.cancelFailed
'
))
}
finally
{
canceling
.
value
=
false
cancelTarget
.
value
=
null
}
}
watch
(
()
=>
props
.
show
,
(
show
)
=>
{
if
(
show
)
{
resetFilters
()
loadTasks
()
startPolling
()
}
else
{
stopPolling
()
}
}
)
onUnmounted
(()
=>
{
stopPolling
()
})
</
script
>
frontend/src/components/admin/usage/UsageFilters.vue
View file @
0f8d42c5
...
...
@@ -127,6 +127,12 @@
<
Select
v
-
model
=
"
filters.stream
"
:
options
=
"
streamTypeOptions
"
@
change
=
"
emitChange
"
/>
<
/div
>
<!--
Billing
Type
Filter
-->
<
div
class
=
"
w-full sm:w-auto sm:min-w-[200px]
"
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.usage.billingType
'
)
}}
<
/label
>
<
Select
v
-
model
=
"
filters.billing_type
"
:
options
=
"
billingTypeOptions
"
@
change
=
"
emitChange
"
/>
<
/div
>
<!--
Group
Filter
-->
<
div
class
=
"
w-full sm:w-auto sm:min-w-[200px]
"
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.usage.group
'
)
}}
<
/label
>
...
...
@@ -147,10 +153,13 @@
<
/div
>
<!--
Right
:
actions
-->
<
div
class
=
"
flex w-full flex-wrap items-center justify-end gap-3 sm:w-auto
"
>
<
div
v
-
if
=
"
showActions
"
class
=
"
flex w-full flex-wrap items-center justify-end gap-3 sm:w-auto
"
>
<
button
type
=
"
button
"
@
click
=
"
$emit('reset')
"
class
=
"
btn btn-secondary
"
>
{{
t
(
'
common.reset
'
)
}}
<
/button
>
<
button
type
=
"
button
"
@
click
=
"
$emit('cleanup')
"
class
=
"
btn btn-danger
"
>
{{
t
(
'
admin.usage.cleanup.button
'
)
}}
<
/button
>
<
button
type
=
"
button
"
@
click
=
"
$emit('export')
"
:
disabled
=
"
exporting
"
class
=
"
btn btn-primary
"
>
{{
t
(
'
usage.exportExcel
'
)
}}
<
/button
>
...
...
@@ -174,16 +183,20 @@ interface Props {
exporting
:
boolean
startDate
:
string
endDate
:
string
showActions
?:
boolean
}
const
props
=
defineProps
<
Props
>
()
const
props
=
withDefaults
(
defineProps
<
Props
>
(),
{
showActions
:
true
}
)
const
emit
=
defineEmits
([
'
update:modelValue
'
,
'
update:startDate
'
,
'
update:endDate
'
,
'
change
'
,
'
reset
'
,
'
export
'
'
export
'
,
'
cleanup
'
])
const
{
t
}
=
useI18n
()
...
...
@@ -221,6 +234,12 @@ const streamTypeOptions = ref<SelectOption[]>([
{
value
:
false
,
label
:
t
(
'
usage.sync
'
)
}
])
const
billingTypeOptions
=
ref
<
SelectOption
[]
>
([
{
value
:
null
,
label
:
t
(
'
admin.usage.allBillingTypes
'
)
}
,
{
value
:
0
,
label
:
t
(
'
admin.usage.billingTypeBalance
'
)
}
,
{
value
:
1
,
label
:
t
(
'
admin.usage.billingTypeSubscription
'
)
}
])
const
emitChange
=
()
=>
emit
(
'
change
'
)
const
updateStartDate
=
(
value
:
string
)
=>
{
...
...
frontend/src/components/common/Pagination.vue
View file @
0f8d42c5
...
...
@@ -37,7 +37,7 @@
<
/p
>
<!--
Page
size
selector
-->
<
div
class
=
"
flex items-center space-x-2
"
>
<
div
v
-
if
=
"
showPageSizeSelector
"
class
=
"
flex items-center space-x-2
"
>
<
span
class
=
"
text-sm text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
pagination.perPage
'
)
}}
:
<
/spa
n
>
...
...
@@ -49,6 +49,22 @@
/>
<
/div
>
<
/div
>
<
div
v
-
if
=
"
showJump
"
class
=
"
flex items-center space-x-2
"
>
<
span
class
=
"
text-sm text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
pagination.jumpTo
'
)
}}
<
/span
>
<
input
v
-
model
=
"
jumpPage
"
type
=
"
number
"
min
=
"
1
"
:
max
=
"
totalPages
"
class
=
"
input w-20 text-sm
"
:
placeholder
=
"
t('pagination.jumpPlaceholder')
"
@
keyup
.
enter
=
"
submitJump
"
/>
<
button
type
=
"
button
"
class
=
"
btn btn-ghost btn-sm
"
@
click
=
"
submitJump
"
>
{{
t
(
'
pagination.jumpAction
'
)
}}
<
/button
>
<
/div
>
<
/div
>
<!--
Desktop
pagination
buttons
-->
...
...
@@ -102,7 +118,7 @@
<
/template
>
<
script
setup
lang
=
"
ts
"
>
import
{
computed
}
from
'
vue
'
import
{
computed
,
ref
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
Select
from
'
./Select.vue
'
...
...
@@ -114,6 +130,8 @@ interface Props {
page
:
number
pageSize
:
number
pageSizeOptions
?:
number
[]
showPageSizeSelector
?:
boolean
showJump
?:
boolean
}
interface
Emits
{
...
...
@@ -122,7 +140,9 @@ interface Emits {
}
const
props
=
withDefaults
(
defineProps
<
Props
>
(),
{
pageSizeOptions
:
()
=>
[
10
,
20
,
50
,
100
]
pageSizeOptions
:
()
=>
[
10
,
20
,
50
,
100
],
showPageSizeSelector
:
true
,
showJump
:
false
}
)
const
emit
=
defineEmits
<
Emits
>
()
...
...
@@ -146,6 +166,8 @@ const pageSizeSelectOptions = computed(() => {
}
))
}
)
const
jumpPage
=
ref
(
''
)
const
visiblePages
=
computed
(()
=>
{
const
pages
:
(
number
|
string
)[]
=
[]
const
maxVisible
=
7
...
...
@@ -196,6 +218,16 @@ const handlePageSizeChange = (value: string | number | boolean | null) => {
const
newPageSize
=
typeof
value
===
'
string
'
?
parseInt
(
value
)
:
value
emit
(
'
update:pageSize
'
,
newPageSize
)
}
const
submitJump
=
()
=>
{
const
value
=
jumpPage
.
value
.
trim
()
if
(
!
value
)
return
const
pageNum
=
Number
.
parseInt
(
value
,
10
)
if
(
Number
.
isNaN
(
pageNum
))
return
const
nextPage
=
Math
.
min
(
Math
.
max
(
pageNum
,
1
),
totalPages
.
value
)
jumpPage
.
value
=
''
goToPage
(
nextPage
)
}
<
/script
>
<
style
scoped
>
...
...
frontend/src/i18n/locales/en.ts
View file @
0f8d42c5
...
...
@@ -573,7 +573,10 @@ export default {
previous
:
'
Previous
'
,
next
:
'
Next
'
,
perPage
:
'
Per page
'
,
goToPage
:
'
Go to page {page}
'
goToPage
:
'
Go to page {page}
'
,
jumpTo
:
'
Jump to
'
,
jumpPlaceholder
:
'
Page
'
,
jumpAction
:
'
Go
'
},
// Errors
...
...
@@ -1938,7 +1941,43 @@ export default {
cacheCreationTokens
:
'
Cache Creation Tokens
'
,
cacheReadTokens
:
'
Cache Read Tokens
'
,
failedToLoad
:
'
Failed to load usage records
'
,
ipAddress
:
'
IP
'
billingType
:
'
Billing Type
'
,
allBillingTypes
:
'
All Billing Types
'
,
billingTypeBalance
:
'
Balance
'
,
billingTypeSubscription
:
'
Subscription
'
,
ipAddress
:
'
IP
'
,
cleanup
:
{
button
:
'
Cleanup
'
,
title
:
'
Cleanup Usage Records
'
,
warning
:
'
Cleanup is irreversible and will affect historical stats.
'
,
submit
:
'
Submit Cleanup
'
,
submitting
:
'
Submitting...
'
,
confirmTitle
:
'
Confirm Cleanup
'
,
confirmMessage
:
'
Are you sure you want to submit this cleanup task? This action cannot be undone.
'
,
confirmSubmit
:
'
Confirm Cleanup
'
,
cancel
:
'
Cancel
'
,
cancelConfirmTitle
:
'
Confirm Cancel
'
,
cancelConfirmMessage
:
'
Are you sure you want to cancel this cleanup task?
'
,
cancelConfirm
:
'
Confirm Cancel
'
,
cancelSuccess
:
'
Cleanup task canceled
'
,
cancelFailed
:
'
Failed to cancel cleanup task
'
,
recentTasks
:
'
Recent Cleanup Tasks
'
,
loadingTasks
:
'
Loading tasks...
'
,
noTasks
:
'
No cleanup tasks yet
'
,
range
:
'
Range
'
,
deletedRows
:
'
Deleted
'
,
missingRange
:
'
Please select a date range
'
,
submitSuccess
:
'
Cleanup task created
'
,
submitFailed
:
'
Failed to create cleanup task
'
,
loadFailed
:
'
Failed to load cleanup tasks
'
,
status
:
{
pending
:
'
Pending
'
,
running
:
'
Running
'
,
succeeded
:
'
Succeeded
'
,
failed
:
'
Failed
'
,
canceled
:
'
Canceled
'
}
}
},
// Ops Monitoring
...
...
frontend/src/i18n/locales/zh.ts
View file @
0f8d42c5
...
...
@@ -569,7 +569,10 @@ export default {
previous
:
'
上一页
'
,
next
:
'
下一页
'
,
perPage
:
'
每页
'
,
goToPage
:
'
跳转到第 {page} 页
'
goToPage
:
'
跳转到第 {page} 页
'
,
jumpTo
:
'
跳转页
'
,
jumpPlaceholder
:
'
页码
'
,
jumpAction
:
'
跳转
'
},
// Errors
...
...
@@ -2085,7 +2088,43 @@ export default {
cacheCreationTokens
:
'
缓存创建 Token
'
,
cacheReadTokens
:
'
缓存读取 Token
'
,
failedToLoad
:
'
加载使用记录失败
'
,
ipAddress
:
'
IP
'
billingType
:
'
计费类型
'
,
allBillingTypes
:
'
全部计费类型
'
,
billingTypeBalance
:
'
钱包余额
'
,
billingTypeSubscription
:
'
订阅套餐
'
,
ipAddress
:
'
IP
'
,
cleanup
:
{
button
:
'
清理
'
,
title
:
'
清理使用记录
'
,
warning
:
'
清理不可恢复,且会影响历史统计回看。
'
,
submit
:
'
提交清理
'
,
submitting
:
'
提交中...
'
,
confirmTitle
:
'
确认清理
'
,
confirmMessage
:
'
确定要提交清理任务吗?清理不可恢复。
'
,
confirmSubmit
:
'
确认清理
'
,
cancel
:
'
取消任务
'
,
cancelConfirmTitle
:
'
确认取消
'
,
cancelConfirmMessage
:
'
确定要取消该清理任务吗?
'
,
cancelConfirm
:
'
确认取消
'
,
cancelSuccess
:
'
清理任务已取消
'
,
cancelFailed
:
'
取消清理任务失败
'
,
recentTasks
:
'
最近清理任务
'
,
loadingTasks
:
'
正在加载任务...
'
,
noTasks
:
'
暂无清理任务
'
,
range
:
'
时间范围
'
,
deletedRows
:
'
删除数量
'
,
missingRange
:
'
请选择时间范围
'
,
submitSuccess
:
'
清理任务已创建
'
,
submitFailed
:
'
创建清理任务失败
'
,
loadFailed
:
'
加载清理任务失败
'
,
status
:
{
pending
:
'
待执行
'
,
running
:
'
执行中
'
,
succeeded
:
'
已完成
'
,
failed
:
'
失败
'
,
canceled
:
'
已取消
'
}
}
},
// Ops Monitoring
...
...
frontend/src/types/index.ts
View file @
0f8d42c5
...
...
@@ -633,6 +633,7 @@ export interface UsageLog {
actual_cost
:
number
rate_multiplier
:
number
account_rate_multiplier
?:
number
|
null
billing_type
:
number
stream
:
boolean
duration_ms
:
number
...
...
@@ -657,6 +658,33 @@ export interface UsageLog {
subscription
?:
UserSubscription
}
export
interface
UsageCleanupFilters
{
start_time
:
string
end_time
:
string
user_id
?:
number
api_key_id
?:
number
account_id
?:
number
group_id
?:
number
model
?:
string
|
null
stream
?:
boolean
|
null
billing_type
?:
number
|
null
}
export
interface
UsageCleanupTask
{
id
:
number
status
:
string
filters
:
UsageCleanupFilters
created_by
:
number
deleted_rows
:
number
error_message
?:
string
|
null
canceled_by
?:
number
|
null
canceled_at
?:
string
|
null
started_at
?:
string
|
null
finished_at
?:
string
|
null
created_at
:
string
updated_at
:
string
}
export
interface
RedeemCode
{
id
:
number
code
:
string
...
...
@@ -880,6 +908,7 @@ export interface UsageQueryParams {
group_id
?:
number
model
?:
string
stream
?:
boolean
billing_type
?:
number
|
null
start_date
?:
string
end_date
?:
string
}
...
...
frontend/src/views/admin/UsageView.vue
View file @
0f8d42c5
...
...
@@ -17,12 +17,19 @@
<TokenUsageTrend
:trend-data=
"trendData"
:loading=
"chartsLoading"
/>
</div>
</div>
<UsageFilters
v-model=
"filters"
v-model:startDate=
"startDate"
v-model:endDate=
"endDate"
:exporting=
"exporting"
@
change=
"applyFilters"
@
reset=
"resetFilters"
@
export=
"exportToExcel"
/>
<UsageFilters
v-model=
"filters"
v-model:startDate=
"startDate"
v-model:endDate=
"endDate"
:exporting=
"exporting"
@
change=
"applyFilters"
@
reset=
"resetFilters"
@
cleanup=
"openCleanupDialog"
@
export=
"exportToExcel"
/>
<UsageTable
:data=
"usageLogs"
:loading=
"loading"
/>
<Pagination
v-if=
"pagination.total > 0"
:page=
"pagination.page"
:total=
"pagination.total"
:page-size=
"pagination.page_size"
@
update:page=
"handlePageChange"
@
update:pageSize=
"handlePageSizeChange"
/>
</div>
</AppLayout>
<UsageExportProgress
:show=
"exportProgress.show"
:progress=
"exportProgress.progress"
:current=
"exportProgress.current"
:total=
"exportProgress.total"
:estimated-time=
"exportProgress.estimatedTime"
@
cancel=
"cancelExport"
/>
<UsageCleanupDialog
:show=
"cleanupDialogVisible"
:filters=
"filters"
:start-date=
"startDate"
:end-date=
"endDate"
@
close=
"cleanupDialogVisible = false"
/>
</
template
>
<
script
setup
lang=
"ts"
>
...
...
@@ -33,6 +40,7 @@ import { useAppStore } from '@/stores/app'; import { adminAPI } from '@/api/admi
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
{
UsageLog
,
TrendDataPoint
,
ModelStat
}
from
'
@/types
'
;
import
type
{
AdminUsageStatsResponse
,
AdminUsageQueryParams
}
from
'
@/api/admin/usage
'
...
...
@@ -42,6 +50,7 @@ const usageStats = ref<AdminUsageStatsResponse | null>(null); const usageLogs =
const
trendData
=
ref
<
TrendDataPoint
[]
>
([]);
const
modelStats
=
ref
<
ModelStat
[]
>
([]);
const
chartsLoading
=
ref
(
false
);
const
granularity
=
ref
<
'
day
'
|
'
hour
'
>
(
'
day
'
)
let
abortController
:
AbortController
|
null
=
null
;
let
exportAbortController
:
AbortController
|
null
=
null
const
exportProgress
=
reactive
({
show
:
false
,
progress
:
0
,
current
:
0
,
total
:
0
,
estimatedTime
:
''
})
const
cleanupDialogVisible
=
ref
(
false
)
const
granularityOptions
=
computed
(()
=>
[{
value
:
'
day
'
,
label
:
t
(
'
admin.dashboard.day
'
)
},
{
value
:
'
hour
'
,
label
:
t
(
'
admin.dashboard.hour
'
)
}])
// Use local timezone to avoid UTC timezone issues
...
...
@@ -53,7 +62,7 @@ const formatLD = (d: Date) => {
}
const
now
=
new
Date
();
const
weekAgo
=
new
Date
();
weekAgo
.
setDate
(
weekAgo
.
getDate
()
-
6
)
const
startDate
=
ref
(
formatLD
(
weekAgo
));
const
endDate
=
ref
(
formatLD
(
now
))
const
filters
=
ref
<
AdminUsageQueryParams
>
({
user_id
:
undefined
,
model
:
undefined
,
group_id
:
undefined
,
start_date
:
startDate
.
value
,
end_date
:
endDate
.
value
})
const
filters
=
ref
<
AdminUsageQueryParams
>
({
user_id
:
undefined
,
model
:
undefined
,
group_id
:
undefined
,
billing_type
:
null
,
start_date
:
startDate
.
value
,
end_date
:
endDate
.
value
})
const
pagination
=
reactive
({
page
:
1
,
page_size
:
20
,
total
:
0
})
const
loadLogs
=
async
()
=>
{
...
...
@@ -67,16 +76,17 @@ const loadStats = async () => { try { const s = await adminAPI.usage.getStats(fi
const
loadChartData
=
async
()
=>
{
chartsLoading
.
value
=
true
try
{
const
params
=
{
start_date
:
filters
.
value
.
start_date
||
startDate
.
value
,
end_date
:
filters
.
value
.
end_date
||
endDate
.
value
,
granularity
:
granularity
.
value
,
user_id
:
filters
.
value
.
user_id
,
model
:
filters
.
value
.
model
,
api_key_id
:
filters
.
value
.
api_key_id
,
account_id
:
filters
.
value
.
account_id
,
group_id
:
filters
.
value
.
group_id
,
stream
:
filters
.
value
.
stream
}
const
[
trendRes
,
modelRes
]
=
await
Promise
.
all
([
adminAPI
.
dashboard
.
getUsageTrend
(
params
),
adminAPI
.
dashboard
.
getModelStats
({
start_date
:
params
.
start_date
,
end_date
:
params
.
end_date
,
user_id
:
params
.
user_id
,
model
:
params
.
model
,
api_key_id
:
params
.
api_key_id
,
account_id
:
params
.
account_id
,
group_id
:
params
.
group_id
,
stream
:
params
.
stream
})])
const
params
=
{
start_date
:
filters
.
value
.
start_date
||
startDate
.
value
,
end_date
:
filters
.
value
.
end_date
||
endDate
.
value
,
granularity
:
granularity
.
value
,
user_id
:
filters
.
value
.
user_id
,
model
:
filters
.
value
.
model
,
api_key_id
:
filters
.
value
.
api_key_id
,
account_id
:
filters
.
value
.
account_id
,
group_id
:
filters
.
value
.
group_id
,
stream
:
filters
.
value
.
stream
,
billing_type
:
filters
.
value
.
billing_type
}
const
[
trendRes
,
modelRes
]
=
await
Promise
.
all
([
adminAPI
.
dashboard
.
getUsageTrend
(
params
),
adminAPI
.
dashboard
.
getModelStats
({
start_date
:
params
.
start_date
,
end_date
:
params
.
end_date
,
user_id
:
params
.
user_id
,
model
:
params
.
model
,
api_key_id
:
params
.
api_key_id
,
account_id
:
params
.
account_id
,
group_id
:
params
.
group_id
,
stream
:
params
.
stream
,
billing_type
:
params
.
billing_type
})])
trendData
.
value
=
trendRes
.
trend
||
[];
modelStats
.
value
=
modelRes
.
models
||
[]
}
catch
(
error
)
{
console
.
error
(
'
Failed to load chart data:
'
,
error
)
}
finally
{
chartsLoading
.
value
=
false
}
}
const
applyFilters
=
()
=>
{
pagination
.
page
=
1
;
loadLogs
();
loadStats
();
loadChartData
()
}
const
resetFilters
=
()
=>
{
startDate
.
value
=
formatLD
(
weekAgo
);
endDate
.
value
=
formatLD
(
now
);
filters
.
value
=
{
start_date
:
startDate
.
value
,
end_date
:
endDate
.
value
};
granularity
.
value
=
'
day
'
;
applyFilters
()
}
const
resetFilters
=
()
=>
{
startDate
.
value
=
formatLD
(
weekAgo
);
endDate
.
value
=
formatLD
(
now
);
filters
.
value
=
{
start_date
:
startDate
.
value
,
end_date
:
endDate
.
value
,
billing_type
:
null
};
granularity
.
value
=
'
day
'
;
applyFilters
()
}
const
handlePageChange
=
(
p
:
number
)
=>
{
pagination
.
page
=
p
;
loadLogs
()
}
const
handlePageSizeChange
=
(
s
:
number
)
=>
{
pagination
.
page_size
=
s
;
pagination
.
page
=
1
;
loadLogs
()
}
const
cancelExport
=
()
=>
exportAbortController
?.
abort
()
const
openCleanupDialog
=
()
=>
{
cleanupDialogVisible
.
value
=
true
}
const
exportToExcel
=
async
()
=>
{
if
(
exporting
.
value
)
return
;
exporting
.
value
=
true
;
exportProgress
.
show
=
true
...
...
Prev
1
2
3
4
Next
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment