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
fb9d0878
Commit
fb9d0878
authored
Dec 28, 2025
by
shaw
Browse files
Merge PR #62: refactor(frontend): 前端界面优化与订阅状态管理增强
parents
fd51ff69
18c6686f
Changes
48
Hide whitespace changes
Inline
Side-by-side
frontend/src/views/auth/LoginView.vue
View file @
fb9d0878
...
...
@@ -39,6 +39,7 @@
v-model=
"formData.email"
type=
"email"
required
autofocus
autocomplete=
"email"
:disabled=
"isLoading"
class=
"input pl-11"
...
...
frontend/src/views/auth/RegisterView.vue
View file @
fb9d0878
...
...
@@ -66,6 +66,7 @@
v
-
model
=
"
formData.email
"
type
=
"
email
"
required
autofocus
autocomplete
=
"
email
"
:
disabled
=
"
isLoading
"
class
=
"
input pl-11
"
...
...
frontend/src/views/setup/SetupWizardView.vue
View file @
fb9d0878
...
...
@@ -563,13 +563,13 @@ const installing = ref(false)
const
confirmPassword
=
ref
(
''
)
const
serviceReady
=
ref
(
false
)
//
Get curren
t server port
from browser location (set by install.sh)
//
Defaul
t server port
const
getCurrentPort
=
():
number
=>
{
const
port
=
window
.
location
.
port
if
(
port
)
{
return
parseInt
(
port
,
10
)
}
// Default port based on protocol
return
window
.
location
.
protocol
===
'
https:
'
?
443
:
80
}
...
...
@@ -674,42 +674,35 @@ async function performInstall() {
// Wait for service to restart and become available
async
function
waitForServiceRestart
()
{
const
maxAttempts
=
3
0
//
3
0 attempts, ~
3
0 seconds max
const
maxAttempts
=
6
0
//
Increase to 6
0 attempts, ~
6
0 seconds max
const
interval
=
1000
// 1 second between attempts
// Wait a moment for the service to start restarting
await
new
Promise
((
resolve
)
=>
setTimeout
(
resolve
,
2
000
))
await
new
Promise
((
resolve
)
=>
setTimeout
(
resolve
,
3
000
))
for
(
let
attempt
=
0
;
attempt
<
maxAttempts
;
attempt
++
)
{
try
{
// Try to access the health endpoint
const
response
=
await
fetch
(
'
/health
'
,
{
// Use setup status endpoint as it tells us the real mode
// Service might return 404 or connection refused while restarting
const
response
=
await
fetch
(
'
/setup/status
'
,
{
method
:
'
GET
'
,
cache
:
'
no-store
'
})
if
(
response
.
ok
)
{
// Service is up, check if setup is no longer needed
const
statusResponse
=
await
fetch
(
'
/setup/status
'
,
{
method
:
'
GET
'
,
cache
:
'
no-store
'
})
if
(
statusResponse
.
ok
)
{
const
data
=
await
statusResponse
.
json
()
// If needs_setup is false, service has restarted in normal mode
if
(
data
.
data
&&
!
data
.
data
.
needs_setup
)
{
serviceReady
.
value
=
true
// Redirect to login page after a short delay
setTimeout
(()
=>
{
window
.
location
.
href
=
'
/login
'
},
1500
)
return
}
const
data
=
await
response
.
json
()
// If needs_setup is false, service has restarted in normal mode
if
(
data
.
data
&&
!
data
.
data
.
needs_setup
)
{
serviceReady
.
value
=
true
// Redirect to login page after a short delay
setTimeout
(()
=>
{
window
.
location
.
href
=
'
/login
'
},
1500
)
return
}
}
}
catch
{
// Service not ready
ye
t, continue polling
// Service not ready
or network error during restar
t, continue polling
}
await
new
Promise
((
resolve
)
=>
setTimeout
(
resolve
,
interval
))
...
...
frontend/src/views/user/DashboardView.vue
View file @
fb9d0878
...
...
@@ -322,7 +322,13 @@
<!-- Charts Grid -->
<div
class=
"grid grid-cols-1 gap-6 lg:grid-cols-2"
>
<!-- Model Distribution Chart -->
<div
class=
"card p-4"
>
<div
class=
"card relative overflow-hidden p-4"
>
<div
v-if=
"loadingCharts"
class=
"absolute inset-0 z-10 flex items-center justify-center bg-white/50 backdrop-blur-sm dark:bg-dark-800/50"
>
<LoadingSpinner
size=
"md"
/>
</div>
<h3
class=
"mb-4 text-sm font-semibold text-gray-900 dark:text-white"
>
{{
t
(
'
dashboard.modelDistribution
'
)
}}
</h3>
...
...
@@ -330,6 +336,7 @@
<div
class=
"h-48 w-48"
>
<Doughnut
v-if=
"modelChartData"
ref=
"modelChartRef"
:data=
"modelChartData"
:options=
"doughnutOptions"
/>
...
...
@@ -383,12 +390,23 @@
</div>
<!-- Token Usage Trend Chart -->
<div
class=
"card p-4"
>
<div
class=
"card relative overflow-hidden p-4"
>
<div
v-if=
"loadingCharts"
class=
"absolute inset-0 z-10 flex items-center justify-center bg-white/50 backdrop-blur-sm dark:bg-dark-800/50"
>
<LoadingSpinner
size=
"md"
/>
</div>
<h3
class=
"mb-4 text-sm font-semibold text-gray-900 dark:text-white"
>
{{
t
(
'
dashboard.tokenUsageTrend
'
)
}}
</h3>
<div
class=
"h-48"
>
<Line
v-if=
"trendChartData"
:data=
"trendChartData"
:options=
"lineOptions"
/>
<Line
v-if=
"trendChartData"
ref=
"trendChartRef"
:data=
"trendChartData"
:options=
"lineOptions"
/>
<div
v-else
class=
"flex h-full items-center justify-center text-sm text-gray-500 dark:text-gray-400"
...
...
@@ -645,10 +663,11 @@
</template>
<
script
setup
lang=
"ts"
>
import
{
ref
,
computed
,
onMounted
,
watch
}
from
'
vue
'
import
{
ref
,
computed
,
onMounted
,
watch
,
nextTick
}
from
'
vue
'
import
{
useRouter
}
from
'
vue-router
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAuthStore
}
from
'
@/stores/auth
'
import
{
useSubscriptionStore
}
from
'
@/stores/subscriptions
'
import
{
formatDateTime
}
from
'
@/utils/format
'
const
{
t
}
=
useI18n
()
...
...
@@ -689,15 +708,21 @@ ChartJS.register(
const
router
=
useRouter
()
const
authStore
=
useAuthStore
()
const
subscriptionStore
=
useSubscriptionStore
()
const
user
=
computed
(()
=>
authStore
.
user
)
const
stats
=
ref
<
UserDashboardStats
|
null
>
(
null
)
const
loading
=
ref
(
false
)
const
loadingUsage
=
ref
(
false
)
const
loadingCharts
=
ref
(
false
)
type
ChartComponentRef
=
{
chart
?:
ChartJS
}
// Chart data
const
trendData
=
ref
<
TrendDataPoint
[]
>
([])
const
modelStats
=
ref
<
ModelStat
[]
>
([])
const
modelChartRef
=
ref
<
ChartComponentRef
|
null
>
(
null
)
const
trendChartRef
=
ref
<
ChartComponentRef
|
null
>
(
null
)
// Recent usage
const
recentUsage
=
ref
<
UsageLog
[]
>
([])
...
...
@@ -964,6 +989,7 @@ const loadDashboardStats = async () => {
}
const
loadChartData
=
async
()
=>
{
loadingCharts
.
value
=
true
try
{
const
params
=
{
start_date
:
startDate
.
value
,
...
...
@@ -981,18 +1007,15 @@ const loadChartData = async () => {
modelStats
.
value
=
modelResponse
.
models
||
[]
}
catch
(
error
)
{
console
.
error
(
'
Error loading chart data:
'
,
error
)
}
finally
{
loadingCharts
.
value
=
false
}
}
const
loadRecentUsage
=
async
()
=>
{
loadingUsage
.
value
=
true
try
{
// 后端 /usage 查询参数 start_date/end_date 仅接受 YYYY-MM-DD(见 backend usage handler 的校验逻辑)。
// 同时后端会将 end_date 自动扩展到当天 23:59:59.999...,因此前端只需要传「日期」即可。
// 注意:toISOString() 生成的是 UTC 日期字符串;如果需要按本地/服务端时区对齐统计口径,
// 请改用时区感知的日期格式化方法(例如 Intl.DateTimeFormat 指定 timeZone)。
const
now
=
new
Date
()
const
endDate
=
now
.
toISOString
().
split
(
'
T
'
)[
0
]
const
endDate
=
new
Date
().
toISOString
().
split
(
'
T
'
)[
0
]
const
startDate
=
new
Date
(
Date
.
now
()
-
7
*
24
*
60
*
60
*
1000
).
toISOString
().
split
(
'
T
'
)[
0
]
const
usageResponse
=
await
usageAPI
.
getByDateRange
(
startDate
,
endDate
)
recentUsage
.
value
=
usageResponse
.
items
.
slice
(
0
,
5
)
...
...
@@ -1003,16 +1026,30 @@ const loadRecentUsage = async () => {
}
}
onMounted
(()
=>
{
loadDashboardStats
()
onMounted
(
async
()
=>
{
// Load critical data first
await
loadDashboardStats
()
// Force refresh subscription status when entering dashboard (bypass cache)
subscriptionStore
.
fetchActiveSubscriptions
(
true
).
catch
((
error
)
=>
{
console
.
error
(
'
Failed to refresh subscription status:
'
,
error
)
})
// Initialize date range (synchronous)
initializeDateRange
()
loadChartData
()
loadRecentUsage
()
// Load chart data and recent usage in parallel (non-critical)
Promise
.
all
([
loadChartData
(),
loadRecentUsage
()]).
catch
((
error
)
=>
{
console
.
error
(
'
Error loading secondary data:
'
,
error
)
})
})
// Watch for dark mode changes
watch
(
isDarkMode
,
()
=>
{
// Force chart re-render on theme change
nextTick
(()
=>
{
modelChartRef
.
value
?.
chart
?.
update
()
trendChartRef
.
value
?.
chart
?.
update
()
})
})
</
script
>
...
...
frontend/src/views/user/KeysView.vue
View file @
fb9d0878
...
...
@@ -292,17 +292,19 @@
:total=
"pagination.total"
:page-size=
"pagination.page_size"
@
update:page=
"handlePageChange"
@
update:pageSize=
"handlePageSizeChange"
/>
</
template
>
</TablePageLayout>
<!-- Create/Edit Modal -->
<
Modal
<
BaseDialog
:show=
"showCreateModal || showEditModal"
:title=
"showEditModal ? t('keys.editKey') : t('keys.createKey')"
width=
"narrow"
@
close=
"closeModals"
>
<form
@
submit.prevent=
"handleSubmit"
class=
"space-y-5"
>
<form
id=
"key-form"
@
submit.prevent=
"handleSubmit"
class=
"space-y-5"
>
<div>
<label
class=
"input-label"
>
{{ t('keys.nameLabel') }}
</label>
<input
...
...
@@ -383,12 +385,13 @@
:placeholder=
"t('keys.selectStatus')"
/>
</div>
<div
class=
"flex justify-end gap-3 pt-4"
>
</form>
<
template
#footer
>
<div
class=
"flex justify-end gap-3"
>
<button
@
click=
"closeModals"
type=
"button"
class=
"btn btn-secondary"
>
{{
t
(
'
common.cancel
'
)
}}
</button>
<button
type=
"submit"
:disabled=
"submitting"
class=
"btn btn-primary"
>
<button
form=
"key-form"
type=
"submit"
:disabled=
"submitting"
class=
"btn btn-primary"
>
<svg
v-if=
"submitting"
class=
"-ml-1 mr-2 h-4 w-4 animate-spin"
...
...
@@ -418,8 +421,8 @@
}}
</button>
</div>
</
form
>
</
Modal
>
</
template
>
</
BaseDialog
>
<!-- Delete Confirmation Dialog -->
<ConfirmDialog
...
...
@@ -501,7 +504,7 @@ import AppLayout from '@/components/layout/AppLayout.vue'
import
TablePageLayout
from
'
@/components/layout/TablePageLayout.vue
'
import
DataTable
from
'
@/components/common/DataTable.vue
'
import
Pagination
from
'
@/components/common/Pagination.vue
'
import
Modal
from
'
@/components/common/
Modal
.vue
'
import
BaseDialog
from
'
@/components/common/
BaseDialog
.vue
'
import
ConfirmDialog
from
'
@/components/common/ConfirmDialog.vue
'
import
EmptyState
from
'
@/components/common/EmptyState.vue
'
import
Select
from
'
@/components/common/Select.vue
'
...
...
@@ -557,6 +560,7 @@ const publicSettings = ref<PublicSettings | null>(null)
const
dropdownRef
=
ref
<
HTMLElement
|
null
>
(
null
)
const
dropdownPosition
=
ref
<
{
top
:
number
;
left
:
number
}
|
null
>
(
null
)
const
groupButtonRefs
=
ref
<
Map
<
number
,
HTMLElement
>>
(
new
Map
())
let
abortController
:
AbortController
|
null
=
null
// Get the currently selected key for group change
const
selectedKeyForGroup
=
computed
(()
=>
{
...
...
@@ -623,14 +627,27 @@ const copyToClipboard = async (text: string, keyId: number) => {
copiedKeyId
.
value
=
keyId
setTimeout
(()
=>
{
copiedKeyId
.
value
=
null
},
20
00
)
},
8
00
)
}
}
const
isAbortError
=
(
error
:
unknown
)
=>
{
if
(
!
error
||
typeof
error
!==
'
object
'
)
return
false
const
{
name
,
code
}
=
error
as
{
name
?:
string
;
code
?:
string
}
return
name
===
'
AbortError
'
||
code
===
'
ERR_CANCELED
'
}
const
loadApiKeys
=
async
()
=>
{
abortController
?.
abort
()
const
controller
=
new
AbortController
()
abortController
=
controller
const
{
signal
}
=
controller
loading
.
value
=
true
try
{
const
response
=
await
keysAPI
.
list
(
pagination
.
value
.
page
,
pagination
.
value
.
page_size
)
const
response
=
await
keysAPI
.
list
(
pagination
.
value
.
page
,
pagination
.
value
.
page_size
,
{
signal
})
if
(
signal
.
aborted
)
return
apiKeys
.
value
=
response
.
items
pagination
.
value
.
total
=
response
.
total
pagination
.
value
.
pages
=
response
.
pages
...
...
@@ -639,16 +656,24 @@ const loadApiKeys = async () => {
if
(
response
.
items
.
length
>
0
)
{
const
keyIds
=
response
.
items
.
map
((
k
)
=>
k
.
id
)
try
{
const
usageResponse
=
await
usageAPI
.
getDashboardApiKeysUsage
(
keyIds
)
const
usageResponse
=
await
usageAPI
.
getDashboardApiKeysUsage
(
keyIds
,
{
signal
})
if
(
signal
.
aborted
)
return
usageStats
.
value
=
usageResponse
.
stats
}
catch
(
e
)
{
console
.
error
(
'
Failed to load usage stats:
'
,
e
)
if
(
!
isAbortError
(
e
))
{
console
.
error
(
'
Failed to load usage stats:
'
,
e
)
}
}
}
}
catch
(
error
)
{
if
(
isAbortError
(
error
))
{
return
}
appStore
.
showError
(
t
(
'
keys.failedToLoad
'
))
}
finally
{
loading
.
value
=
false
if
(
abortController
===
controller
)
{
loading
.
value
=
false
}
}
}
...
...
@@ -683,6 +708,12 @@ const handlePageChange = (page: number) => {
loadApiKeys
()
}
const
handlePageSizeChange
=
(
pageSize
:
number
)
=>
{
pagination
.
value
.
page_size
=
pageSize
pagination
.
value
.
page
=
1
loadApiKeys
()
}
const
editKey
=
(
key
:
ApiKey
)
=>
{
selectedKey
.
value
=
key
formData
.
value
=
{
...
...
frontend/src/views/user/ProfileView.vue
View file @
fb9d0878
...
...
@@ -244,6 +244,12 @@
autocomplete=
"new-password"
class=
"input"
/>
<p
v-if=
"passwordForm.new_password && passwordForm.confirm_password && passwordForm.new_password !== passwordForm.confirm_password"
class=
"input-error-text"
>
{{
t
(
'
profile.passwordsNotMatch
'
)
}}
</p>
</div>
<div
class=
"flex justify-end pt-4"
>
...
...
@@ -392,6 +398,12 @@ const handleChangePassword = async () => {
}
const
handleUpdateProfile
=
async
()
=>
{
// Basic validation
if
(
!
profileForm
.
value
.
username
.
trim
())
{
appStore
.
showError
(
t
(
'
profile.usernameRequired
'
))
return
}
updatingProfile
.
value
=
true
try
{
const
updatedUser
=
await
userAPI
.
updateProfile
({
...
...
frontend/src/views/user/RedeemView.vue
View file @
fb9d0878
...
...
@@ -445,6 +445,7 @@ import { ref, computed, onMounted } from 'vue'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAuthStore
}
from
'
@/stores/auth
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
useSubscriptionStore
}
from
'
@/stores/subscriptions
'
import
{
redeemAPI
,
authAPI
,
type
RedeemHistoryItem
}
from
'
@/api
'
import
AppLayout
from
'
@/components/layout/AppLayout.vue
'
import
{
formatDateTime
}
from
'
@/utils/format
'
...
...
@@ -452,6 +453,7 @@ import { formatDateTime } from '@/utils/format'
const
{
t
}
=
useI18n
()
const
authStore
=
useAuthStore
()
const
appStore
=
useAppStore
()
const
subscriptionStore
=
useSubscriptionStore
()
const
user
=
computed
(()
=>
authStore
.
user
)
...
...
@@ -544,6 +546,16 @@ const handleRedeem = async () => {
// Refresh user data to get updated balance/concurrency
await
authStore
.
refreshUser
()
// If subscription type, immediately refresh subscription status
if
(
result
.
type
===
'
subscription
'
)
{
try
{
await
subscriptionStore
.
fetchActiveSubscriptions
(
true
)
// force refresh
}
catch
(
error
)
{
console
.
error
(
'
Failed to refresh subscriptions after redeem:
'
,
error
)
appStore
.
showWarning
(
t
(
'
redeem.subscriptionRefreshFailed
'
))
}
}
// Clear the input
redeemCode
.
value
=
''
...
...
frontend/src/views/user/UsageView.vue
View file @
fb9d0878
...
...
@@ -164,8 +164,28 @@
<button
@
click=
"resetFilters"
class=
"btn btn-secondary"
>
{{
t
(
'
common.reset
'
)
}}
</button>
<button
@
click=
"exportToCSV"
class=
"btn btn-primary"
>
{{
t
(
'
usage.exportCsv
'
)
}}
<button
@
click=
"exportToCSV"
:disabled=
"exporting"
class=
"btn btn-primary"
>
<svg
v-if=
"exporting"
class=
"-ml-1 mr-2 h-4 w-4 animate-spin"
fill=
"none"
viewBox=
"0 0 24 24"
>
<circle
class=
"opacity-25"
cx=
"12"
cy=
"12"
r=
"10"
stroke=
"currentColor"
stroke-width=
"4"
></circle>
<path
class=
"opacity-75"
fill=
"currentColor"
d=
"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{{
exporting
?
t
(
'
usage.exporting
'
)
:
t
(
'
usage.exportCsv
'
)
}}
</button>
</div>
</div>
...
...
@@ -366,6 +386,7 @@
:total=
"pagination.total"
:page-size=
"pagination.page_size"
@
update:page=
"handlePageChange"
@
update:pageSize=
"handlePageSizeChange"
/>
</
template
>
</TablePageLayout>
...
...
@@ -412,7 +433,7 @@
</template>
<
script
setup
lang=
"ts"
>
import
{
ref
,
computed
,
onMounted
}
from
'
vue
'
import
{
ref
,
computed
,
reactive
,
onMounted
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
usageAPI
,
keysAPI
}
from
'
@/api
'
...
...
@@ -430,6 +451,8 @@ import { formatDateTime } from '@/utils/format'
const
{
t
}
=
useI18n
()
const
appStore
=
useAppStore
()
let
abortController
:
AbortController
|
null
=
null
// Tooltip state
const
tooltipVisible
=
ref
(
false
)
const
tooltipPosition
=
ref
({
x
:
0
,
y
:
0
})
...
...
@@ -453,6 +476,7 @@ const columns = computed<Column[]>(() => [
const
usageLogs
=
ref
<
UsageLog
[]
>
([])
const
apiKeys
=
ref
<
ApiKey
[]
>
([])
const
loading
=
ref
(
false
)
const
exporting
=
ref
(
false
)
const
apiKeyOptions
=
computed
(()
=>
{
return
[
...
...
@@ -498,7 +522,7 @@ const onDateRangeChange = (range: {
applyFilters
()
}
const
pagination
=
re
f
({
const
pagination
=
re
active
({
page
:
1
,
page_size
:
20
,
total
:
0
,
...
...
@@ -532,22 +556,40 @@ const formatCacheTokens = (value: number): string => {
}
const
loadUsageLogs
=
async
()
=>
{
if
(
abortController
)
{
abortController
.
abort
()
}
const
currentAbortController
=
new
AbortController
()
abortController
=
currentAbortController
const
{
signal
}
=
currentAbortController
loading
.
value
=
true
try
{
const
params
:
UsageQueryParams
=
{
page
:
pagination
.
value
.
page
,
page_size
:
pagination
.
value
.
page_size
,
page
:
pagination
.
page
,
page_size
:
pagination
.
page_size
,
...
filters
.
value
}
const
response
=
await
usageAPI
.
query
(
params
)
const
response
=
await
usageAPI
.
query
(
params
,
{
signal
})
if
(
signal
.
aborted
)
{
return
}
usageLogs
.
value
=
response
.
items
pagination
.
value
.
total
=
response
.
total
pagination
.
value
.
pages
=
response
.
pages
pagination
.
total
=
response
.
total
pagination
.
pages
=
response
.
pages
}
catch
(
error
)
{
if
(
signal
.
aborted
)
{
return
}
const
abortError
=
error
as
{
name
?:
string
;
code
?:
string
}
if
(
abortError
?.
name
===
'
AbortError
'
||
abortError
?.
code
===
'
ERR_CANCELED
'
)
{
return
}
appStore
.
showError
(
t
(
'
usage.failedToLoad
'
))
}
finally
{
loading
.
value
=
false
if
(
abortController
===
currentAbortController
)
{
loading
.
value
=
false
}
}
}
...
...
@@ -575,7 +617,7 @@ const loadUsageStats = async () => {
}
const
applyFilters
=
()
=>
{
pagination
.
value
.
page
=
1
pagination
.
page
=
1
loadUsageLogs
()
loadUsageStats
()
}
...
...
@@ -588,60 +630,128 @@ const resetFilters = () => {
}
// Reset date range to default (last 7 days)
initializeDateRange
()
pagination
.
value
.
page
=
1
pagination
.
page
=
1
loadUsageLogs
()
loadUsageStats
()
}
const
handlePageChange
=
(
page
:
number
)
=>
{
pagination
.
value
.
page
=
page
pagination
.
page
=
page
loadUsageLogs
()
}
const
exportToCSV
=
()
=>
{
if
(
usageLogs
.
value
.
length
===
0
)
{
const
handlePageSizeChange
=
(
pageSize
:
number
)
=>
{
pagination
.
page_size
=
pageSize
pagination
.
page
=
1
loadUsageLogs
()
}
/**
* Escape CSV value to prevent injection and handle special characters
*/
const
escapeCSVValue
=
(
value
:
unknown
):
string
=>
{
if
(
value
==
null
)
return
''
const
str
=
String
(
value
)
const
escaped
=
str
.
replace
(
/"/g
,
'
""
'
)
// Prevent formula injection by prefixing dangerous characters with single quote
if
(
/^
[
=+
\-
@
\t\r]
/
.
test
(
str
))
{
return
`"\'
${
escaped
}
"`
}
// Escape values containing comma, quote, or newline
if
(
/
[
,"
\n\r]
/
.
test
(
str
))
{
return
`"
${
escaped
}
"`
}
return
str
}
const
exportToCSV
=
async
()
=>
{
if
(
pagination
.
total
===
0
)
{
appStore
.
showWarning
(
t
(
'
usage.noDataToExport
'
))
return
}
const
headers
=
[
'
Model
'
,
'
Type
'
,
'
Input Tokens
'
,
'
Output Tokens
'
,
'
Cache Read Tokens
'
,
'
Cache Write Tokens
'
,
'
Total Cost
'
,
'
Billing Type
'
,
'
First Token (ms)
'
,
'
Duration (ms)
'
,
'
Time
'
]
const
rows
=
usageLogs
.
value
.
map
((
log
)
=>
[
log
.
model
,
log
.
stream
?
'
Stream
'
:
'
Sync
'
,
log
.
input_tokens
,
log
.
output_tokens
,
log
.
cache_read_tokens
,
log
.
cache_creation_tokens
,
log
.
total_cost
.
toFixed
(
6
),
log
.
billing_type
===
1
?
'
Subscription
'
:
'
Balance
'
,
log
.
first_token_ms
??
''
,
log
.
duration_ms
,
log
.
created_at
])
const
csvContent
=
[
headers
.
join
(
'
,
'
),
...
rows
.
map
((
row
)
=>
row
.
join
(
'
,
'
))].
join
(
'
\n
'
)
const
blob
=
new
Blob
([
csvContent
],
{
type
:
'
text/csv
'
})
const
url
=
window
.
URL
.
createObjectURL
(
blob
)
const
link
=
document
.
createElement
(
'
a
'
)
link
.
href
=
url
link
.
download
=
`usage_
${
new
Date
().
toISOString
().
split
(
'
T
'
)[
0
]}
.csv`
link
.
click
()
window
.
URL
.
revokeObjectURL
(
url
)
appStore
.
showSuccess
(
t
(
'
usage.exportSuccess
'
))
exporting
.
value
=
true
appStore
.
showInfo
(
t
(
'
usage.preparingExport
'
))
try
{
const
allLogs
:
UsageLog
[]
=
[]
const
pageSize
=
100
// Use a larger page size for export to reduce requests
const
totalRequests
=
Math
.
ceil
(
pagination
.
total
/
pageSize
)
for
(
let
page
=
1
;
page
<=
totalRequests
;
page
++
)
{
const
params
:
UsageQueryParams
=
{
page
:
page
,
page_size
:
pageSize
,
...
filters
.
value
}
const
response
=
await
usageAPI
.
query
(
params
)
allLogs
.
push
(...
response
.
items
)
}
if
(
allLogs
.
length
===
0
)
{
appStore
.
showWarning
(
t
(
'
usage.noDataToExport
'
))
return
}
const
headers
=
[
'
Time
'
,
'
API Key Name
'
,
'
Model
'
,
'
Type
'
,
'
Input Tokens
'
,
'
Output Tokens
'
,
'
Cache Read Tokens
'
,
'
Cache Creation Tokens
'
,
'
Rate Multiplier
'
,
'
Billed Cost
'
,
'
Original Cost
'
,
'
Billing Type
'
,
'
First Token (ms)
'
,
'
Duration (ms)
'
]
const
rows
=
allLogs
.
map
((
log
)
=>
[
log
.
created_at
,
log
.
api_key
?.
name
||
''
,
log
.
model
,
log
.
stream
?
'
Stream
'
:
'
Sync
'
,
log
.
input_tokens
,
log
.
output_tokens
,
log
.
cache_read_tokens
,
log
.
cache_creation_tokens
,
log
.
rate_multiplier
,
log
.
actual_cost
.
toFixed
(
8
),
log
.
total_cost
.
toFixed
(
8
),
log
.
billing_type
===
1
?
'
Subscription
'
:
'
Balance
'
,
log
.
first_token_ms
??
''
,
log
.
duration_ms
].
map
(
escapeCSVValue
)
)
const
csvContent
=
[
headers
.
map
(
escapeCSVValue
).
join
(
'
,
'
),
...
rows
.
map
((
row
)
=>
row
.
join
(
'
,
'
))
].
join
(
'
\n
'
)
const
blob
=
new
Blob
([
csvContent
],
{
type
:
'
text/csv;charset=utf-8;
'
})
const
url
=
window
.
URL
.
createObjectURL
(
blob
)
const
link
=
document
.
createElement
(
'
a
'
)
link
.
href
=
url
link
.
download
=
`usage_
${
filters
.
value
.
start_date
}
_to_
${
filters
.
value
.
end_date
}
.csv`
link
.
click
()
window
.
URL
.
revokeObjectURL
(
url
)
appStore
.
showSuccess
(
t
(
'
usage.exportSuccess
'
))
}
catch
(
error
)
{
appStore
.
showError
(
t
(
'
usage.exportFailed
'
))
console
.
error
(
'
CSV Export failed:
'
,
error
)
}
finally
{
exporting
.
value
=
false
}
}
// Tooltip functions
...
...
Prev
1
2
3
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