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
d4f6ad72
Commit
d4f6ad72
authored
Mar 05, 2026
by
shaw
Browse files
feat: 新增apikey的usage查询页面
parent
078fefed
Changes
5
Show whitespace changes
Inline
Side-by-side
frontend/src/i18n/locales/en.ts
View file @
d4f6ad72
...
...
@@ -110,6 +110,65 @@ export default {
}
},
// Key Usage Query Page
keyUsage
:
{
title
:
'
API Key Usage
'
,
subtitle
:
'
Enter your API Key to view real-time spending and usage status
'
,
placeholder
:
'
sk-ant-mirror-xxxxxxxxxxxx
'
,
query
:
'
Query
'
,
querying
:
'
Querying...
'
,
privacyNote
:
'
Your Key is processed locally in the browser and will not be stored
'
,
dateRange
:
'
Date Range:
'
,
dateRangeToday
:
'
Today
'
,
dateRange7d
:
'
7 Days
'
,
dateRange30d
:
'
30 Days
'
,
dateRangeCustom
:
'
Custom
'
,
apply
:
'
Apply
'
,
used
:
'
Used
'
,
detailInfo
:
'
Detail Information
'
,
tokenStats
:
'
Token Statistics
'
,
modelStats
:
'
Model Usage Statistics
'
,
// Table headers
model
:
'
Model
'
,
requests
:
'
Requests
'
,
inputTokens
:
'
Input Tokens
'
,
outputTokens
:
'
Output Tokens
'
,
totalTokens
:
'
Total Tokens
'
,
cost
:
'
Cost
'
,
// Status
quotaMode
:
'
Key Quota Mode
'
,
walletBalance
:
'
Wallet Balance
'
,
// Ring card titles
totalQuota
:
'
Total Quota
'
,
limit5h
:
'
5-Hour Limit
'
,
limitDaily
:
'
Daily Limit
'
,
limit7d
:
'
7-Day Limit
'
,
limitWeekly
:
'
Weekly Limit
'
,
limitMonthly
:
'
Monthly Limit
'
,
// Detail rows
remainingQuota
:
'
Remaining Quota
'
,
expiresAt
:
'
Expires At
'
,
todayExpires
:
'
(expires today)
'
,
daysLeft
:
'
({days} days)
'
,
usedQuota
:
'
Used Quota
'
,
subscriptionType
:
'
Subscription Type
'
,
subscriptionExpires
:
'
Subscription Expires
'
,
// Usage stat cells
todayRequests
:
'
Today Requests
'
,
todayTokens
:
'
Today Tokens
'
,
todayCost
:
'
Today Cost
'
,
rpmTpm
:
'
RPM / TPM
'
,
totalRequests
:
'
Total Requests
'
,
totalTokensLabel
:
'
Total Tokens
'
,
totalCost
:
'
Total Cost
'
,
avgDuration
:
'
Avg Duration
'
,
// Messages
enterApiKey
:
'
Please enter an API Key
'
,
querySuccess
:
'
Query successful
'
,
queryFailed
:
'
Query failed
'
,
queryFailedRetry
:
'
Query failed, please try again later
'
,
},
// Setup Wizard
setup
:
{
title
:
'
Sub2API Setup
'
,
...
...
frontend/src/i18n/locales/zh.ts
View file @
d4f6ad72
...
...
@@ -110,6 +110,65 @@ export default {
}
},
// Key Usage Query Page
keyUsage
:
{
title
:
'
API Key 用量查询
'
,
subtitle
:
'
输入您的 API Key 以查看实时消费金额与使用状态
'
,
placeholder
:
'
sk-ant-mirror-xxxxxxxxxxxx
'
,
query
:
'
查询
'
,
querying
:
'
查询中...
'
,
privacyNote
:
'
您的 Key 仅在浏览器本地处理,不会被存储
'
,
dateRange
:
'
统计范围:
'
,
dateRangeToday
:
'
今日
'
,
dateRange7d
:
'
7 天
'
,
dateRange30d
:
'
30 天
'
,
dateRangeCustom
:
'
自定义
'
,
apply
:
'
应用
'
,
used
:
'
已使用
'
,
detailInfo
:
'
详细信息
'
,
tokenStats
:
'
Token 统计
'
,
modelStats
:
'
模型用量统计
'
,
// Table headers
model
:
'
模型
'
,
requests
:
'
请求数
'
,
inputTokens
:
'
输入 Tokens
'
,
outputTokens
:
'
输出 Tokens
'
,
totalTokens
:
'
总 Tokens
'
,
cost
:
'
费用
'
,
// Status
quotaMode
:
'
Key 限额模式
'
,
walletBalance
:
'
钱包余额
'
,
// Ring card titles
totalQuota
:
'
总额度
'
,
limit5h
:
'
5 小时限额
'
,
limitDaily
:
'
日限额
'
,
limit7d
:
'
7 天限额
'
,
limitWeekly
:
'
周限额
'
,
limitMonthly
:
'
月限额
'
,
// Detail rows
remainingQuota
:
'
剩余额度
'
,
expiresAt
:
'
过期时间
'
,
todayExpires
:
'
(今日到期)
'
,
daysLeft
:
'
({days} 天)
'
,
usedQuota
:
'
已用额度
'
,
subscriptionType
:
'
订阅类型
'
,
subscriptionExpires
:
'
订阅到期
'
,
// Usage stat cells
todayRequests
:
'
今日请求
'
,
todayTokens
:
'
今日 Tokens
'
,
todayCost
:
'
今日费用
'
,
rpmTpm
:
'
RPM / TPM
'
,
totalRequests
:
'
累计请求
'
,
totalTokensLabel
:
'
累计 Tokens
'
,
totalCost
:
'
累计费用
'
,
avgDuration
:
'
平均耗时
'
,
// Messages
enterApiKey
:
'
请输入 API Key
'
,
querySuccess
:
'
查询成功
'
,
queryFailed
:
'
查询失败
'
,
queryFailedRetry
:
'
查询失败,请稍后重试
'
,
},
// Setup Wizard
setup
:
{
title
:
'
Sub2API 安装向导
'
,
...
...
frontend/src/router/index.ts
View file @
d4f6ad72
...
...
@@ -102,6 +102,15 @@ const routes: RouteRecordRaw[] = [
title
:
'
Reset Password
'
}
},
{
path
:
'
/key-usage
'
,
name
:
'
KeyUsage
'
,
component
:
()
=>
import
(
'
@/views/KeyUsageView.vue
'
),
meta
:
{
requiresAuth
:
false
,
title
:
'
Key Usage
'
,
}
},
// ==================== User Routes ====================
{
...
...
frontend/src/views/KeyUsageView.vue
0 → 100644
View file @
d4f6ad72
<
template
>
<div
class=
"relative flex min-h-screen flex-col bg-gray-50 dark:bg-dark-950"
>
<!-- Header (same pattern as HomeView) -->
<header
class=
"relative z-20 px-6 py-4"
>
<nav
class=
"mx-auto flex max-w-6xl items-center justify-between"
>
<router-link
to=
"/home"
class=
"flex items-center gap-3"
>
<div
class=
"h-10 w-10 overflow-hidden rounded-xl shadow-md"
>
<img
:src=
"siteLogo || '/logo.png'"
alt=
"Logo"
class=
"h-full w-full object-contain"
/>
</div>
<span
class=
"text-lg font-semibold tracking-tight text-gray-900 dark:text-white"
>
{{
siteName
}}
</span>
</router-link>
<div
class=
"flex items-center gap-3"
>
<LocaleSwitcher
/>
<a
v-if=
"docUrl"
:href=
"docUrl"
target=
"_blank"
rel=
"noopener noreferrer"
class=
"rounded-lg p-2 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 dark:text-dark-400 dark:hover:bg-dark-800 dark:hover:text-white"
:title=
"t('home.viewDocs')"
>
<Icon
name=
"book"
size=
"md"
/>
</a>
<button
@
click=
"toggleTheme"
class=
"rounded-lg p-2 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 dark:text-dark-400 dark:hover:bg-dark-800 dark:hover:text-white"
:title=
"isDark ? t('home.switchToLight') : t('home.switchToDark')"
>
<Icon
v-if=
"isDark"
name=
"sun"
size=
"md"
/>
<Icon
v-else
name=
"moon"
size=
"md"
/>
</button>
</div>
</nav>
</header>
<!-- Main Content -->
<main
class=
"flex-1 w-full max-w-5xl mx-auto px-6 py-12"
>
<!-- Hero -->
<div
class=
"text-center mb-12"
>
<h1
class=
"text-3xl sm:text-4xl font-bold tracking-tight mb-3 text-gray-900 dark:text-white"
>
{{
t
(
'
keyUsage.title
'
)
}}
</h1>
<p
class=
"text-gray-500 dark:text-dark-400 text-base max-w-md mx-auto"
>
{{
t
(
'
keyUsage.subtitle
'
)
}}
</p>
</div>
<!-- Input Section -->
<div
class=
"max-w-xl mx-auto mb-14"
>
<div
class=
"flex gap-3"
>
<div
class=
"flex-1 relative"
>
<div
class=
"absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 dark:text-dark-500"
>
<svg
class=
"w-5 h-5"
viewBox=
"0 0 24 24"
fill=
"none"
stroke=
"currentColor"
stroke-width=
"2"
stroke-linecap=
"round"
stroke-linejoin=
"round"
>
<rect
x=
"3"
y=
"11"
width=
"18"
height=
"11"
rx=
"2"
ry=
"2"
/><path
d=
"M7 11V7a5 5 0 0 1 10 0v4"
/>
</svg>
</div>
<input
v-model=
"apiKey"
:type=
"keyVisible ? 'text' : 'password'"
:placeholder=
"t('keyUsage.placeholder')"
class=
"input-ring w-full h-12 pl-12 pr-12 rounded-xl border border-gray-200 bg-white text-sm text-gray-900 placeholder:text-gray-400 transition-all dark:border-dark-700 dark:bg-dark-900 dark:text-white dark:placeholder:text-dark-500"
@
keydown.enter=
"queryKey"
/>
<button
@
click=
"keyVisible = !keyVisible"
class=
"absolute right-4 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-700 dark:text-dark-500 dark:hover:text-white transition-colors"
>
<svg
v-if=
"!keyVisible"
class=
"w-5 h-5"
viewBox=
"0 0 24 24"
fill=
"none"
stroke=
"currentColor"
stroke-width=
"2"
stroke-linecap=
"round"
stroke-linejoin=
"round"
>
<path
d=
"M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"
/>
<line
x1=
"1"
y1=
"1"
x2=
"23"
y2=
"23"
/>
</svg>
<svg
v-else
class=
"w-5 h-5"
viewBox=
"0 0 24 24"
fill=
"none"
stroke=
"currentColor"
stroke-width=
"2"
stroke-linecap=
"round"
stroke-linejoin=
"round"
>
<path
d=
"M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"
/><circle
cx=
"12"
cy=
"12"
r=
"3"
/>
</svg>
</button>
</div>
<button
@
click=
"queryKey"
:disabled=
"isQuerying"
class=
"h-12 px-7 rounded-xl bg-primary-500 hover:bg-primary-600 text-white font-medium text-sm transition-all active:scale-[0.97] flex items-center gap-2 whitespace-nowrap disabled:opacity-60"
>
<svg
v-if=
"isQuerying"
class=
"w-4 h-4 animate-spin"
viewBox=
"0 0 24 24"
fill=
"none"
>
<circle
cx=
"12"
cy=
"12"
r=
"10"
stroke=
"currentColor"
stroke-width=
"3"
opacity=
"0.25"
/>
<path
d=
"M12 2a10 10 0 0 1 10 10"
stroke=
"currentColor"
stroke-width=
"3"
stroke-linecap=
"round"
/>
</svg>
<svg
v-else
class=
"w-4 h-4"
viewBox=
"0 0 24 24"
fill=
"none"
stroke=
"currentColor"
stroke-width=
"2.5"
stroke-linecap=
"round"
stroke-linejoin=
"round"
>
<circle
cx=
"11"
cy=
"11"
r=
"8"
/><line
x1=
"21"
y1=
"21"
x2=
"16.65"
y2=
"16.65"
/>
</svg>
{{
isQuerying
?
t
(
'
keyUsage.querying
'
)
:
t
(
'
keyUsage.query
'
)
}}
</button>
</div>
<p
class=
"text-xs text-gray-400 dark:text-dark-500 mt-3 text-center"
>
{{
t
(
'
keyUsage.privacyNote
'
)
}}
</p>
<!-- Date Range Picker -->
<div
v-if=
"showDatePicker"
class=
"mt-4"
>
<div
class=
"flex flex-wrap items-center gap-2 justify-center"
>
<span
class=
"text-xs text-gray-500 dark:text-dark-400"
>
{{
t
(
'
keyUsage.dateRange
'
)
}}
</span>
<button
v-for=
"range in dateRanges"
:key=
"range.key"
@
click=
"setDateRange(range.key)"
class=
"text-xs px-3 py-1.5 rounded-lg border transition-all"
:class=
"currentRange === range.key
? 'bg-primary-500 text-white border-primary-500'
: 'border-gray-200 bg-white text-gray-700 dark:border-dark-700 dark:bg-dark-900 dark:text-dark-200 hover:border-primary-300 dark:hover:border-dark-600'"
>
{{
range
.
label
}}
</button>
<div
v-if=
"currentRange === 'custom'"
class=
"flex items-center gap-2 ml-1"
>
<input
v-model=
"customStartDate"
type=
"date"
class=
"input-ring text-xs px-2 py-1.5 rounded-lg border border-gray-200 bg-white text-gray-900 dark:border-dark-700 dark:bg-dark-900 dark:text-white"
/>
<span
class=
"text-xs text-gray-400"
>
-
</span>
<input
v-model=
"customEndDate"
type=
"date"
class=
"input-ring text-xs px-2 py-1.5 rounded-lg border border-gray-200 bg-white text-gray-900 dark:border-dark-700 dark:bg-dark-900 dark:text-white"
/>
<button
@
click=
"queryKey"
class=
"text-xs px-3 py-1.5 rounded-lg bg-primary-500 text-white hover:bg-primary-600"
>
{{
t
(
'
keyUsage.apply
'
)
}}
</button>
</div>
</div>
</div>
</div>
<!-- Results Container -->
<div
v-if=
"showResults"
>
<!-- Loading Skeleton -->
<div
v-if=
"showLoading"
class=
"space-y-6"
>
<div
class=
"grid grid-cols-1 md:grid-cols-2 gap-6"
>
<div
class=
"rounded-2xl border border-gray-200 bg-white p-8 dark:border-dark-700 dark:bg-dark-900"
>
<div
class=
"skeleton h-5 w-24 mb-6"
></div>
<div
class=
"flex justify-center"
><div
class=
"skeleton w-44 h-44 rounded-full"
></div></div>
</div>
<div
class=
"rounded-2xl border border-gray-200 bg-white p-8 dark:border-dark-700 dark:bg-dark-900"
>
<div
class=
"skeleton h-5 w-24 mb-6"
></div>
<div
class=
"flex justify-center"
><div
class=
"skeleton w-44 h-44 rounded-full"
></div></div>
</div>
</div>
<div
class=
"rounded-2xl border border-gray-200 bg-white p-8 dark:border-dark-700 dark:bg-dark-900"
>
<div
class=
"skeleton h-5 w-32 mb-6"
></div>
<div
class=
"space-y-4"
>
<div
class=
"skeleton h-4 w-full"
></div>
<div
class=
"skeleton h-4 w-3/4"
></div>
<div
class=
"skeleton h-4 w-5/6"
></div>
<div
class=
"skeleton h-4 w-2/3"
></div>
</div>
</div>
</div>
<!-- Result Content -->
<div
v-else-if=
"resultData"
class=
"space-y-6"
>
<!-- Status Badge -->
<div
v-if=
"statusInfo"
class=
"fade-up flex items-center justify-center mb-2"
>
<div
class=
"inline-flex items-center gap-2 px-5 py-2.5 rounded-full border border-gray-200 bg-white/90 shadow-sm backdrop-blur-sm dark:border-dark-700 dark:bg-dark-900/90"
>
<span
class=
"w-2.5 h-2.5 rounded-full pulse-dot"
:class=
"statusInfo.isActive ? 'bg-emerald-500' : 'bg-rose-500'"
></span>
<span
class=
"text-sm font-medium text-gray-900 dark:text-white"
>
{{
statusInfo
.
label
}}
</span>
<span
class=
"text-xs text-gray-400 dark:text-dark-500"
>
|
</span>
<span
class=
"text-xs text-gray-500 dark:text-dark-400"
>
{{
statusInfo
.
statusText
}}
</span>
</div>
</div>
<!-- Ring Cards Grid -->
<div
v-if=
"ringItems.length > 0"
:class=
"ringGridClass"
>
<div
v-for=
"(ring, i) in ringItems"
:key=
"i"
class=
"fade-up rounded-2xl border border-gray-200 bg-white/90 p-8 backdrop-blur-sm transition-all duration-300 hover:shadow-lg dark:border-dark-700 dark:bg-dark-900/90"
:class=
"`fade-up-delay-$
{Math.min(i + 1, 4)}`"
>
<div
class=
"flex items-center justify-between mb-6"
>
<h3
class=
"text-sm font-semibold uppercase tracking-wider text-gray-500 dark:text-dark-400"
>
{{
ring
.
title
}}
</h3>
<!-- Clock icon -->
<svg
v-if=
"ring.iconType === 'clock'"
class=
"w-5 h-5 text-gray-400 dark:text-dark-500"
viewBox=
"0 0 24 24"
fill=
"none"
stroke=
"currentColor"
stroke-width=
"2"
stroke-linecap=
"round"
stroke-linejoin=
"round"
>
<circle
cx=
"12"
cy=
"12"
r=
"10"
/><polyline
points=
"12 6 12 12 16 14"
/>
</svg>
<!-- Calendar icon -->
<svg
v-else-if=
"ring.iconType === 'calendar'"
class=
"w-5 h-5 text-gray-400 dark:text-dark-500"
viewBox=
"0 0 24 24"
fill=
"none"
stroke=
"currentColor"
stroke-width=
"2"
stroke-linecap=
"round"
stroke-linejoin=
"round"
>
<rect
x=
"3"
y=
"4"
width=
"18"
height=
"18"
rx=
"2"
ry=
"2"
/><line
x1=
"16"
y1=
"2"
x2=
"16"
y2=
"6"
/><line
x1=
"8"
y1=
"2"
x2=
"8"
y2=
"6"
/><line
x1=
"3"
y1=
"10"
x2=
"21"
y2=
"10"
/>
</svg>
<!-- Dollar icon -->
<svg
v-else
class=
"w-5 h-5 text-gray-400 dark:text-dark-500"
viewBox=
"0 0 24 24"
fill=
"none"
stroke=
"currentColor"
stroke-width=
"2"
stroke-linecap=
"round"
stroke-linejoin=
"round"
>
<line
x1=
"12"
y1=
"1"
x2=
"12"
y2=
"23"
/><path
d=
"M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"
/>
</svg>
</div>
<div
class=
"flex justify-center"
>
<div
class=
"relative"
>
<svg
class=
"w-44 h-44"
viewBox=
"0 0 160 160"
>
<circle
cx=
"80"
cy=
"80"
r=
"68"
fill=
"none"
:stroke=
"ringTrackColor"
stroke-width=
"10"
/>
<circle
class=
"progress-ring"
cx=
"80"
cy=
"80"
r=
"68"
fill=
"none"
:stroke=
"`url(#ring-grad-$
{i})`"
stroke-width="10" stroke-linecap="round"
:stroke-dasharray="CIRCUMFERENCE.toFixed(2)"
:stroke-dashoffset="getRingOffset(ring)"
/>
<defs>
<linearGradient
:id=
"`ring-grad-$
{i}`" x1="0%" y1="0%" x2="100%" y2="100%">
<stop
offset=
"0%"
:stop-color=
"RING_GRADIENTS[i % 4].from"
/>
<stop
offset=
"100%"
:stop-color=
"RING_GRADIENTS[i % 4].to"
/>
</linearGradient>
</defs>
</svg>
<div
class=
"absolute inset-0 flex flex-col items-center justify-center"
>
<template
v-if=
"ring.isBalance"
>
<span
class=
"text-2xl font-bold tabular-nums"
:style=
"
{ color: RING_GRADIENTS[i % 4].from }">
{{
ring
.
amount
}}
</span>
</
template
>
<
template
v-else
>
<span
class=
"text-3xl font-bold tabular-nums text-gray-900 dark:text-white"
>
{{
displayPcts
[
i
]
??
0
}}
%
</span>
<span
class=
"text-xs text-gray-500 dark:text-dark-400 mt-0.5"
>
{{
t
(
'
keyUsage.used
'
)
}}
</span>
<span
class=
"text-sm font-semibold mt-1 tabular-nums"
:style=
"
{ color: RING_GRADIENTS[i % 4].from }"
>
{{
ring
.
amount
}}
</span>
</
template
>
</div>
</div>
</div>
</div>
</div>
<!-- Detail Card -->
<div
v-if=
"detailRows.length > 0"
class=
"fade-up fade-up-delay-3 rounded-2xl border border-gray-200 bg-white/90 backdrop-blur-sm overflow-hidden dark:border-dark-700 dark:bg-dark-900/90"
>
<div
class=
"px-8 py-5 border-b border-gray-200 dark:border-dark-700"
>
<h3
class=
"text-sm font-semibold uppercase tracking-wider text-gray-500 dark:text-dark-400"
>
{{ t('keyUsage.detailInfo') }}
</h3>
</div>
<div
class=
"divide-y divide-gray-100 dark:divide-dark-800"
>
<div
v-for=
"(row, i) in detailRows"
:key=
"i"
class=
"px-8 py-4 flex items-center justify-between"
>
<div
class=
"flex items-center gap-3"
>
<div
class=
"w-8 h-8 rounded-lg flex items-center justify-center"
:class=
"row.iconBg"
>
<svg
class=
"w-4 h-4"
:class=
"row.iconColor"
viewBox=
"0 0 24 24"
fill=
"none"
stroke=
"currentColor"
stroke-width=
"2"
stroke-linecap=
"round"
stroke-linejoin=
"round"
v-html=
"row.iconSvg"
></svg>
</div>
<span
class=
"text-sm text-gray-700 dark:text-dark-200"
>
{{ row.label }}
</span>
</div>
<span
class=
"text-sm font-semibold tabular-nums"
:class=
"row.valueClass || 'text-gray-900 dark:text-white'"
>
{{ row.value }}
</span>
</div>
</div>
</div>
<!-- Usage Stats Card -->
<div
v-if=
"usageStatCells.length > 0"
class=
"fade-up fade-up-delay-3 rounded-2xl border border-gray-200 bg-white/90 backdrop-blur-sm overflow-hidden dark:border-dark-700 dark:bg-dark-900/90"
>
<div
class=
"px-8 py-5 border-b border-gray-200 dark:border-dark-700"
>
<h3
class=
"text-sm font-semibold uppercase tracking-wider text-gray-500 dark:text-dark-400"
>
{{ t('keyUsage.tokenStats') }}
</h3>
</div>
<div
class=
"grid grid-cols-2 md:grid-cols-4 gap-px bg-gray-100 dark:bg-dark-800"
>
<div
v-for=
"(cell, i) in usageStatCells"
:key=
"i"
class=
"bg-white px-6 py-4 dark:bg-dark-900"
>
<div
class=
"text-xs text-gray-500 dark:text-dark-400 mb-1"
>
{{ cell.label }}
</div>
<div
class=
"text-sm font-semibold tabular-nums text-gray-900 dark:text-white"
>
{{ cell.value }}
</div>
</div>
</div>
</div>
<!-- Model Stats Table -->
<div
v-if=
"modelStats.length > 0"
class=
"fade-up fade-up-delay-4 rounded-2xl border border-gray-200 bg-white/90 backdrop-blur-sm overflow-hidden dark:border-dark-700 dark:bg-dark-900/90"
>
<div
class=
"px-8 py-5 border-b border-gray-200 dark:border-dark-700"
>
<h3
class=
"text-sm font-semibold uppercase tracking-wider text-gray-500 dark:text-dark-400"
>
{{ t('keyUsage.modelStats') }}
</h3>
</div>
<div
class=
"overflow-x-auto"
>
<table
class=
"w-full"
>
<thead>
<tr
class=
"border-b border-gray-200 bg-gray-50 dark:border-dark-700 dark:bg-dark-950"
>
<th
class=
"px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-dark-400"
>
{{ t('keyUsage.model') }}
</th>
<th
class=
"px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-dark-400"
>
{{ t('keyUsage.requests') }}
</th>
<th
class=
"px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-dark-400"
>
{{ t('keyUsage.inputTokens') }}
</th>
<th
class=
"px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-dark-400"
>
{{ t('keyUsage.outputTokens') }}
</th>
<th
class=
"px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-dark-400"
>
{{ t('keyUsage.totalTokens') }}
</th>
<th
class=
"px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-dark-400"
>
{{ t('keyUsage.cost') }}
</th>
</tr>
</thead>
<tbody>
<tr
v-for=
"(m, i) in modelStats"
:key=
"i"
class=
"border-b border-gray-100 last:border-b-0 dark:border-dark-800"
>
<td
class=
"px-4 py-3 text-sm font-medium whitespace-nowrap text-gray-900 dark:text-white"
>
{{ m.model || '-' }}
</td>
<td
class=
"px-4 py-3 text-sm tabular-nums text-right text-gray-700 dark:text-dark-200"
>
{{ fmtNum(m.requests) }}
</td>
<td
class=
"px-4 py-3 text-sm tabular-nums text-right text-gray-700 dark:text-dark-200"
>
{{ fmtNum(m.input_tokens) }}
</td>
<td
class=
"px-4 py-3 text-sm tabular-nums text-right text-gray-700 dark:text-dark-200"
>
{{ fmtNum(m.output_tokens) }}
</td>
<td
class=
"px-4 py-3 text-sm tabular-nums text-right text-gray-700 dark:text-dark-200"
>
{{ fmtNum(m.total_tokens) }}
</td>
<td
class=
"px-4 py-3 text-sm tabular-nums text-right font-medium text-gray-900 dark:text-white"
>
{{ usd(m.actual_cost != null ? m.actual_cost : m.cost) }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</main>
<!-- Footer (same pattern as HomeView) -->
<footer
class=
"relative z-10 border-t border-gray-200/50 px-6 py-8 dark:border-dark-800/50"
>
<div
class=
"mx-auto flex max-w-6xl flex-col items-center justify-center gap-4 text-center sm:flex-row sm:text-left"
>
<p
class=
"text-sm text-gray-500 dark:text-dark-400"
>
©
{{ currentYear }} {{ siteName }}. {{ t('home.footer.allRightsReserved') }}
</p>
<div
class=
"flex items-center gap-4"
>
<a
v-if=
"docUrl"
:href=
"docUrl"
target=
"_blank"
rel=
"noopener noreferrer"
class=
"text-sm text-gray-500 transition-colors hover:text-gray-700 dark:text-dark-400 dark:hover:text-white"
>
{{ t('home.docs') }}
</a>
<a
:href=
"githubUrl"
target=
"_blank"
rel=
"noopener noreferrer"
class=
"text-sm text-gray-500 transition-colors hover:text-gray-700 dark:text-dark-400 dark:hover:text-white"
>
GitHub
</a>
</div>
</div>
</footer>
</div>
</template>
<
script
setup
lang=
"ts"
>
import
{
ref
,
computed
,
onMounted
,
nextTick
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
}
from
'
@/stores
'
import
LocaleSwitcher
from
'
@/components/common/LocaleSwitcher.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
const
{
t
,
locale
}
=
useI18n
()
const
appStore
=
useAppStore
()
// ==================== Site Settings (same as HomeView) ====================
const
siteName
=
computed
(()
=>
appStore
.
cachedPublicSettings
?.
site_name
||
appStore
.
siteName
||
'
Sub2API
'
)
const
siteLogo
=
computed
(()
=>
appStore
.
cachedPublicSettings
?.
site_logo
||
appStore
.
siteLogo
||
''
)
const
docUrl
=
computed
(()
=>
appStore
.
cachedPublicSettings
?.
doc_url
||
appStore
.
docUrl
||
''
)
const
githubUrl
=
'
https://github.com/Wei-Shaw/sub2api
'
// ==================== Theme (same as HomeView) ====================
const
isDark
=
ref
(
document
.
documentElement
.
classList
.
contains
(
'
dark
'
))
function
toggleTheme
()
{
isDark
.
value
=
!
isDark
.
value
document
.
documentElement
.
classList
.
toggle
(
'
dark
'
,
isDark
.
value
)
localStorage
.
setItem
(
'
theme
'
,
isDark
.
value
?
'
dark
'
:
'
light
'
)
}
const
currentYear
=
computed
(()
=>
new
Date
().
getFullYear
())
// ==================== Key Query State ====================
const
apiKey
=
ref
(
''
)
const
keyVisible
=
ref
(
false
)
const
isQuerying
=
ref
(
false
)
const
showResults
=
ref
(
false
)
const
showLoading
=
ref
(
false
)
const
showDatePicker
=
ref
(
false
)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const
resultData
=
ref
<
any
>
(
null
)
// ==================== Date Range State ====================
type
DateRangeKey
=
'
today
'
|
'
7d
'
|
'
30d
'
|
'
custom
'
const
currentRange
=
ref
<
DateRangeKey
>
(
'
today
'
)
const
customStartDate
=
ref
(
''
)
const
customEndDate
=
ref
(
''
)
const
dateRanges
=
computed
(()
=>
[
{
key
:
'
today
'
as
const
,
label
:
t
(
'
keyUsage.dateRangeToday
'
)
},
{
key
:
'
7d
'
as
const
,
label
:
t
(
'
keyUsage.dateRange7d
'
)
},
{
key
:
'
30d
'
as
const
,
label
:
t
(
'
keyUsage.dateRange30d
'
)
},
{
key
:
'
custom
'
as
const
,
label
:
t
(
'
keyUsage.dateRangeCustom
'
)
},
])
function
setDateRange
(
key
:
DateRangeKey
)
{
currentRange
.
value
=
key
if
(
key
!==
'
custom
'
)
{
queryKey
()
}
}
function
getDateParams
():
string
{
const
now
=
new
Date
()
const
fmt
=
(
d
:
Date
)
=>
d
.
toISOString
().
split
(
'
T
'
)[
0
]
if
(
currentRange
.
value
===
'
custom
'
)
{
if
(
customStartDate
.
value
&&
customEndDate
.
value
)
{
return
`start_date=
${
customStartDate
.
value
}
&end_date=
${
customEndDate
.
value
}
`
}
return
''
}
const
end
=
fmt
(
now
)
let
start
:
string
switch
(
currentRange
.
value
)
{
case
'
today
'
:
start
=
end
;
break
case
'
7d
'
:
start
=
fmt
(
new
Date
(
now
.
getTime
()
-
7
*
86400000
));
break
case
'
30d
'
:
start
=
fmt
(
new
Date
(
now
.
getTime
()
-
30
*
86400000
));
break
default
:
start
=
fmt
(
new
Date
(
now
.
getTime
()
-
30
*
86400000
))
}
return
`start_date=
${
start
}
&end_date=
${
end
}
`
}
// ==================== Ring Animation ====================
const
CIRCUMFERENCE
=
2
*
Math
.
PI
*
68
const
RING_GRADIENTS
=
[
{
from
:
'
#14b8a6
'
,
to
:
'
#5eead4
'
},
{
from
:
'
#6366F1
'
,
to
:
'
#A5B4FC
'
},
{
from
:
'
#10B981
'
,
to
:
'
#6EE7B7
'
},
{
from
:
'
#F59E0B
'
,
to
:
'
#FCD34D
'
},
]
const
ringAnimated
=
ref
(
false
)
const
displayPcts
=
ref
<
number
[]
>
([])
const
ringTrackColor
=
computed
(()
=>
isDark
.
value
?
'
#222222
'
:
'
#F0F0EE
'
)
interface
RingItem
{
title
:
string
pct
:
number
amount
:
string
isBalance
?:
boolean
iconType
:
'
clock
'
|
'
calendar
'
|
'
dollar
'
}
function
getRingOffset
(
ring
:
RingItem
):
number
{
if
(
!
ringAnimated
.
value
)
return
CIRCUMFERENCE
if
(
ring
.
isBalance
)
return
0
return
CIRCUMFERENCE
-
(
Math
.
min
(
ring
.
pct
,
100
)
/
100
)
*
CIRCUMFERENCE
}
function
triggerRingAnimation
(
items
:
RingItem
[])
{
ringAnimated
.
value
=
false
displayPcts
.
value
=
items
.
map
(()
=>
0
)
nextTick
(()
=>
{
requestAnimationFrame
(()
=>
{
setTimeout
(()
=>
{
ringAnimated
.
value
=
true
// Animate percentage numbers
const
duration
=
1000
const
startTime
=
performance
.
now
()
const
targets
=
items
.
map
(
item
=>
item
.
isBalance
?
0
:
item
.
pct
)
function
tick
()
{
const
elapsed
=
performance
.
now
()
-
startTime
const
p
=
Math
.
min
(
elapsed
/
duration
,
1
)
const
ease
=
1
-
Math
.
pow
(
1
-
p
,
3
)
displayPcts
.
value
=
targets
.
map
(
target
=>
Math
.
round
(
ease
*
target
))
if
(
p
<
1
)
requestAnimationFrame
(
tick
)
}
requestAnimationFrame
(
tick
)
},
50
)
})
})
}
// ==================== Computed Data ====================
const
statusInfo
=
computed
(()
=>
{
const
data
=
resultData
.
value
if
(
!
data
)
return
null
if
(
data
.
mode
===
'
quota_limited
'
)
{
const
isValid
=
data
.
isValid
!==
false
const
statusMap
:
Record
<
string
,
string
>
=
{
active
:
'
Active
'
,
quota_exhausted
:
'
Quota Exhausted
'
,
expired
:
'
Expired
'
,
}
return
{
label
:
t
(
'
keyUsage.quotaMode
'
),
statusText
:
statusMap
[
data
.
status
]
||
data
.
status
||
'
Unknown
'
,
isActive
:
isValid
&&
data
.
status
===
'
active
'
,
}
}
return
{
label
:
data
.
planName
||
t
(
'
keyUsage.walletBalance
'
),
statusText
:
'
Active
'
,
isActive
:
true
,
}
})
const
ringItems
=
computed
<
RingItem
[]
>
(()
=>
{
const
data
=
resultData
.
value
if
(
!
data
)
return
[]
const
items
:
RingItem
[]
=
[]
if
(
data
.
mode
===
'
quota_limited
'
)
{
if
(
data
.
quota
)
{
const
pct
=
data
.
quota
.
limit
>
0
?
Math
.
min
(
Math
.
round
((
data
.
quota
.
used
/
data
.
quota
.
limit
)
*
100
),
100
)
:
0
items
.
push
({
title
:
t
(
'
keyUsage.totalQuota
'
),
pct
,
amount
:
`
${
usd
(
data
.
quota
.
used
)}
/
${
usd
(
data
.
quota
.
limit
)}
`
,
iconType
:
'
dollar
'
})
}
if
(
data
.
rate_limits
)
{
const
windowLabels
:
Record
<
string
,
string
>
=
{
'
5h
'
:
t
(
'
keyUsage.limit5h
'
),
'
1d
'
:
t
(
'
keyUsage.limitDaily
'
),
'
7d
'
:
t
(
'
keyUsage.limit7d
'
)
}
const
windowIcons
:
Record
<
string
,
'
clock
'
|
'
calendar
'
>
=
{
'
5h
'
:
'
clock
'
,
'
1d
'
:
'
calendar
'
,
'
7d
'
:
'
calendar
'
}
for
(
const
rl
of
data
.
rate_limits
)
{
const
pct
=
rl
.
limit
>
0
?
Math
.
min
(
Math
.
round
((
rl
.
used
/
rl
.
limit
)
*
100
),
100
)
:
0
items
.
push
({
title
:
windowLabels
[
rl
.
window
]
||
rl
.
window
,
pct
,
amount
:
`
${
usd
(
rl
.
used
)}
/
${
usd
(
rl
.
limit
)}
`
,
iconType
:
windowIcons
[
rl
.
window
]
||
'
clock
'
,
})
}
}
}
else
{
if
(
data
.
subscription
)
{
const
sub
=
data
.
subscription
const
limits
=
[
{
label
:
t
(
'
keyUsage.limitDaily
'
),
usage
:
sub
.
daily_usage_usd
,
limit
:
sub
.
daily_limit_usd
},
{
label
:
t
(
'
keyUsage.limitWeekly
'
),
usage
:
sub
.
weekly_usage_usd
,
limit
:
sub
.
weekly_limit_usd
},
{
label
:
t
(
'
keyUsage.limitMonthly
'
),
usage
:
sub
.
monthly_usage_usd
,
limit
:
sub
.
monthly_limit_usd
},
]
for
(
const
l
of
limits
)
{
if
(
l
.
limit
!=
null
&&
l
.
limit
>
0
)
{
const
pct
=
Math
.
min
(
Math
.
round
((
l
.
usage
/
l
.
limit
)
*
100
),
100
)
items
.
push
({
title
:
l
.
label
,
pct
,
amount
:
`
${
usd
(
l
.
usage
)}
/
${
usd
(
l
.
limit
)}
`
,
iconType
:
'
calendar
'
})
}
}
}
if
(
!
data
.
subscription
&&
data
.
balance
!=
null
)
{
items
.
push
({
title
:
t
(
'
keyUsage.walletBalance
'
),
pct
:
0
,
amount
:
usd
(
data
.
balance
),
isBalance
:
true
,
iconType
:
'
dollar
'
})
}
}
return
items
})
const
ringGridClass
=
computed
(()
=>
{
const
len
=
ringItems
.
value
.
length
if
(
len
===
1
)
return
'
grid grid-cols-1 max-w-md mx-auto gap-6
'
if
(
len
===
2
)
return
'
grid grid-cols-1 md:grid-cols-2 gap-6
'
return
'
grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6
'
})
interface
DetailRow
{
iconBg
:
string
iconColor
:
string
iconSvg
:
string
label
:
string
value
:
string
valueClass
:
string
}
function
getUsageColor
(
pct
:
number
):
string
{
if
(
pct
>
90
)
return
'
text-rose-500
'
if
(
pct
>
70
)
return
'
text-amber-500
'
return
'
text-emerald-500
'
}
const
detailRows
=
computed
<
DetailRow
[]
>
(()
=>
{
const
data
=
resultData
.
value
if
(
!
data
)
return
[]
const
rows
:
DetailRow
[]
=
[]
const
ICON_SHIELD
=
'
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
'
const
ICON_CALENDAR
=
'
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>
'
const
ICON_DOLLAR
=
'
<line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>
'
const
ICON_CHECK
=
'
<polyline points="20 6 9 17 4 12"/>
'
if
(
data
.
mode
===
'
quota_limited
'
)
{
if
(
data
.
quota
)
{
const
remainColor
=
data
.
quota
.
remaining
<=
0
?
'
text-rose-500
'
:
data
.
quota
.
remaining
<
data
.
quota
.
limit
*
0.1
?
'
text-amber-500
'
:
'
text-emerald-500
'
rows
.
push
({
iconBg
:
'
bg-emerald-500/10
'
,
iconColor
:
'
text-emerald-500
'
,
iconSvg
:
ICON_SHIELD
,
label
:
t
(
'
keyUsage.remainingQuota
'
),
value
:
usd
(
data
.
quota
.
remaining
),
valueClass
:
remainColor
,
})
}
if
(
data
.
expires_at
)
{
const
daysLeft
=
data
.
days_until_expiry
let
expiryStr
=
formatDate
(
data
.
expires_at
)
if
(
daysLeft
!=
null
)
{
expiryStr
+=
daysLeft
>
0
?
`
${
t
(
'
keyUsage.daysLeft
'
,
{
days
:
daysLeft
})}
`
:
daysLeft
===
0
?
`
${
t
(
'
keyUsage.todayExpires
'
)}
`
:
''
}
rows
.
push
({
iconBg
:
'
bg-amber-500/10
'
,
iconColor
:
'
text-amber-500
'
,
iconSvg
:
ICON_CALENDAR
,
label
:
t
(
'
keyUsage.expiresAt
'
),
value
:
expiryStr
,
valueClass
:
''
,
})
}
if
(
data
.
rate_limits
)
{
const
windowMap
:
Record
<
string
,
string
>
=
{
'
5h
'
:
'
5H
'
,
'
1d
'
:
locale
.
value
===
'
zh
'
?
'
日
'
:
'
D
'
,
'
7d
'
:
'
7D
'
}
for
(
const
rl
of
data
.
rate_limits
)
{
const
pct
=
rl
.
limit
>
0
?
(
rl
.
used
/
rl
.
limit
)
*
100
:
0
rows
.
push
({
iconBg
:
'
bg-primary-500/10
'
,
iconColor
:
'
text-primary-500
'
,
iconSvg
:
ICON_DOLLAR
,
label
:
`
${
t
(
'
keyUsage.usedQuota
'
)}
(
${
windowMap
[
rl
.
window
]
||
rl
.
window
}
)`
,
value
:
`
${
usd
(
rl
.
used
)}
/
${
usd
(
rl
.
limit
)}
`
,
valueClass
:
getUsageColor
(
pct
),
})
}
}
}
else
{
rows
.
push
({
iconBg
:
'
bg-emerald-500/10
'
,
iconColor
:
'
text-emerald-500
'
,
iconSvg
:
ICON_CHECK
,
label
:
t
(
'
keyUsage.subscriptionType
'
),
value
:
data
.
planName
||
t
(
'
keyUsage.walletBalance
'
),
valueClass
:
''
,
})
if
(
data
.
subscription
)
{
const
sub
=
data
.
subscription
if
(
sub
.
daily_limit_usd
>
0
)
{
const
pct
=
(
sub
.
daily_usage_usd
/
sub
.
daily_limit_usd
)
*
100
rows
.
push
({
iconBg
:
'
bg-primary-500/10
'
,
iconColor
:
'
text-primary-500
'
,
iconSvg
:
ICON_DOLLAR
,
label
:
`
${
t
(
'
keyUsage.usedQuota
'
)}
(
${
locale
.
value
===
'
zh
'
?
'
日
'
:
'
D
'
}
)`
,
value
:
`
${
usd
(
sub
.
daily_usage_usd
)}
/
${
usd
(
sub
.
daily_limit_usd
)}
`
,
valueClass
:
getUsageColor
(
pct
),
})
}
if
(
sub
.
weekly_limit_usd
>
0
)
{
const
pct
=
(
sub
.
weekly_usage_usd
/
sub
.
weekly_limit_usd
)
*
100
rows
.
push
({
iconBg
:
'
bg-indigo-500/10
'
,
iconColor
:
'
text-indigo-500
'
,
iconSvg
:
ICON_DOLLAR
,
label
:
`
${
t
(
'
keyUsage.usedQuota
'
)}
(
${
locale
.
value
===
'
zh
'
?
'
周
'
:
'
W
'
}
)`
,
value
:
`
${
usd
(
sub
.
weekly_usage_usd
)}
/
${
usd
(
sub
.
weekly_limit_usd
)}
`
,
valueClass
:
getUsageColor
(
pct
),
})
}
if
(
sub
.
monthly_limit_usd
>
0
)
{
const
pct
=
(
sub
.
monthly_usage_usd
/
sub
.
monthly_limit_usd
)
*
100
rows
.
push
({
iconBg
:
'
bg-emerald-500/10
'
,
iconColor
:
'
text-emerald-500
'
,
iconSvg
:
ICON_DOLLAR
,
label
:
`
${
t
(
'
keyUsage.usedQuota
'
)}
(
${
locale
.
value
===
'
zh
'
?
'
月
'
:
'
M
'
}
)`
,
value
:
`
${
usd
(
sub
.
monthly_usage_usd
)}
/
${
usd
(
sub
.
monthly_limit_usd
)}
`
,
valueClass
:
getUsageColor
(
pct
),
})
}
if
(
sub
.
expires_at
)
{
rows
.
push
({
iconBg
:
'
bg-amber-500/10
'
,
iconColor
:
'
text-amber-500
'
,
iconSvg
:
ICON_CALENDAR
,
label
:
t
(
'
keyUsage.subscriptionExpires
'
),
value
:
formatDate
(
sub
.
expires_at
),
valueClass
:
''
,
})
}
}
const
remainColor
=
data
.
remaining
!=
null
?
(
data
.
remaining
<=
0
?
'
text-rose-500
'
:
data
.
remaining
<
10
?
'
text-amber-500
'
:
'
text-emerald-500
'
)
:
''
rows
.
push
({
iconBg
:
'
bg-emerald-500/10
'
,
iconColor
:
'
text-emerald-500
'
,
iconSvg
:
ICON_SHIELD
,
label
:
t
(
'
keyUsage.remainingQuota
'
),
value
:
data
.
remaining
!=
null
?
usd
(
data
.
remaining
)
:
'
-
'
,
valueClass
:
remainColor
,
})
}
return
rows
})
interface
StatCell
{
label
:
string
value
:
string
}
const
usageStatCells
=
computed
<
StatCell
[]
>
(()
=>
{
const
usage
=
resultData
.
value
?.
usage
if
(
!
usage
)
return
[]
const
today
=
usage
.
today
||
{}
const
total
=
usage
.
total
||
{}
return
[
{
label
:
t
(
'
keyUsage.todayRequests
'
),
value
:
fmtNum
(
today
.
requests
)
},
{
label
:
t
(
'
keyUsage.todayTokens
'
),
value
:
fmtNum
(
today
.
total_tokens
)
},
{
label
:
t
(
'
keyUsage.todayCost
'
),
value
:
usd
(
today
.
actual_cost
)
},
{
label
:
t
(
'
keyUsage.rpmTpm
'
),
value
:
`
${
usage
.
rpm
||
0
}
/
${
usage
.
tpm
||
0
}
`
},
{
label
:
t
(
'
keyUsage.totalRequests
'
),
value
:
fmtNum
(
total
.
requests
)
},
{
label
:
t
(
'
keyUsage.totalTokensLabel
'
),
value
:
fmtNum
(
total
.
total_tokens
)
},
{
label
:
t
(
'
keyUsage.totalCost
'
),
value
:
usd
(
total
.
actual_cost
)
},
{
label
:
t
(
'
keyUsage.avgDuration
'
),
value
:
usage
.
average_duration_ms
?
`
${
Math
.
round
(
usage
.
average_duration_ms
)}
ms`
:
'
-
'
},
]
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const
modelStats
=
computed
<
any
[]
>
(()
=>
resultData
.
value
?.
model_stats
||
[])
// ==================== Utility Functions ====================
function
usd
(
value
:
number
|
null
|
undefined
):
string
{
if
(
value
==
null
||
value
<
0
)
return
'
-
'
return
'
$
'
+
Number
(
value
).
toFixed
(
2
)
}
function
fmtNum
(
val
:
number
|
null
|
undefined
):
string
{
if
(
val
==
null
)
return
'
-
'
return
val
.
toLocaleString
()
}
function
formatDate
(
iso
:
string
|
null
|
undefined
):
string
{
if
(
!
iso
)
return
'
-
'
const
d
=
new
Date
(
iso
)
const
loc
=
locale
.
value
===
'
zh
'
?
'
zh-CN
'
:
'
en-US
'
return
d
.
toLocaleDateString
(
loc
,
{
year
:
'
numeric
'
,
month
:
'
long
'
,
day
:
'
numeric
'
})
}
// ==================== API Query ====================
async
function
fetchUsage
(
key
:
string
)
{
const
dateParams
=
getDateParams
()
const
url
=
'
/v1/usage
'
+
(
dateParams
?
'
?
'
+
dateParams
:
''
)
const
res
=
await
fetch
(
url
,
{
headers
:
{
'
Authorization
'
:
'
Bearer
'
+
key
},
})
if
(
!
res
.
ok
)
{
const
body
=
await
res
.
json
().
catch
(()
=>
null
)
const
msg
=
body
?.
error
?.
message
||
body
?.
message
||
`
${
t
(
'
keyUsage.queryFailed
'
)}
(
${
res
.
status
}
)`
throw
new
Error
(
msg
)
}
return
await
res
.
json
()
}
async
function
queryKey
()
{
if
(
isQuerying
.
value
)
return
const
key
=
apiKey
.
value
.
trim
()
if
(
!
key
)
{
appStore
.
showInfo
(
t
(
'
keyUsage.enterApiKey
'
))
return
}
isQuerying
.
value
=
true
showResults
.
value
=
true
showLoading
.
value
=
true
resultData
.
value
=
null
try
{
const
data
=
await
fetchUsage
(
key
)
resultData
.
value
=
data
showLoading
.
value
=
false
showDatePicker
.
value
=
true
// Trigger ring animations after DOM update
nextTick
(()
=>
{
triggerRingAnimation
(
ringItems
.
value
)
})
appStore
.
showSuccess
(
t
(
'
keyUsage.querySuccess
'
))
}
catch
(
err
)
{
showResults
.
value
=
false
showLoading
.
value
=
false
appStore
.
showError
((
err
as
Error
).
message
||
t
(
'
keyUsage.queryFailedRetry
'
))
}
finally
{
isQuerying
.
value
=
false
}
}
// ==================== Lifecycle ====================
function
initTheme
()
{
const
savedTheme
=
localStorage
.
getItem
(
'
theme
'
)
if
(
savedTheme
===
'
dark
'
||
(
!
savedTheme
&&
window
.
matchMedia
(
'
(prefers-color-scheme: dark)
'
).
matches
))
{
isDark
.
value
=
true
document
.
documentElement
.
classList
.
add
(
'
dark
'
)
}
}
onMounted
(()
=>
{
initTheme
()
if
(
!
appStore
.
publicSettingsLoaded
)
{
appStore
.
fetchPublicSettings
()
}
})
</
script
>
<
style
scoped
>
/* Input focus ring */
.input-ring
{
transition
:
box-shadow
0.2s
ease
,
border-color
0.2s
ease
;
}
.input-ring
:focus
{
box-shadow
:
0
0
0
3px
rgba
(
20
,
184
,
166
,
0.2
);
border-color
:
#14b8a6
;
outline
:
none
;
}
/* Ring animation */
.progress-ring
{
transition
:
stroke-dashoffset
1.2s
cubic-bezier
(
0.4
,
0
,
0.2
,
1
);
transform
:
rotate
(
-90deg
);
transform-origin
:
50%
50%
;
}
/* Skeleton loading */
@keyframes
shimmer-kv
{
0
%
{
background-position
:
-200%
0
;
}
100
%
{
background-position
:
200%
0
;
}
}
.skeleton
{
background
:
linear-gradient
(
90deg
,
#e5e7eb
25%
,
#f3f4f6
50%
,
#e5e7eb
75%
);
background-size
:
200%
100%
;
animation
:
shimmer-kv
1.8s
ease-in-out
infinite
;
border-radius
:
8px
;
}
:global
(
.dark
)
.skeleton
{
background
:
linear-gradient
(
90deg
,
#334155
25%
,
#1e293b
50%
,
#334155
75%
);
background-size
:
200%
100%
;
}
/* Fade up animation */
@keyframes
fade-up-kv
{
from
{
opacity
:
0
;
transform
:
translateY
(
16px
);
}
to
{
opacity
:
1
;
transform
:
translateY
(
0
);
}
}
.fade-up
{
animation
:
fade-up-kv
0.5s
cubic-bezier
(
0.4
,
0
,
0.2
,
1
)
forwards
;
}
.fade-up-delay-1
{
animation-delay
:
0.1s
;
opacity
:
0
;
}
.fade-up-delay-2
{
animation-delay
:
0.2s
;
opacity
:
0
;
}
.fade-up-delay-3
{
animation-delay
:
0.3s
;
opacity
:
0
;
}
.fade-up-delay-4
{
animation-delay
:
0.4s
;
opacity
:
0
;
}
/* Pulse dot */
@keyframes
pulse-dot-kv
{
0
%,
100
%
{
opacity
:
1
;
box-shadow
:
0
0
0
0
currentColor
;
}
50
%
{
opacity
:
0.6
;
box-shadow
:
0
0
8px
2px
currentColor
;
}
}
.pulse-dot
{
animation
:
pulse-dot-kv
2s
ease-in-out
infinite
;
}
/* Tabular nums */
.tabular-nums
{
font-variant-numeric
:
tabular-nums
;
letter-spacing
:
-0.02em
;
}
</
style
>
frontend/vite.config.ts
View file @
d4f6ad72
...
...
@@ -115,6 +115,10 @@ export default defineConfig(({ mode }) => {
target
:
backendUrl
,
changeOrigin
:
true
},
'
/v1
'
:
{
target
:
backendUrl
,
changeOrigin
:
true
},
'
/setup
'
:
{
target
:
backendUrl
,
changeOrigin
:
true
...
...
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