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
5763f5ce
Commit
5763f5ce
authored
Dec 25, 2025
by
ianshaw
Browse files
style(frontend): 统一 Views 模块代码风格
- 移除语句末尾分号,规范代码格式 - 优化组件结构和类型定义 - 改进视图文档和示例 - 提升代码一致性
parent
f79b0f0f
Changes
25
Hide whitespace changes
Inline
Side-by-side
frontend/src/views/user/KeysView.vue
View file @
5763f5ce
...
...
@@ -10,17 +10,27 @@
:title=
"t('common.refresh')"
>
<svg
:class=
"['w-5 h-5', loading ? 'animate-spin' : '']"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
:class=
"['h-5 w-5', loading ? 'animate-spin' : '']"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
</button>
<button
@
click=
"showCreateModal = true"
class=
"btn btn-primary"
>
<svg
class=
"w-5 h-5 mr-2"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<button
@
click=
"showCreateModal = true"
class=
"btn btn-primary"
>
<svg
class=
"mr-2 h-5 w-5"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M12 4.5v15m7.5-7.5h-15"
/>
</svg>
{{
t
(
'
keys.createKey
'
)
}}
...
...
@@ -29,11 +39,7 @@
<!-- API Keys Table -->
<div
class=
"card overflow-hidden"
>
<DataTable
:columns=
"columns"
:data=
"apiKeys"
:loading=
"loading"
>
<DataTable
:columns=
"columns"
:data=
"apiKeys"
:loading=
"loading"
>
<template
#cell-key
="
{ value, row }">
<div
class=
"flex items-center gap-2"
>
<code
class=
"code text-xs"
>
...
...
@@ -41,16 +47,37 @@
</code>
<button
@
click=
"copyToClipboard(value, row.id)"
class=
"p-1 rounded-lg hover:bg-gray-100 dark:hover:bg-dark-700 transition-colors"
:class=
"copiedKeyId === row.id ? 'text-green-500' : 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'"
class=
"rounded-lg p-1 transition-colors hover:bg-gray-100 dark:hover:bg-dark-700"
:class=
"
copiedKeyId === row.id
? 'text-green-500'
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
"
:title=
"copiedKeyId === row.id ? t('keys.copied') : t('keys.copyToClipboard')"
>
<svg
v-if=
"copiedKeyId === row.id"
class=
"w-4 h-4"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"2"
>
<svg
v-if=
"copiedKeyId === row.id"
class=
"h-4 w-4"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M5 13l4 4L19 7"
/>
</svg>
<svg
v-else
class=
"w-4 h-4"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184"
/>
<svg
v-else
class=
"h-4 w-4"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184"
/>
</svg>
</button>
</div>
...
...
@@ -61,11 +88,11 @@
</
template
>
<
template
#cell-group=
"{ row }"
>
<div
class=
"
relative
group/dropdown"
>
<div
class=
"group/dropdown
relative
"
>
<button
:ref=
"(el) => setGroupButtonRef(row.id, el)"
@
click=
"openGroupSelector(row)"
class=
"
flex
items-center gap-2
px-2 py-1 -m
x-2
-m
y-1
rounded-lg
hover:bg-gray-100 dark:hover:bg-dark-700
transition-all duration-200 cursor-pointer
"
class=
"
-mx-2 -my-1 flex cursor-pointer
items-center gap-2
rounded-lg p
x-2
p
y-1
transition-all duration-200
hover:bg-gray-100 dark:hover:bg-dark-700"
:title=
"t('keys.clickToChangeGroup')"
>
<GroupBadge
...
...
@@ -75,9 +102,21 @@
:subscription-type=
"row.group.subscription_type"
:rate-multiplier=
"row.group.rate_multiplier"
/>
<span
v-else
class=
"text-sm text-gray-400 dark:text-dark-500"
>
{{
t
(
'
keys.noGroup
'
)
}}
</span>
<svg
class=
"w-3.5 h-3.5 text-gray-400 opacity-0 group-hover/dropdown:opacity-100 transition-opacity"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M8.25 15L12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9"
/>
<span
v-else
class=
"text-sm text-gray-400 dark:text-dark-500"
>
{{
t
(
'
keys.noGroup
'
)
}}
</span>
<svg
class=
"h-3.5 w-3.5 text-gray-400 opacity-0 transition-opacity group-hover/dropdown:opacity-100"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M8.25 15L12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9"
/>
</svg>
</button>
</div>
...
...
@@ -91,7 +130,7 @@
$
{{
(
usageStats
[
row
.
id
]?.
today_actual_cost
??
0
).
toFixed
(
4
)
}}
</span>
</div>
<div
class=
"flex items-center gap-1.5
mt-0.5
"
>
<div
class=
"
mt-0.5
flex items-center gap-1.5"
>
<span
class=
"text-gray-500 dark:text-gray-400"
>
{{
t
(
'
keys.total
'
)
}}
:
</span>
<span
class=
"font-medium text-gray-900 dark:text-white"
>
$
{{
(
usageStats
[
row
.
id
]?.
total_actual_cost
??
0
).
toFixed
(
4
)
}}
...
...
@@ -101,14 +140,7 @@
</
template
>
<
template
#cell-status=
"{ value }"
>
<span
:class=
"[
'badge',
value === 'active'
? 'badge-success'
: 'badge-gray'
]"
>
<span
:class=
"['badge', value === 'active' ? 'badge-success' : 'badge-gray']"
>
{{
value
}}
</span>
</
template
>
...
...
@@ -122,61 +154,121 @@
<!-- Use Key Button -->
<button
@
click=
"openUseKeyModal(row)"
class=
"
p-2
rounded-lg
hover:bg-green
-50
dark:hover:bg-green-900/20 text-gray-500
hover:
text
-green-
60
0 dark:hover:text-green-400
transition-colors
"
class=
"rounded-lg
p-2 text-gray
-50
0
transition-colors hover:bg-green-50 hover:text-green-600 dark:
hover:
bg
-green-
900/2
0 dark:hover:text-green-400"
:title=
"t('keys.useKey')"
>
<svg
class=
"w-4 h-4"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M6.75 7.5l3 2.25-3 2.25m4.5 0h3m-9 8.25h13.5A2.25 2.25 0 0021 18V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v12a2.25 2.25 0 002.25 2.25z"
/>
<svg
class=
"h-4 w-4"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M6.75 7.5l3 2.25-3 2.25m4.5 0h3m-9 8.25h13.5A2.25 2.25 0 0021 18V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v12a2.25 2.25 0 002.25 2.25z"
/>
</svg>
</button>
<!-- Import to CC Switch Button -->
<button
@
click=
"importToCcswitch(row.key)"
class=
"
p-2
rounded-lg
hover:bg-blue-50 dark:hover:bg-blue-900/20 text-gray-500
hover:
text
-blue-
60
0 dark:hover:text-blue-400
transition-colors
"
class=
"rounded-lg
p-2 text-gray-500 transition-colors hover:bg-blue-50 hover:text-blue-600 dark:
hover:
bg
-blue-
900/2
0 dark:hover:text-blue-400"
:title=
"t('keys.importToCcSwitch')"
>
<svg
class=
"w-4 h-4"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"
/>
<svg
class=
"h-4 w-4"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"
/>
</svg>
</button>
<!-- Toggle Status Button -->
<button
@
click=
"toggleKeyStatus(row)"
:class=
"[
'
p-2
rounded-lg transition-colors',
'rounded-lg
p-2
transition-colors',
row.status === 'active'
? 'hover:bg-yellow-50
dark:
hover:
bg
-yellow-
9
00
/20 text-gray-500
hover:
text
-yellow-
60
0 dark:hover:text-yellow-400'
: '
hover:bg-green-50 dark:hover:bg-green-900/20 text-gray-500
hover:
text
-green-
60
0 dark:hover:text-green-400'
? '
text-gray-500
hover:bg-yellow-50 hover:
text
-yellow-
6
00
dark:
hover:
bg
-yellow-
900/2
0 dark:hover:text-yellow-400'
: '
text-gray-500 hover:bg-green-50 hover:text-green-600 dark:
hover:
bg
-green-
900/2
0 dark:hover:text-green-400'
]"
:title=
"row.status === 'active' ? t('keys.disable') : t('keys.enable')"
>
<svg
v-if=
"row.status === 'active'"
class=
"w-4 h-4"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
/>
<svg
v-if=
"row.status === 'active'"
class=
"h-4 w-4"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
/>
</svg>
<svg
v-else
class=
"w-4 h-4"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
<svg
v-else
class=
"h-4 w-4"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</button>
<!-- Edit Button -->
<button
@
click=
"editKey(row)"
class=
"
p-2
rounded-lg
hover:bg
-gray-
1
00
dark:hover:bg-dark-700 text
-gray-
5
00 hover:text-primary-600 dark:hover:
text-primary-400 transition-colors
"
class=
"rounded-lg
p-2 text
-gray-
5
00
transition-colors hover:bg
-gray-
1
00 hover:text-primary-600 dark:hover:
bg-dark-700 dark:hover:text-primary-400
"
title=
"Edit"
>
<svg
class=
"w-4 h-4"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
/>
<svg
class=
"h-4 w-4"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
/>
</svg>
</button>
<!-- Delete Button -->
<button
@
click=
"confirmDelete(row)"
class=
"
p-2
rounded-lg
hover:bg-red
-50
dark:hover:bg-red-900/20 text-gray-500
hover:
text
-red-
60
0 dark:hover:text-red-400
transition-colors
"
class=
"rounded-lg
p-2 text-gray
-50
0
transition-colors hover:bg-red-50 hover:text-red-600 dark:
hover:
bg
-red-
900/2
0 dark:hover:text-red-400"
title=
"Delete"
>
<svg
class=
"w-4 h-4"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
/>
<svg
class=
"h-4 w-4"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
/>
</svg>
</button>
</div>
...
...
@@ -292,28 +384,37 @@
</div>
<div
class=
"flex justify-end gap-3 pt-4"
>
<button
@
click=
"closeModals"
type=
"button"
class=
"btn btn-secondary"
>
<button
@
click=
"closeModals"
type=
"button"
class=
"btn btn-secondary"
>
{{ t('common.cancel') }}
</button>
<button
type=
"submit"
:disabled=
"submitting"
class=
"btn btn-primary"
>
<button
type=
"submit"
:disabled=
"submitting"
class=
"btn btn-primary"
>
<svg
v-if=
"submitting"
class=
"
animate-spin
-ml-1 mr-2 h-4 w-4"
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>
<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>
{{ submitting ? t('keys.saving') : (showEditModal ? t('common.update') : t('common.create')) }}
{{
submitting
? t('keys.saving')
: showEditModal
? t('common.update')
: t('common.create')
}}
</button>
</div>
</form>
...
...
@@ -345,17 +446,18 @@
<div
v-if=
"groupSelectorKeyId !== null && dropdownPosition"
ref=
"dropdownRef"
class=
"fixed z-[9999] w-64 rounded-xl bg-white
dark:bg-dark-800
shadow-lg ring-1 ring-black/5 d
ark:ring-white/10 overflow-hidden animate-in fade-in slide-in-from-top-2 duration-20
0"
class=
"
animate-in fade-in slide-in-from-top-2
fixed z-[9999] w-64
overflow-hidden
rounded-xl bg-white shadow-lg ring-1 ring-black/5 d
uration-200 dark:bg-dark-800 dark:ring-white/1
0"
:style=
"{ top: dropdownPosition.top + 'px', left: dropdownPosition.left + 'px' }"
>
<div
class=
"
p-1.5
max-h-64 overflow-y-auto"
>
<div
class=
"max-h-64 overflow-y-auto
p-1.5
"
>
<button
v-for=
"option in groupOptions"
:key=
"option.value ?? 'null'"
@
click=
"changeGroup(selectedKeyForGroup!, option.value)"
:class=
"[
'w-full flex items-center justify-between px-3 py-2 rounded-lg text-sm transition-colors',
(selectedKeyForGroup?.group_id === option.value || (!selectedKeyForGroup?.group_id && option.value === null))
'flex w-full items-center justify-between rounded-lg px-3 py-2 text-sm transition-colors',
selectedKeyForGroup?.group_id === option.value ||
(!selectedKeyForGroup?.group_id && option.value === null)
? 'bg-primary-50 dark:bg-primary-900/20'
: 'hover:bg-gray-100 dark:hover:bg-dark-700'
]"
...
...
@@ -367,8 +469,11 @@
:rate-multiplier=
"option.rate"
/>
<svg
v-if=
"selectedKeyForGroup?.group_id === option.value || (!selectedKeyForGroup?.group_id && option.value === null)"
class=
"w-4 h-4 text-primary-600 dark:text-primary-400 shrink-0"
v-if=
"
selectedKeyForGroup?.group_id === option.value ||
(!selectedKeyForGroup?.group_id && option.value === null)
"
class=
"h-4 w-4 shrink-0 text-primary-600 dark:text-primary-400"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
...
...
@@ -451,7 +556,7 @@ const groupButtonRefs = ref<Map<number, HTMLElement>>(new Map())
// Get the currently selected key for group change
const
selectedKeyForGroup
=
computed
(()
=>
{
if
(
groupSelectorKeyId
.
value
===
null
)
return
null
return
apiKeys
.
value
.
find
(
k
=>
k
.
id
===
groupSelectorKeyId
.
value
)
||
null
return
apiKeys
.
value
.
find
(
(
k
)
=>
k
.
id
===
groupSelectorKeyId
.
value
)
||
null
})
const
setGroupButtonRef
=
(
keyId
:
number
,
el
:
Element
|
ComponentPublicInstance
|
null
)
=>
{
...
...
@@ -493,7 +598,7 @@ const statusOptions = computed(() => [
// Convert groups to Select options format with rate multiplier and subscription type
const
groupOptions
=
computed
(()
=>
groups
.
value
.
map
(
group
=>
({
groups
.
value
.
map
(
(
group
)
=>
({
value
:
group
.
id
,
label
:
group
.
name
,
rate
:
group
.
rate_multiplier
,
...
...
@@ -538,7 +643,7 @@ const loadApiKeys = async () => {
// Load usage stats for all API keys in the list
if
(
response
.
items
.
length
>
0
)
{
const
keyIds
=
response
.
items
.
map
(
k
=>
k
.
id
)
const
keyIds
=
response
.
items
.
map
(
(
k
)
=>
k
.
id
)
try
{
const
usageResponse
=
await
usageAPI
.
getDashboardApiKeysUsage
(
keyIds
)
usageStats
.
value
=
usageResponse
.
stats
...
...
@@ -600,7 +705,9 @@ const toggleKeyStatus = async (key: ApiKey) => {
const
newStatus
=
key
.
status
===
'
active
'
?
'
inactive
'
:
'
active
'
try
{
await
keysAPI
.
toggleStatus
(
key
.
id
,
newStatus
)
appStore
.
showSuccess
(
newStatus
===
'
active
'
?
t
(
'
keys.keyEnabledSuccess
'
)
:
t
(
'
keys.keyDisabledSuccess
'
))
appStore
.
showSuccess
(
newStatus
===
'
active
'
?
t
(
'
keys.keyEnabledSuccess
'
)
:
t
(
'
keys.keyDisabledSuccess
'
)
)
loadApiKeys
()
}
catch
(
error
)
{
appStore
.
showError
(
t
(
'
keys.failedToUpdateStatus
'
))
...
...
frontend/src/views/user/ProfileView.vue
View file @
5763f5ce
<
template
>
<AppLayout>
<div
class=
"max-w-4xl
mx-auto
space-y-6"
>
<div
class=
"m
x-auto m
ax-w-4xl space-y-6"
>
<!-- Account Stats Summary -->
<div
class=
"grid grid-cols-1 gap-6 sm:grid-cols-3"
>
<StatCard
...
...
@@ -25,28 +25,26 @@
<!-- User Information -->
<div
class=
"card overflow-hidden"
>
<div
class=
"px-6 py-5 bg-gradient-to-r from-primary-500/10 to-primary-600/5 dark:from-primary-500/20 dark:to-primary-600/10 border-b border-gray-100 dark:border-dark-700"
>
<div
class=
"border-b border-gray-100 bg-gradient-to-r from-primary-500/10 to-primary-600/5 px-6 py-5 dark:border-dark-700 dark:from-primary-500/20 dark:to-primary-600/10"
>
<div
class=
"flex items-center gap-4"
>
<!-- Avatar -->
<div
class=
"w-16 h-16 rounded-2xl bg-gradient-to-br from-primary-500 to-primary-600 flex items-center justify-center text-white text-2xl font-bold shadow-lg shadow-primary-500/20"
>
<div
class=
"flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-primary-500 to-primary-600 text-2xl font-bold text-white shadow-lg shadow-primary-500/20"
>
{{
user
?.
email
?.
charAt
(
0
).
toUpperCase
()
||
'
U
'
}}
</div>
<div
class=
"flex-1 min-w-0"
>
<h2
class=
"text-lg font-semibold text-gray-900 dark:text-white truncate"
>
{{
user
?.
email
}}
</h2>
<div
class=
"flex items-center gap-2 mt-1"
>
<span
:class=
"[
'badge',
user?.role === 'admin' ? 'badge-primary' : 'badge-gray'
]"
>
<div
class=
"min-w-0 flex-1"
>
<h2
class=
"truncate text-lg font-semibold text-gray-900 dark:text-white"
>
{{
user
?.
email
}}
</h2>
<div
class=
"mt-1 flex items-center gap-2"
>
<span
:class=
"['badge', user?.role === 'admin' ? 'badge-primary' : 'badge-gray']"
>
{{
user
?.
role
===
'
admin
'
?
t
(
'
profile.administrator
'
)
:
t
(
'
profile.user
'
)
}}
</span>
<span
:class=
"[
'badge',
user?.status === 'active' ? 'badge-success' : 'badge-danger'
]"
:class=
"['badge', user?.status === 'active' ? 'badge-success' : 'badge-danger']"
>
{{
user
?.
status
}}
</span>
...
...
@@ -57,20 +55,56 @@
<div
class=
"px-6 py-4"
>
<div
class=
"space-y-3"
>
<div
class=
"flex items-center gap-3 text-sm text-gray-600 dark:text-gray-400"
>
<svg
class=
"w-4 h-4 text-gray-400 dark:text-gray-500"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75"
/>
<svg
class=
"h-4 w-4 text-gray-400 dark:text-gray-500"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75"
/>
</svg>
<span
class=
"truncate"
>
{{
user
?.
email
}}
</span>
</div>
<div
v-if=
"user?.username"
class=
"flex items-center gap-3 text-sm text-gray-600 dark:text-gray-400"
>
<svg
class=
"w-4 h-4 text-gray-400 dark:text-gray-500"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z"
/>
<div
v-if=
"user?.username"
class=
"flex items-center gap-3 text-sm text-gray-600 dark:text-gray-400"
>
<svg
class=
"h-4 w-4 text-gray-400 dark:text-gray-500"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z"
/>
</svg>
<span
class=
"truncate"
>
{{
user
.
username
}}
</span>
</div>
<div
v-if=
"user?.wechat"
class=
"flex items-center gap-3 text-sm text-gray-600 dark:text-gray-400"
>
<svg
class=
"w-4 h-4 text-gray-400 dark:text-gray-500"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z"
/>
<div
v-if=
"user?.wechat"
class=
"flex items-center gap-3 text-sm text-gray-600 dark:text-gray-400"
>
<svg
class=
"h-4 w-4 text-gray-400 dark:text-gray-500"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z"
/>
</svg>
<span
class=
"truncate"
>
{{
user
.
wechat
}}
</span>
</div>
...
...
@@ -79,17 +113,36 @@
</div>
<!-- Contact Support Section -->
<div
v-if=
"contactInfo"
class=
"card border-primary-200 dark:border-primary-800/40 bg-gradient-to-r from-primary-50 to-primary-100/50 dark:from-primary-900/20 dark:to-primary-800/10"
>
<div
v-if=
"contactInfo"
class=
"card border-primary-200 bg-gradient-to-r from-primary-50 to-primary-100/50 dark:border-primary-800/40 dark:from-primary-900/20 dark:to-primary-800/10"
>
<div
class=
"px-6 py-5"
>
<div
class=
"flex items-center gap-4"
>
<div
class=
"flex-shrink-0 w-12 h-12 rounded-xl bg-primary-100 dark:bg-primary-900/30 flex items-center justify-center"
>
<svg
class=
"w-6 h-6 text-primary-600 dark:text-primary-400"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z"
/>
<div
class=
"flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl bg-primary-100 dark:bg-primary-900/30"
>
<svg
class=
"h-6 w-6 text-primary-600 dark:text-primary-400"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z"
/>
</svg>
</div>
<div
class=
"flex-1 min-w-0"
>
<h3
class=
"text-sm font-semibold text-primary-800 dark:text-primary-200"
>
{{
t
(
'
common.contactSupport
'
)
}}
</h3>
<p
class=
"mt-1 text-sm font-medium text-primary-600 dark:text-primary-300"
>
{{
contactInfo
}}
</p>
<div
class=
"min-w-0 flex-1"
>
<h3
class=
"text-sm font-semibold text-primary-800 dark:text-primary-200"
>
{{
t
(
'
common.contactSupport
'
)
}}
</h3>
<p
class=
"mt-1 text-sm font-medium text-primary-600 dark:text-primary-300"
>
{{
contactInfo
}}
</p>
</div>
</div>
</div>
...
...
@@ -97,8 +150,10 @@
<!-- Edit Profile Section -->
<div
class=
"card"
>
<div
class=
"px-6 py-4 border-b border-gray-100 dark:border-dark-700"
>
<h2
class=
"text-lg font-medium text-gray-900 dark:text-white"
>
{{
t
(
'
profile.editProfile
'
)
}}
</h2>
<div
class=
"border-b border-gray-100 px-6 py-4 dark:border-dark-700"
>
<h2
class=
"text-lg font-medium text-gray-900 dark:text-white"
>
{{
t
(
'
profile.editProfile
'
)
}}
</h2>
</div>
<div
class=
"px-6 py-6"
>
<form
@
submit.prevent=
"handleUpdateProfile"
class=
"space-y-4"
>
...
...
@@ -129,11 +184,7 @@
</div>
<div
class=
"flex justify-end pt-4"
>
<button
type=
"submit"
:disabled=
"updatingProfile"
class=
"btn btn-primary"
>
<button
type=
"submit"
:disabled=
"updatingProfile"
class=
"btn btn-primary"
>
{{
updatingProfile
?
t
(
'
profile.updating
'
)
:
t
(
'
profile.updateProfile
'
)
}}
</button>
</div>
...
...
@@ -143,8 +194,10 @@
<!-- Change Password Section -->
<div
class=
"card"
>
<div
class=
"px-6 py-4 border-b border-gray-100 dark:border-dark-700"
>
<h2
class=
"text-lg font-medium text-gray-900 dark:text-white"
>
{{
t
(
'
profile.changePassword
'
)
}}
</h2>
<div
class=
"border-b border-gray-100 px-6 py-4 dark:border-dark-700"
>
<h2
class=
"text-lg font-medium text-gray-900 dark:text-white"
>
{{
t
(
'
profile.changePassword
'
)
}}
</h2>
</div>
<div
class=
"px-6 py-6"
>
<form
@
submit.prevent=
"handleChangePassword"
class=
"space-y-4"
>
...
...
@@ -194,18 +247,17 @@
</div>
<div
class=
"flex justify-end pt-4"
>
<button
type=
"submit"
:disabled=
"
changingPassword
"
class=
"btn btn-primary"
>
{{
changingPassword
?
t
(
'
profile.changingPassword
'
)
:
t
(
'
profile.changePasswordButton
'
)
}}
<button
type=
"submit"
:disabled=
"changingPassword"
class=
"btn btn-primary"
>
{{
changingPassword
?
t
(
'
profile.changingPassword
'
)
:
t
(
'
profile.changePasswordButton
'
)
}}
</button>
</div>
</form>
</div>
</div>
</div>
</AppLayout>
</
template
>
...
...
@@ -223,21 +275,48 @@ import StatCard from '@/components/common/StatCard.vue'
// SVG Icon Components
const
WalletIcon
=
{
render
:
()
=>
h
(
'
svg
'
,
{
fill
:
'
none
'
,
viewBox
:
'
0 0 24 24
'
,
stroke
:
'
currentColor
'
,
'
stroke-width
'
:
'
1.5
'
},
[
h
(
'
path
'
,
{
'
stroke-linecap
'
:
'
round
'
,
'
stroke-linejoin
'
:
'
round
'
,
d
:
'
M21 12a2.25 2.25 0 00-2.25-2.25H15a3 3 0 11-6 0H5.25A2.25 2.25 0 003 12m18 0v6a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 18v-6m18 0V9M3 12V9m18 0a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 9m18 0V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v3
'
})
])
render
:
()
=>
h
(
'
svg
'
,
{
fill
:
'
none
'
,
viewBox
:
'
0 0 24 24
'
,
stroke
:
'
currentColor
'
,
'
stroke-width
'
:
'
1.5
'
},
[
h
(
'
path
'
,
{
'
stroke-linecap
'
:
'
round
'
,
'
stroke-linejoin
'
:
'
round
'
,
d
:
'
M21 12a2.25 2.25 0 00-2.25-2.25H15a3 3 0 11-6 0H5.25A2.25 2.25 0 003 12m18 0v6a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 18v-6m18 0V9M3 12V9m18 0a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 9m18 0V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v3
'
})
]
)
}
const
BoltIcon
=
{
render
:
()
=>
h
(
'
svg
'
,
{
fill
:
'
none
'
,
viewBox
:
'
0 0 24 24
'
,
stroke
:
'
currentColor
'
,
'
stroke-width
'
:
'
1.5
'
},
[
h
(
'
path
'
,
{
'
stroke-linecap
'
:
'
round
'
,
'
stroke-linejoin
'
:
'
round
'
,
d
:
'
m3.75 13.5 10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z
'
})
])
render
:
()
=>
h
(
'
svg
'
,
{
fill
:
'
none
'
,
viewBox
:
'
0 0 24 24
'
,
stroke
:
'
currentColor
'
,
'
stroke-width
'
:
'
1.5
'
},
[
h
(
'
path
'
,
{
'
stroke-linecap
'
:
'
round
'
,
'
stroke-linejoin
'
:
'
round
'
,
d
:
'
m3.75 13.5 10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z
'
})
]
)
}
const
CalendarIcon
=
{
render
:
()
=>
h
(
'
svg
'
,
{
fill
:
'
none
'
,
viewBox
:
'
0 0 24 24
'
,
stroke
:
'
currentColor
'
,
'
stroke-width
'
:
'
1.5
'
},
[
h
(
'
path
'
,
{
'
stroke-linecap
'
:
'
round
'
,
'
stroke-linejoin
'
:
'
round
'
,
d
:
'
M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5
'
})
])
render
:
()
=>
h
(
'
svg
'
,
{
fill
:
'
none
'
,
viewBox
:
'
0 0 24 24
'
,
stroke
:
'
currentColor
'
,
'
stroke-width
'
:
'
1.5
'
},
[
h
(
'
path
'
,
{
'
stroke-linecap
'
:
'
round
'
,
'
stroke-linejoin
'
:
'
round
'
,
d
:
'
M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5
'
})
]
)
}
const
authStore
=
useAuthStore
()
...
...
@@ -303,10 +382,7 @@ const handleChangePassword = async () => {
changingPassword
.
value
=
true
try
{
await
userAPI
.
changePassword
(
passwordForm
.
value
.
old_password
,
passwordForm
.
value
.
new_password
)
await
userAPI
.
changePassword
(
passwordForm
.
value
.
old_password
,
passwordForm
.
value
.
new_password
)
// Clear form
passwordForm
.
value
=
{
...
...
frontend/src/views/user/RedeemView.vue
View file @
5763f5ce
<
template
>
<AppLayout>
<div
class=
"max-w-2xl
mx-auto
space-y-6"
>
<div
class=
"m
x-auto m
ax-w-2xl space-y-6"
>
<!-- Current Balance Card -->
<div
class=
"card overflow-hidden"
>
<div
class=
"bg-gradient-to-br from-primary-500 to-primary-600 px-6 py-8 text-center"
>
<div
class=
"inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-white/20 backdrop-blur-sm mb-4"
>
<svg
class=
"w-8 h-8 text-white"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M2.25 18.75a60.07 60.07 0 0115.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 013 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 00-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 01-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 003 15h-.75M15 10.5a3 3 0 11-6 0 3 3 0 016 0zm3 0h.008v.008H18V10.5zm-12 0h.008v.008H6V10.5z"
/>
<div
class=
"mb-4 inline-flex h-16 w-16 items-center justify-center rounded-2xl bg-white/20 backdrop-blur-sm"
>
<svg
class=
"h-8 w-8 text-white"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M2.25 18.75a60.07 60.07 0 0115.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 013 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 00-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 01-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 003 15h-.75M15 10.5a3 3 0 11-6 0 3 3 0 016 0zm3 0h.008v.008H18V10.5zm-12 0h.008v.008H6V10.5z"
/>
</svg>
</div>
<p
class=
"text-sm font-medium text-primary-100"
>
{{
t
(
'
redeem.currentBalance
'
)
}}
</p>
...
...
@@ -28,9 +40,19 @@
{{
t
(
'
redeem.redeemCodeLabel
'
)
}}
</label>
<div
class=
"relative mt-1"
>
<div
class=
"absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none"
>
<svg
class=
"w-5 h-5 text-gray-400 dark:text-dark-500"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M21 11.25v8.25a1.5 1.5 0 01-1.5 1.5H5.25a1.5 1.5 0 01-1.5-1.5v-8.25M12 4.875A2.625 2.625 0 109.375 7.5H12m0-2.625V7.5m0-2.625A2.625 2.625 0 1114.625 7.5H12m0 0V21m-8.625-9.75h18c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125h-18c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z"
/>
<div
class=
"pointer-events-none absolute inset-y-0 left-0 flex items-center pl-4"
>
<svg
class=
"h-5 w-5 text-gray-400 dark:text-dark-500"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M21 11.25v8.25a1.5 1.5 0 01-1.5 1.5H5.25a1.5 1.5 0 01-1.5-1.5v-8.25M12 4.875A2.625 2.625 0 109.375 7.5H12m0-2.625V7.5m0-2.625A2.625 2.625 0 1114.625 7.5H12m0 0V21m-8.625-9.75h18c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125h-18c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z"
/>
</svg>
</div>
<input
...
...
@@ -40,7 +62,7 @@
required
:placeholder=
"t('redeem.redeemCodePlaceholder')"
:disabled=
"submitting"
class=
"input pl-12 text-lg
py-3
"
class=
"input
py-3
pl-12 text-lg"
/>
</div>
<p
class=
"input-hint"
>
...
...
@@ -55,15 +77,37 @@
>
<svg
v-if=
"submitting"
class=
"
animate-spin
-ml-1 mr-2 h-5 w-5"
class=
"-ml-1 mr-2 h-5 w-5
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>
<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>
<svg
v-else
class=
"w-5 h-5 mr-2"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
<svg
v-else
class=
"mr-2 h-5 w-5"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
{{
submitting
?
t
(
'
redeem.redeeming
'
)
:
t
(
'
redeem.redeemButton
'
)
}}
</button>
...
...
@@ -75,13 +119,25 @@
<transition
name=
"fade"
>
<div
v-if=
"redeemResult"
class=
"card border-emerald-200 dark:border-emerald-800/50
bg-emerald-50
dark:bg-emerald-900/20"
class=
"card border-emerald-200
bg-emerald-50
dark:border-emerald-800/50 dark:bg-emerald-900/20"
>
<div
class=
"p-6"
>
<div
class=
"flex items-start gap-4"
>
<div
class=
"flex-shrink-0 w-10 h-10 rounded-xl bg-emerald-100 dark:bg-emerald-900/30 flex items-center justify-center"
>
<svg
class=
"w-5 h-5 text-emerald-600 dark:text-emerald-400"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
<div
class=
"flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-emerald-100 dark:bg-emerald-900/30"
>
<svg
class=
"h-5 w-5 text-emerald-600 dark:text-emerald-400"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<div
class=
"flex-1"
>
...
...
@@ -95,18 +151,27 @@
{{
t
(
'
redeem.added
'
)
}}
: $
{{
redeemResult
.
value
.
toFixed
(
2
)
}}
</p>
<p
v-else-if=
"redeemResult.type === 'concurrency'"
class=
"font-medium"
>
{{
t
(
'
redeem.added
'
)
}}
:
{{
redeemResult
.
value
}}
{{
t
(
'
redeem.concurrentRequests
'
)
}}
{{
t
(
'
redeem.added
'
)
}}
:
{{
redeemResult
.
value
}}
{{
t
(
'
redeem.concurrentRequests
'
)
}}
</p>
<p
v-else-if=
"redeemResult.type === 'subscription'"
class=
"font-medium"
>
{{
t
(
'
redeem.subscriptionAssigned
'
)
}}
<span
v-if=
"redeemResult.group_name"
>
-
{{
redeemResult
.
group_name
}}
</span>
<span
v-if=
"redeemResult.validity_days"
>
(
{{
t
(
'
redeem.subscriptionDays
'
,
{
days
:
redeemResult
.
validity_days
}
)
}}
)
<
/span
>
<span
v-if=
"redeemResult.validity_days"
>
(
{{
t
(
'
redeem.subscriptionDays
'
,
{
days
:
redeemResult
.
validity_days
}
)
}}
)
<
/spa
n
>
<
/p
>
<
p
v
-
if
=
"
redeemResult.new_balance !== undefined
"
>
{{
t
(
'
redeem.newBalance
'
)
}}
:
<
span
class
=
"
font-semibold
"
>
$
{{
redeemResult
.
new_balance
.
toFixed
(
2
)
}}
<
/span
>
{{
t
(
'
redeem.newBalance
'
)
}}
:
<
span
class
=
"
font-semibold
"
>
$
{{
redeemResult
.
new_balance
.
toFixed
(
2
)
}}
<
/span
>
<
/p
>
<
p
v
-
if
=
"
redeemResult.new_concurrency !== undefined
"
>
{{
t
(
'
redeem.newConcurrency
'
)
}}
:
<
span
class
=
"
font-semibold
"
>
{{
redeemResult
.
new_concurrency
}}
{{
t
(
'
redeem.requests
'
)
}}
<
/span
>
{{
t
(
'
redeem.newConcurrency
'
)
}}
:
<
span
class
=
"
font-semibold
"
>
{{
redeemResult
.
new_concurrency
}}
{{
t
(
'
redeem.requests
'
)
}}
<
/spa
n
>
<
/p
>
<
/div
>
<
/div
>
...
...
@@ -120,13 +185,25 @@
<
transition
name
=
"
fade
"
>
<
div
v
-
if
=
"
errorMessage
"
class
=
"
card border-red-200 dark:border-red-800/50
bg-red-50
dark:bg-red-900/20
"
class
=
"
card border-red-200
bg-red-50
dark:border-red-800/50 dark:bg-red-900/20
"
>
<
div
class
=
"
p-6
"
>
<
div
class
=
"
flex items-start gap-4
"
>
<
div
class
=
"
flex-shrink-0 w-10 h-10 rounded-xl bg-red-100 dark:bg-red-900/30 flex items-center justify-center
"
>
<
svg
class
=
"
w-5 h-5 text-red-600 dark:text-red-400
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
stroke
-
width
=
"
1.5
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
d
=
"
M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z
"
/>
<
div
class
=
"
flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-red-100 dark:bg-red-900/30
"
>
<
svg
class
=
"
h-5 w-5 text-red-600 dark:text-red-400
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
stroke
-
width
=
"
1.5
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
d
=
"
M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z
"
/>
<
/svg
>
<
/div
>
<
div
class
=
"
flex-1
"
>
...
...
@@ -143,24 +220,43 @@
<
/transition
>
<!--
Information
Card
-->
<
div
class
=
"
card border-primary-200 dark:border-primary-800/50 bg-primary-50 dark:bg-primary-900/20
"
>
<
div
class
=
"
card border-primary-200 bg-primary-50 dark:border-primary-800/50 dark:bg-primary-900/20
"
>
<
div
class
=
"
p-6
"
>
<
div
class
=
"
flex items-start gap-4
"
>
<
div
class
=
"
flex-shrink-0 w-10 h-10 rounded-xl bg-primary-100 dark:bg-primary-900/30 flex items-center justify-center
"
>
<
svg
class
=
"
w-5 h-5 text-primary-600 dark:text-primary-400
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
stroke
-
width
=
"
1.5
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
d
=
"
M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z
"
/>
<
div
class
=
"
flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-primary-100 dark:bg-primary-900/30
"
>
<
svg
class
=
"
h-5 w-5 text-primary-600 dark:text-primary-400
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
stroke
-
width
=
"
1.5
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
d
=
"
M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z
"
/>
<
/svg
>
<
/div
>
<
div
class
=
"
flex-1
"
>
<
h3
class
=
"
text-sm font-semibold text-primary-800 dark:text-primary-300
"
>
{{
t
(
'
redeem.aboutCodes
'
)
}}
<
/h3
>
<
ul
class
=
"
mt-2 text-sm text-primary-700 dark:text-primary-400 space-y-1 list-disc list-inside
"
>
<
ul
class
=
"
mt-2 list-inside list-disc space-y-1 text-sm text-primary-700 dark:text-primary-400
"
>
<
li
>
{{
t
(
'
redeem.codeRule1
'
)
}}
<
/li
>
<
li
>
{{
t
(
'
redeem.codeRule2
'
)
}}
<
/li
>
<
li
>
{{
t
(
'
redeem.codeRule3
'
)
}}
<
span
v
-
if
=
"
contactInfo
"
class
=
"
inline-flex items-center ml-1.5 px-2 py-0.5 rounded-md bg-primary-200/50 dark:bg-primary-800/40 text-primary-800 dark:text-primary-200 font-medium text-xs
"
>
<
span
v
-
if
=
"
contactInfo
"
class
=
"
ml-1.5 inline-flex items-center rounded-md bg-primary-200/50 px-2 py-0.5 text-xs font-medium text-primary-800 dark:bg-primary-800/40 dark:text-primary-200
"
>
{{
contactInfo
}}
<
/span
>
<
/li
>
...
...
@@ -173,15 +269,28 @@
<!--
Recent
Activity
-->
<
div
class
=
"
card
"
>
<
div
class
=
"
px-6 py-4 border-b border-gray-100 dark:border-dark-700
"
>
<
h2
class
=
"
text-lg font-semibold text-gray-900 dark:text-white
"
>
{{
t
(
'
redeem.recentActivity
'
)
}}
<
/h2
>
<
div
class
=
"
border-b border-gray-100 px-6 py-4 dark:border-dark-700
"
>
<
h2
class
=
"
text-lg font-semibold text-gray-900 dark:text-white
"
>
{{
t
(
'
redeem.recentActivity
'
)
}}
<
/h2
>
<
/div
>
<
div
class
=
"
p-6
"
>
<!--
Loading
State
-->
<
div
v
-
if
=
"
loadingHistory
"
class
=
"
flex items-center justify-center py-8
"
>
<
svg
class
=
"
animate-spin h-6 w-6 text-primary-500
"
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
class
=
"
h-6 w-6 animate-spin text-primary-500
"
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
>
<
/div
>
...
...
@@ -190,57 +299,77 @@
<
div
v
-
for
=
"
item in history
"
:
key
=
"
item.id
"
class
=
"
flex items-center justify-between
p-4
rounded-xl bg-gray-50 dark:bg-dark-800
"
class
=
"
flex items-center justify-between rounded-xl bg-gray-50
p-4
dark:bg-dark-800
"
>
<
div
class
=
"
flex items-center gap-4
"
>
<
div
:
class
=
"
[
'
w
-10
h
-10
rounded-xl flex
items-center justify-center',
'
flex h
-10
w
-10 items-center justify-center
rounded-xl
',
isBalanceType(item.type)
? (item.value >= 0 ? 'bg-emerald-100 dark:bg-emerald-900/30' : 'bg-red-100 dark:bg-red-900/30')
? item.value >= 0
? 'bg-emerald-100 dark:bg-emerald-900/30'
: 'bg-red-100 dark:bg-red-900/30'
: isSubscriptionType(item.type)
? 'bg-purple-100 dark:bg-purple-900/30'
: (item.value >= 0 ? 'bg-blue-100 dark:bg-blue-900/30' : 'bg-orange-100 dark:bg-orange-900/30')
: item.value >= 0
? 'bg-blue-100 dark:bg-blue-900/30'
: 'bg-orange-100 dark:bg-orange-900/30'
]
"
>
<!--
余额类型图标
-->
<
svg
v
-
if
=
"
isBalanceType(item.type)
"
:
class
=
"
[
'w-5 h-5',
item.value >= 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400'
'h-5 w-5',
item.value >= 0
? 'text-emerald-600 dark:text-emerald-400'
: 'text-red-600 dark:text-red-400'
]
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
stroke
-
width
=
"
1.5
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
d
=
"
M12 6v12m-3-2.818l.879.659c1.171.879 3.07.879 4.242 0 1.172-.879 1.172-2.303 0-3.182C13.536 12.219 12.768 12 12 12c-.725 0-1.45-.22-2.003-.659-1.106-.879-1.106-2.303 0-3.182s2.9-.879 4.006 0l.415.33M21 12a9 9 0 11-18 0 9 9 0 0118 0z
"
/>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
d
=
"
M12 6v12m-3-2.818l.879.659c1.171.879 3.07.879 4.242 0 1.172-.879 1.172-2.303 0-3.182C13.536 12.219 12.768 12 12 12c-.725 0-1.45-.22-2.003-.659-1.106-.879-1.106-2.303 0-3.182s2.9-.879 4.006 0l.415.33M21 12a9 9 0 11-18 0 9 9 0 0118 0z
"
/>
<
/svg
>
<!--
订阅类型图标
-->
<
svg
v
-
else
-
if
=
"
isSubscriptionType(item.type)
"
class
=
"
w
-5
h
-5 text-purple-600 dark:text-purple-400
"
class
=
"
h
-5
w
-5 text-purple-600 dark:text-purple-400
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
stroke
-
width
=
"
1.5
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
d
=
"
M9 12.75L11.25 15 15 9.75M21 12c0 1.268-.63 2.39-1.593 3.068a3.745 3.745 0 01-1.043 3.296 3.745 3.745 0 01-3.296 1.043A3.745 3.745 0 0112 21c-1.268 0-2.39-.63-3.068-1.593a3.746 3.746 0 01-3.296-1.043 3.745 3.745 0 01-1.043-3.296A3.745 3.745 0 013 12c0-1.268.63-2.39 1.593-3.068a3.745 3.745 0 011.043-3.296 3.746 3.746 0 013.296-1.043A3.746 3.746 0 0112 3c1.268 0 2.39.63 3.068 1.593a3.746 3.746 0 013.296 1.043 3.746 3.746 0 011.043 3.296A3.745 3.745 0 0121 12z
"
/>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
d
=
"
M9 12.75L11.25 15 15 9.75M21 12c0 1.268-.63 2.39-1.593 3.068a3.745 3.745 0 01-1.043 3.296 3.745 3.745 0 01-3.296 1.043A3.745 3.745 0 0112 21c-1.268 0-2.39-.63-3.068-1.593a3.746 3.746 0 01-3.296-1.043 3.745 3.745 0 01-1.043-3.296A3.745 3.745 0 013 12c0-1.268.63-2.39 1.593-3.068a3.745 3.745 0 011.043-3.296 3.746 3.746 0 013.296-1.043A3.746 3.746 0 0112 3c1.268 0 2.39.63 3.068 1.593a3.746 3.746 0 013.296 1.043 3.746 3.746 0 011.043 3.296A3.745 3.745 0 0121 12z
"
/>
<
/svg
>
<!--
并发类型图标
-->
<
svg
v
-
else
:
class
=
"
[
'w-5 h-5',
item.value >= 0 ? 'text-blue-600 dark:text-blue-400' : 'text-orange-600 dark:text-orange-400'
'h-5 w-5',
item.value >= 0
? 'text-blue-600 dark:text-blue-400'
: 'text-orange-600 dark:text-orange-400'
]
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
stroke
-
width
=
"
1.5
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
d
=
"
M3.75 13.5l10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z
"
/>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
d
=
"
M3.75 13.5l10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z
"
/>
<
/svg
>
<
/div
>
<
div
>
...
...
@@ -257,15 +386,22 @@
:
class
=
"
[
'text-sm font-semibold',
isBalanceType(item.type)
? (item.value >= 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400')
? item.value >= 0
? 'text-emerald-600 dark:text-emerald-400'
: 'text-red-600 dark:text-red-400'
: isSubscriptionType(item.type)
? 'text-purple-600 dark:text-purple-400'
: (item.value >= 0 ? 'text-blue-600 dark:text-blue-400' : 'text-orange-600 dark:text-orange-400')
: item.value >= 0
? 'text-blue-600 dark:text-blue-400'
: 'text-orange-600 dark:text-orange-400'
]
"
>
{{
formatHistoryValue
(
item
)
}}
<
/p
>
<
p
v
-
if
=
"
!isAdminAdjustment(item.type)
"
class
=
"
text-xs text-gray-400 dark:text-dark-500 font-mono
"
>
<
p
v
-
if
=
"
!isAdminAdjustment(item.type)
"
class
=
"
font-mono text-xs text-gray-400 dark:text-dark-500
"
>
{{
item
.
code
.
slice
(
0
,
8
)
}}
...
<
/p
>
<
p
v
-
else
class
=
"
text-xs text-gray-400 dark:text-dark-500
"
>
...
...
@@ -277,9 +413,21 @@
<!--
Empty
State
-->
<
div
v
-
else
class
=
"
empty-state py-8
"
>
<
div
class
=
"
w-16 h-16 mb-4 rounded-2xl bg-gray-100 dark:bg-dark-800 flex items-center justify-center
"
>
<
svg
class
=
"
w-8 h-8 text-gray-400 dark:text-dark-500
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
stroke
-
width
=
"
1.5
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
d
=
"
M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z
"
/>
<
div
class
=
"
mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-gray-100 dark:bg-dark-800
"
>
<
svg
class
=
"
h-8 w-8 text-gray-400 dark:text-dark-500
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
stroke
-
width
=
"
1.5
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
d
=
"
M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z
"
/>
<
/svg
>
<
/div
>
<
p
class
=
"
text-sm text-gray-500 dark:text-dark-400
"
>
...
...
frontend/src/views/user/SubscriptionsView.vue
View file @
5763f5ce
...
...
@@ -3,17 +3,31 @@
<div
class=
"space-y-6"
>
<!-- Loading State -->
<div
v-if=
"loading"
class=
"flex justify-center py-12"
>
<div
class=
"animate-spin rounded-full h-8 w-8 border-2 border-primary-500 border-t-transparent"
></div>
<div
class=
"h-8 w-8 animate-spin rounded-full border-2 border-primary-500 border-t-transparent"
></div>
</div>
<!-- Empty State -->
<div
v-else-if=
"subscriptions.length === 0"
class=
"card p-12 text-center"
>
<div
class=
"w-16 h-16 mx-auto mb-4 rounded-full bg-gray-100 dark:bg-dark-700 flex items-center justify-center"
>
<svg
class=
"w-8 h-8 text-gray-400"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z"
/>
<div
class=
"mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-gray-100 dark:bg-dark-700"
>
<svg
class=
"h-8 w-8 text-gray-400"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z"
/>
</svg>
</div>
<h3
class=
"text-lg font-semibold text-gray-900 dark:text-white
mb-2
"
>
<h3
class=
"
mb-2
text-lg font-semibold text-gray-900 dark:text-white"
>
{{
t
(
'
userSubscriptions.noActiveSubscriptions
'
)
}}
</h3>
<p
class=
"text-gray-500 dark:text-dark-400"
>
...
...
@@ -29,11 +43,25 @@
class=
"card overflow-hidden"
>
<!-- Header -->
<div
class=
"flex items-center justify-between p-4 border-b border-gray-100 dark:border-dark-700"
>
<div
class=
"flex items-center justify-between border-b border-gray-100 p-4 dark:border-dark-700"
>
<div
class=
"flex items-center gap-3"
>
<div
class=
"w-10 h-10 rounded-xl bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center"
>
<svg
class=
"w-5 h-5 text-purple-600 dark:text-purple-400"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z"
/>
<div
class=
"flex h-10 w-10 items-center justify-center rounded-xl bg-purple-100 dark:bg-purple-900/30"
>
<svg
class=
"h-5 w-5 text-purple-600 dark:text-purple-400"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z"
/>
</svg>
</div>
<div>
...
...
@@ -48,8 +76,11 @@
<
span
:
class
=
"
[
'badge',
subscription.status === 'active' ? 'badge-success' :
subscription.status === 'expired' ? 'badge-warning' : 'badge-danger'
subscription.status === 'active'
? 'badge-success'
: subscription.status === 'expired'
? 'badge-warning'
: 'badge-danger'
]
"
>
{{
t
(
`userSubscriptions.status.${subscription.status
}
`
)
}}
...
...
@@ -57,17 +88,23 @@
<
/div
>
<!--
Usage
Progress
-->
<
div
class
=
"
p-4
space-y-4
"
>
<
div
class
=
"
space-y-4
p-4
"
>
<!--
Expiration
Info
-->
<
div
v
-
if
=
"
subscription.expires_at
"
class
=
"
flex items-center justify-between text-sm
"
>
<
span
class
=
"
text-gray-500 dark:text-dark-400
"
>
{{
t
(
'
userSubscriptions.expires
'
)
}}
<
/span
>
<
span
class
=
"
text-gray-500 dark:text-dark-400
"
>
{{
t
(
'
userSubscriptions.expires
'
)
}}
<
/span
>
<
span
:
class
=
"
getExpirationClass(subscription.expires_at)
"
>
{{
formatExpirationDate
(
subscription
.
expires_at
)
}}
<
/span
>
<
/div
>
<
div
v
-
else
class
=
"
flex items-center justify-between text-sm
"
>
<
span
class
=
"
text-gray-500 dark:text-dark-400
"
>
{{
t
(
'
userSubscriptions.expires
'
)
}}
<
/span
>
<
span
class
=
"
text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
userSubscriptions.noExpiration
'
)
}}
<
/span
>
<
span
class
=
"
text-gray-500 dark:text-dark-400
"
>
{{
t
(
'
userSubscriptions.expires
'
)
}}
<
/span
>
<
span
class
=
"
text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
userSubscriptions.noExpiration
'
)
}}
<
/span
>
<
/div
>
<!--
Daily
Usage
-->
...
...
@@ -77,18 +114,37 @@
{{
t
(
'
userSubscriptions.daily
'
)
}}
<
/span
>
<
span
class
=
"
text-sm text-gray-500 dark:text-dark-400
"
>
$
{{
(
subscription
.
daily_usage_usd
||
0
).
toFixed
(
2
)
}}
/
$
{{
subscription
.
group
.
daily_limit_usd
.
toFixed
(
2
)
}}
$
{{
(
subscription
.
daily_usage_usd
||
0
).
toFixed
(
2
)
}}
/
$
{{
subscription
.
group
.
daily_limit_usd
.
toFixed
(
2
)
}}
<
/span
>
<
/div
>
<
div
class
=
"
relative h-2
bg-gray-200 dark:bg-dark-600 rounded-full overflow-hidden
"
>
<
div
class
=
"
relative h-2
overflow-hidden rounded-full bg-gray-200 dark:bg-dark-600
"
>
<
div
class
=
"
absolute inset-y-0 left-0 rounded-full transition-all duration-300
"
:
class
=
"
getProgressBarClass(subscription.daily_usage_usd, subscription.group.daily_limit_usd)
"
:
style
=
"
{ width: getProgressWidth(subscription.daily_usage_usd, subscription.group.daily_limit_usd)
}
"
:
class
=
"
getProgressBarClass(
subscription.daily_usage_usd,
subscription.group.daily_limit_usd
)
"
:
style
=
"
{
width: getProgressWidth(
subscription.daily_usage_usd,
subscription.group.daily_limit_usd
)
}
"
><
/div
>
<
/div
>
<
p
v
-
if
=
"
subscription.daily_window_start
"
class
=
"
text-xs text-gray-500 dark:text-dark-400
"
>
{{
t
(
'
userSubscriptions.resetIn
'
,
{
time
:
formatResetTime
(
subscription
.
daily_window_start
,
24
)
}
)
}}
<
p
v
-
if
=
"
subscription.daily_window_start
"
class
=
"
text-xs text-gray-500 dark:text-dark-400
"
>
{{
t
(
'
userSubscriptions.resetIn
'
,
{
time
:
formatResetTime
(
subscription
.
daily_window_start
,
24
)
}
)
}}
<
/p
>
<
/div
>
...
...
@@ -99,18 +155,37 @@
{{
t
(
'
userSubscriptions.weekly
'
)
}}
<
/span
>
<
span
class
=
"
text-sm text-gray-500 dark:text-dark-400
"
>
$
{{
(
subscription
.
weekly_usage_usd
||
0
).
toFixed
(
2
)
}}
/
$
{{
subscription
.
group
.
weekly_limit_usd
.
toFixed
(
2
)
}}
$
{{
(
subscription
.
weekly_usage_usd
||
0
).
toFixed
(
2
)
}}
/
$
{{
subscription
.
group
.
weekly_limit_usd
.
toFixed
(
2
)
}}
<
/span
>
<
/div
>
<
div
class
=
"
relative h-2
bg-gray-200 dark:bg-dark-600 rounded-full overflow-hidden
"
>
<
div
class
=
"
relative h-2
overflow-hidden rounded-full bg-gray-200 dark:bg-dark-600
"
>
<
div
class
=
"
absolute inset-y-0 left-0 rounded-full transition-all duration-300
"
:
class
=
"
getProgressBarClass(subscription.weekly_usage_usd, subscription.group.weekly_limit_usd)
"
:
style
=
"
{ width: getProgressWidth(subscription.weekly_usage_usd, subscription.group.weekly_limit_usd)
}
"
:
class
=
"
getProgressBarClass(
subscription.weekly_usage_usd,
subscription.group.weekly_limit_usd
)
"
:
style
=
"
{
width: getProgressWidth(
subscription.weekly_usage_usd,
subscription.group.weekly_limit_usd
)
}
"
><
/div
>
<
/div
>
<
p
v
-
if
=
"
subscription.weekly_window_start
"
class
=
"
text-xs text-gray-500 dark:text-dark-400
"
>
{{
t
(
'
userSubscriptions.resetIn
'
,
{
time
:
formatResetTime
(
subscription
.
weekly_window_start
,
168
)
}
)
}}
<
p
v
-
if
=
"
subscription.weekly_window_start
"
class
=
"
text-xs text-gray-500 dark:text-dark-400
"
>
{{
t
(
'
userSubscriptions.resetIn
'
,
{
time
:
formatResetTime
(
subscription
.
weekly_window_start
,
168
)
}
)
}}
<
/p
>
<
/div
>
...
...
@@ -121,27 +196,52 @@
{{
t
(
'
userSubscriptions.monthly
'
)
}}
<
/span
>
<
span
class
=
"
text-sm text-gray-500 dark:text-dark-400
"
>
$
{{
(
subscription
.
monthly_usage_usd
||
0
).
toFixed
(
2
)
}}
/
$
{{
subscription
.
group
.
monthly_limit_usd
.
toFixed
(
2
)
}}
$
{{
(
subscription
.
monthly_usage_usd
||
0
).
toFixed
(
2
)
}}
/
$
{{
subscription
.
group
.
monthly_limit_usd
.
toFixed
(
2
)
}}
<
/span
>
<
/div
>
<
div
class
=
"
relative h-2
bg-gray-200 dark:bg-dark-600 rounded-full overflow-hidden
"
>
<
div
class
=
"
relative h-2
overflow-hidden rounded-full bg-gray-200 dark:bg-dark-600
"
>
<
div
class
=
"
absolute inset-y-0 left-0 rounded-full transition-all duration-300
"
:
class
=
"
getProgressBarClass(subscription.monthly_usage_usd, subscription.group.monthly_limit_usd)
"
:
style
=
"
{ width: getProgressWidth(subscription.monthly_usage_usd, subscription.group.monthly_limit_usd)
}
"
:
class
=
"
getProgressBarClass(
subscription.monthly_usage_usd,
subscription.group.monthly_limit_usd
)
"
:
style
=
"
{
width: getProgressWidth(
subscription.monthly_usage_usd,
subscription.group.monthly_limit_usd
)
}
"
><
/div
>
<
/div
>
<
p
v
-
if
=
"
subscription.monthly_window_start
"
class
=
"
text-xs text-gray-500 dark:text-dark-400
"
>
{{
t
(
'
userSubscriptions.resetIn
'
,
{
time
:
formatResetTime
(
subscription
.
monthly_window_start
,
720
)
}
)
}}
<
p
v
-
if
=
"
subscription.monthly_window_start
"
class
=
"
text-xs text-gray-500 dark:text-dark-400
"
>
{{
t
(
'
userSubscriptions.resetIn
'
,
{
time
:
formatResetTime
(
subscription
.
monthly_window_start
,
720
)
}
)
}}
<
/p
>
<
/div
>
<!--
No
limits
configured
-->
<
div
v
-
if
=
"
!subscription.group?.daily_limit_usd && !subscription.group?.weekly_limit_usd && !subscription.group?.monthly_limit_usd
"
class
=
"
text-center py-4
"
v
-
if
=
"
!subscription.group?.daily_limit_usd &&
!subscription.group?.weekly_limit_usd &&
!subscription.group?.monthly_limit_usd
"
class
=
"
py-4 text-center
"
>
<
span
class
=
"
text-sm text-gray-500 dark:text-dark-400
"
>
{{
t
(
'
userSubscriptions.unlimited
'
)
}}
<
/span
>
<
span
class
=
"
text-sm text-gray-500 dark:text-dark-400
"
>
{{
t
(
'
userSubscriptions.unlimited
'
)
}}
<
/span
>
<
/div
>
<
/div
>
<
/div
>
...
...
@@ -151,110 +251,110 @@
<
/template
>
<
script
setup
lang
=
"
ts
"
>
import
{
ref
,
onMounted
}
from
'
vue
'
;
import
{
useI18n
}
from
'
vue-i18n
'
;
import
{
useAppStore
}
from
'
@/stores/app
'
;
import
subscriptionsAPI
from
'
@/api/subscriptions
'
;
import
type
{
UserSubscription
}
from
'
@/types
'
;
import
AppLayout
from
'
@/components/layout/AppLayout.vue
'
;
import
{
ref
,
onMounted
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
subscriptionsAPI
from
'
@/api/subscriptions
'
import
type
{
UserSubscription
}
from
'
@/types
'
import
AppLayout
from
'
@/components/layout/AppLayout.vue
'
const
{
t
}
=
useI18n
()
;
const
appStore
=
useAppStore
()
;
const
{
t
}
=
useI18n
()
const
appStore
=
useAppStore
()
const
subscriptions
=
ref
<
UserSubscription
[]
>
([])
;
const
loading
=
ref
(
true
)
;
const
subscriptions
=
ref
<
UserSubscription
[]
>
([])
const
loading
=
ref
(
true
)
async
function
loadSubscriptions
()
{
try
{
loading
.
value
=
true
;
subscriptions
.
value
=
await
subscriptionsAPI
.
getMySubscriptions
()
;
loading
.
value
=
true
subscriptions
.
value
=
await
subscriptionsAPI
.
getMySubscriptions
()
}
catch
(
error
)
{
console
.
error
(
'
Failed to load subscriptions:
'
,
error
)
;
appStore
.
showError
(
'
Failed to load subscriptions
'
)
;
console
.
error
(
'
Failed to load subscriptions:
'
,
error
)
appStore
.
showError
(
'
Failed to load subscriptions
'
)
}
finally
{
loading
.
value
=
false
;
loading
.
value
=
false
}
}
function
getProgressWidth
(
used
:
number
|
undefined
,
limit
:
number
|
null
|
undefined
):
string
{
if
(
!
limit
||
limit
===
0
)
return
'
0%
'
;
const
percentage
=
Math
.
min
(((
used
||
0
)
/
limit
)
*
100
,
100
)
;
return
`${percentage
}
%`
;
if
(
!
limit
||
limit
===
0
)
return
'
0%
'
const
percentage
=
Math
.
min
(((
used
||
0
)
/
limit
)
*
100
,
100
)
return
`${percentage
}
%`
}
function
getProgressBarClass
(
used
:
number
|
undefined
,
limit
:
number
|
null
|
undefined
):
string
{
if
(
!
limit
||
limit
===
0
)
return
'
bg-gray-400
'
;
const
percentage
=
((
used
||
0
)
/
limit
)
*
100
;
if
(
percentage
>=
90
)
return
'
bg-red-500
'
;
if
(
percentage
>=
70
)
return
'
bg-orange-500
'
;
return
'
bg-green-500
'
;
if
(
!
limit
||
limit
===
0
)
return
'
bg-gray-400
'
const
percentage
=
((
used
||
0
)
/
limit
)
*
100
if
(
percentage
>=
90
)
return
'
bg-red-500
'
if
(
percentage
>=
70
)
return
'
bg-orange-500
'
return
'
bg-green-500
'
}
function
formatExpirationDate
(
expiresAt
:
string
):
string
{
const
now
=
new
Date
()
;
const
expires
=
new
Date
(
expiresAt
)
;
const
diff
=
expires
.
getTime
()
-
now
.
getTime
()
;
const
days
=
Math
.
ceil
(
diff
/
(
1000
*
60
*
60
*
24
))
;
const
now
=
new
Date
()
const
expires
=
new
Date
(
expiresAt
)
const
diff
=
expires
.
getTime
()
-
now
.
getTime
()
const
days
=
Math
.
ceil
(
diff
/
(
1000
*
60
*
60
*
24
))
if
(
days
<
0
)
{
return
t
(
'
userSubscriptions.status.expired
'
)
;
return
t
(
'
userSubscriptions.status.expired
'
)
}
const
dateStr
=
expires
.
toLocaleDateString
(
undefined
,
{
year
:
'
numeric
'
,
month
:
'
short
'
,
day
:
'
numeric
'
}
)
;
}
)
if
(
days
===
0
)
{
return
`${dateStr
}
(Today)`
;
return
`${dateStr
}
(Today)`
}
if
(
days
===
1
)
{
return
`${dateStr
}
(Tomorrow)`
;
return
`${dateStr
}
(Tomorrow)`
}
return
t
(
'
userSubscriptions.daysRemaining
'
,
{
days
}
)
+
` (${dateStr
}
)`
;
return
t
(
'
userSubscriptions.daysRemaining
'
,
{
days
}
)
+
` (${dateStr
}
)`
}
function
getExpirationClass
(
expiresAt
:
string
):
string
{
const
now
=
new
Date
()
;
const
expires
=
new
Date
(
expiresAt
)
;
const
diff
=
expires
.
getTime
()
-
now
.
getTime
()
;
const
days
=
Math
.
ceil
(
diff
/
(
1000
*
60
*
60
*
24
))
;
const
now
=
new
Date
()
const
expires
=
new
Date
(
expiresAt
)
const
diff
=
expires
.
getTime
()
-
now
.
getTime
()
const
days
=
Math
.
ceil
(
diff
/
(
1000
*
60
*
60
*
24
))
if
(
days
<=
0
)
return
'
text-red-600 dark:text-red-400 font-medium
'
;
if
(
days
<=
3
)
return
'
text-red-600 dark:text-red-400
'
;
if
(
days
<=
7
)
return
'
text-orange-600 dark:text-orange-400
'
;
return
'
text-gray-700 dark:text-gray-300
'
;
if
(
days
<=
0
)
return
'
text-red-600 dark:text-red-400 font-medium
'
if
(
days
<=
3
)
return
'
text-red-600 dark:text-red-400
'
if
(
days
<=
7
)
return
'
text-orange-600 dark:text-orange-400
'
return
'
text-gray-700 dark:text-gray-300
'
}
function
formatResetTime
(
windowStart
:
string
|
null
,
windowHours
:
number
):
string
{
if
(
!
windowStart
)
return
t
(
'
userSubscriptions.windowNotActive
'
)
;
if
(
!
windowStart
)
return
t
(
'
userSubscriptions.windowNotActive
'
)
const
start
=
new
Date
(
windowStart
)
;
const
end
=
new
Date
(
start
.
getTime
()
+
windowHours
*
60
*
60
*
1000
)
;
const
now
=
new
Date
()
;
const
diff
=
end
.
getTime
()
-
now
.
getTime
()
;
const
start
=
new
Date
(
windowStart
)
const
end
=
new
Date
(
start
.
getTime
()
+
windowHours
*
60
*
60
*
1000
)
const
now
=
new
Date
()
const
diff
=
end
.
getTime
()
-
now
.
getTime
()
if
(
diff
<=
0
)
return
t
(
'
userSubscriptions.windowNotActive
'
)
;
if
(
diff
<=
0
)
return
t
(
'
userSubscriptions.windowNotActive
'
)
const
hours
=
Math
.
floor
(
diff
/
(
1000
*
60
*
60
))
;
const
minutes
=
Math
.
floor
((
diff
%
(
1000
*
60
*
60
))
/
(
1000
*
60
))
;
const
hours
=
Math
.
floor
(
diff
/
(
1000
*
60
*
60
))
const
minutes
=
Math
.
floor
((
diff
%
(
1000
*
60
*
60
))
/
(
1000
*
60
))
if
(
hours
>
24
)
{
const
days
=
Math
.
floor
(
hours
/
24
)
;
const
remainingHours
=
hours
%
24
;
return
`${days
}
d ${remainingHours
}
h`
;
const
days
=
Math
.
floor
(
hours
/
24
)
const
remainingHours
=
hours
%
24
return
`${days
}
d ${remainingHours
}
h`
}
if
(
hours
>
0
)
{
return
`${hours
}
h ${minutes
}
m`
;
return
`${hours
}
h ${minutes
}
m`
}
return
`${minutes
}
m`
;
return
`${minutes
}
m`
}
onMounted
(()
=>
{
loadSubscriptions
()
;
}
)
;
loadSubscriptions
()
}
)
<
/script
>
frontend/src/views/user/UsageView.vue
View file @
5763f5ce
...
...
@@ -6,15 +6,31 @@
<!-- Total Requests -->
<div
class=
"card p-4"
>
<div
class=
"flex items-center gap-3"
>
<div
class=
"p-2 rounded-lg bg-blue-100 dark:bg-blue-900/30"
>
<svg
class=
"w-5 h-5 text-blue-600 dark:text-blue-400"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
<div
class=
"rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30"
>
<svg
class=
"h-5 w-5 text-blue-600 dark:text-blue-400"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
</div>
<div>
<p
class=
"text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{
t
(
'
usage.totalRequests
'
)
}}
</p>
<p
class=
"text-xl font-bold text-gray-900 dark:text-white"
>
{{
usageStats
?.
total_requests
?.
toLocaleString
()
||
'
0
'
}}
</p>
<p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
usage.inSelectedRange
'
)
}}
</p>
<p
class=
"text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{
t
(
'
usage.totalRequests
'
)
}}
</p>
<p
class=
"text-xl font-bold text-gray-900 dark:text-white"
>
{{
usageStats
?.
total_requests
?.
toLocaleString
()
||
'
0
'
}}
</p>
<p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
usage.inSelectedRange
'
)
}}
</p>
</div>
</div>
</div>
...
...
@@ -22,15 +38,32 @@
<!-- Total Tokens -->
<div
class=
"card p-4"
>
<div
class=
"flex items-center gap-3"
>
<div
class=
"p-2 rounded-lg bg-amber-100 dark:bg-amber-900/30"
>
<svg
class=
"w-5 h-5 text-amber-600 dark:text-amber-400"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"m21 7.5-9-5.25L3 7.5m18 0-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9"
/>
<div
class=
"rounded-lg bg-amber-100 p-2 dark:bg-amber-900/30"
>
<svg
class=
"h-5 w-5 text-amber-600 dark:text-amber-400"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"m21 7.5-9-5.25L3 7.5m18 0-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9"
/>
</svg>
</div>
<div>
<p
class=
"text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{
t
(
'
usage.totalTokens
'
)
}}
</p>
<p
class=
"text-xl font-bold text-gray-900 dark:text-white"
>
{{
formatTokens
(
usageStats
?.
total_tokens
||
0
)
}}
</p>
<p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
usage.in
'
)
}}
:
{{
formatTokens
(
usageStats
?.
total_input_tokens
||
0
)
}}
/
{{
t
(
'
usage.out
'
)
}}
:
{{
formatTokens
(
usageStats
?.
total_output_tokens
||
0
)
}}
</p>
<p
class=
"text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{
t
(
'
usage.totalTokens
'
)
}}
</p>
<p
class=
"text-xl font-bold text-gray-900 dark:text-white"
>
{{
formatTokens
(
usageStats
?.
total_tokens
||
0
)
}}
</p>
<p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
usage.in
'
)
}}
:
{{
formatTokens
(
usageStats
?.
total_input_tokens
||
0
)
}}
/
{{
t
(
'
usage.out
'
)
}}
:
{{
formatTokens
(
usageStats
?.
total_output_tokens
||
0
)
}}
</p>
</div>
</div>
</div>
...
...
@@ -38,16 +71,32 @@
<!-- Total Cost -->
<div
class=
"card p-4"
>
<div
class=
"flex items-center gap-3"
>
<div
class=
"p-2 rounded-lg bg-green-100 dark:bg-green-900/30"
>
<svg
class=
"w-5 h-5 text-green-600 dark:text-green-400"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
<div
class=
"rounded-lg bg-green-100 p-2 dark:bg-green-900/30"
>
<svg
class=
"h-5 w-5 text-green-600 dark:text-green-400"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<div
class=
"min-w-0 flex-1"
>
<p
class=
"text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{
t
(
'
usage.totalCost
'
)
}}
</p>
<p
class=
"text-xl font-bold text-green-600 dark:text-green-400"
>
$
{{
(
usageStats
?.
total_actual_cost
||
0
).
toFixed
(
4
)
}}
</p>
<p
class=
"text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{
t
(
'
usage.totalCost
'
)
}}
</p>
<p
class=
"text-xl font-bold text-green-600 dark:text-green-400"
>
$
{{
(
usageStats
?.
total_actual_cost
||
0
).
toFixed
(
4
)
}}
</p>
<p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
usage.actualCost
'
)
}}
/
<span
class=
"line-through"
>
$
{{
(
usageStats
?.
total_cost
||
0
).
toFixed
(
4
)
}}
</span>
{{
t
(
'
usage.standardCost
'
)
}}
{{
t
(
'
usage.actualCost
'
)
}}
/
<span
class=
"line-through"
>
$
{{
(
usageStats
?.
total_cost
||
0
).
toFixed
(
4
)
}}
</span>
{{
t
(
'
usage.standardCost
'
)
}}
</p>
</div>
</div>
...
...
@@ -56,14 +105,28 @@
<!-- Average Duration -->
<div
class=
"card p-4"
>
<div
class=
"flex items-center gap-3"
>
<div
class=
"p-2 rounded-lg bg-purple-100 dark:bg-purple-900/30"
>
<svg
class=
"w-5 h-5 text-purple-600 dark:text-purple-400"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
<div
class=
"rounded-lg bg-purple-100 p-2 dark:bg-purple-900/30"
>
<svg
class=
"h-5 w-5 text-purple-600 dark:text-purple-400"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<div>
<p
class=
"text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{
t
(
'
usage.avgDuration
'
)
}}
</p>
<p
class=
"text-xl font-bold text-gray-900 dark:text-white"
>
{{
formatDuration
(
usageStats
?.
average_duration_ms
||
0
)
}}
</p>
<p
class=
"text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{
t
(
'
usage.avgDuration
'
)
}}
</p>
<p
class=
"text-xl font-bold text-gray-900 dark:text-white"
>
{{
formatDuration
(
usageStats
?.
average_duration_ms
||
0
)
}}
</p>
<p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
usage.perRequest
'
)
}}
</p>
</div>
</div>
...
...
@@ -96,17 +159,11 @@
</div>
<!-- Actions -->
<div
class=
"flex items-center gap-3 ml-auto"
>
<button
@
click=
"resetFilters"
class=
"btn btn-secondary"
>
<div
class=
"ml-auto flex items-center gap-3"
>
<button
@
click=
"resetFilters"
class=
"btn btn-secondary"
>
{{
t
(
'
common.reset
'
)
}}
</button>
<button
@
click=
"exportToCSV"
class=
"btn btn-primary"
>
<button
@
click=
"exportToCSV"
class=
"btn btn-primary"
>
{{
t
(
'
usage.exportCsv
'
)
}}
</button>
</div>
...
...
@@ -116,96 +173,167 @@
<!-- Usage Table -->
<div
class=
"card overflow-hidden"
>
<DataTable
:columns=
"columns"
:data=
"usageLogs"
:loading=
"loading"
>
<DataTable
:columns=
"columns"
:data=
"usageLogs"
:loading=
"loading"
>
<template
#cell-model
="
{ value }">
<span
class=
"font-medium text-gray-900 dark:text-white"
>
{{
value
}}
</span>
</
template
>
<
template
#cell-stream=
"{ row }"
>
<span
class=
"inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
:class=
"row.stream
? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200'"
class=
"inline-flex items-center rounded px-2 py-0.5 text-xs font-medium"
:class=
"
row.stream
? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200'
"
>
{{
row
.
stream
?
t
(
'
usage.stream
'
)
:
t
(
'
usage.sync
'
)
}}
</span>
</
template
>
<
template
#cell-tokens=
"{ row }"
>
<div
class=
"
text-sm
space-y-1.5"
>
<div
class=
"space-y-1.5
text-sm
"
>
<!-- Input / Output Tokens -->
<div
class=
"flex items-center gap-2"
>
<!-- Input -->
<div
class=
"inline-flex items-center gap-1"
>
<svg
class=
"w-3.5 h-3.5 text-emerald-500"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M19 14l-7 7m0 0l-7-7m7 7V3"
/>
<svg
class=
"h-3.5 w-3.5 text-emerald-500"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M19 14l-7 7m0 0l-7-7m7 7V3"
/>
</svg>
<span
class=
"font-medium text-gray-900 dark:text-white"
>
{{
row
.
input_tokens
.
toLocaleString
()
}}
</span>
<span
class=
"font-medium text-gray-900 dark:text-white"
>
{{
row
.
input_tokens
.
toLocaleString
()
}}
</span>
</div>
<!-- Output -->
<div
class=
"inline-flex items-center gap-1"
>
<svg
class=
"w-3.5 h-3.5 text-violet-500"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M5 10l7-7m0 0l7 7m-7-7v18"
/>
<svg
class=
"h-3.5 w-3.5 text-violet-500"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M5 10l7-7m0 0l7 7m-7-7v18"
/>
</svg>
<span
class=
"font-medium text-gray-900 dark:text-white"
>
{{
row
.
output_tokens
.
toLocaleString
()
}}
</span>
<span
class=
"font-medium text-gray-900 dark:text-white"
>
{{
row
.
output_tokens
.
toLocaleString
()
}}
</span>
</div>
</div>
<!-- Cache Tokens (Read + Write) -->
<div
v-if=
"row.cache_read_tokens > 0 || row.cache_creation_tokens > 0"
class=
"flex items-center gap-2"
>
<div
v-if=
"row.cache_read_tokens > 0 || row.cache_creation_tokens > 0"
class=
"flex items-center gap-2"
>
<!-- Cache Read -->
<div
v-if=
"row.cache_read_tokens > 0"
class=
"inline-flex items-center gap-1"
>
<svg
class=
"w-3.5 h-3.5 text-sky-500"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"
/>
<svg
class=
"h-3.5 w-3.5 text-sky-500"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"
/>
</svg>
<span
class=
"text-sky-600 dark:text-sky-400 font-medium"
>
{{
formatCacheTokens
(
row
.
cache_read_tokens
)
}}
</span>
<span
class=
"font-medium text-sky-600 dark:text-sky-400"
>
{{
formatCacheTokens
(
row
.
cache_read_tokens
)
}}
</span>
</div>
<!-- Cache Write -->
<div
v-if=
"row.cache_creation_tokens > 0"
class=
"inline-flex items-center gap-1"
>
<svg
class=
"w-3.5 h-3.5 text-amber-500"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
<svg
class=
"h-3.5 w-3.5 text-amber-500"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
<span
class=
"text-amber-600 dark:text-amber-400 font-medium"
>
{{
formatCacheTokens
(
row
.
cache_creation_tokens
)
}}
</span>
<span
class=
"font-medium text-amber-600 dark:text-amber-400"
>
{{
formatCacheTokens
(
row
.
cache_creation_tokens
)
}}
</span>
</div>
</div>
</div>
</
template
>
<
template
#cell-cost=
"{ row }"
>
<div
class=
"
text-sm
flex items-center gap-1.5"
>
<div
class=
"flex items-center gap-1.5
text-sm
"
>
<span
class=
"font-medium text-green-600 dark:text-green-400"
>
$
{{
row
.
actual_cost
.
toFixed
(
6
)
}}
</span>
<!-- Cost Detail Tooltip -->
<div
class=
"relative group"
>
<div
class=
"flex items-center justify-center w-4 h-4 rounded-full bg-gray-100 dark:bg-gray-700 cursor-help transition-colors group-hover:bg-blue-100 dark:group-hover:bg-blue-900/50"
>
<svg
class=
"w-3 h-3 text-gray-400 dark:text-gray-500 group-hover:text-blue-500 dark:group-hover:text-blue-400"
fill=
"currentColor"
viewBox=
"0 0 20 20"
>
<path
fill-rule=
"evenodd"
d=
"M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clip-rule=
"evenodd"
/>
<div
class=
"group relative"
>
<div
class=
"flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-gray-100 transition-colors group-hover:bg-blue-100 dark:bg-gray-700 dark:group-hover:bg-blue-900/50"
>
<svg
class=
"h-3 w-3 text-gray-400 group-hover:text-blue-500 dark:text-gray-500 dark:group-hover:text-blue-400"
fill=
"currentColor"
viewBox=
"0 0 20 20"
>
<path
fill-rule=
"evenodd"
d=
"M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clip-rule=
"evenodd"
/>
</svg>
</div>
<!-- Tooltip Content (right side) -->
<div
class=
"absolute z-[100] invisible group-hover:visible opacity-0 group-hover:opacity-100 transition-all duration-200 left-full top-1/2 -translate-y-1/2 ml-2"
>
<div
class=
"bg-gray-900 dark:bg-gray-800 text-white text-xs rounded-lg py-2.5 px-3 shadow-xl whitespace-nowrap border border-gray-700 dark:border-gray-600"
>
<div
class=
"invisible absolute left-full top-1/2 z-[100] ml-2 -translate-y-1/2 opacity-0 transition-all duration-200 group-hover:visible group-hover:opacity-100"
>
<div
class=
"whitespace-nowrap rounded-lg border border-gray-700 bg-gray-900 px-3 py-2.5 text-xs text-white shadow-xl dark:border-gray-600 dark:bg-gray-800"
>
<div
class=
"space-y-1.5"
>
<div
class=
"flex items-center justify-between gap-6"
>
<span
class=
"text-gray-400"
>
{{
t
(
'
usage.rate
'
)
}}
</span>
<span
class=
"font-semibold text-blue-400"
>
{{
(
row
.
rate_multiplier
||
1
).
toFixed
(
2
)
}}
x
</span>
<span
class=
"font-semibold text-blue-400"
>
{{
(
row
.
rate_multiplier
||
1
).
toFixed
(
2
)
}}
x
</span
>
</div>
<div
class=
"flex items-center justify-between gap-6"
>
<span
class=
"text-gray-400"
>
{{
t
(
'
usage.original
'
)
}}
</span>
<span
class=
"font-medium text-white"
>
$
{{
row
.
total_cost
.
toFixed
(
6
)
}}
</span>
</div>
<div
class=
"flex items-center justify-between gap-6 pt-1.5 border-t border-gray-700"
>
<div
class=
"flex items-center justify-between gap-6 border-t border-gray-700 pt-1.5"
>
<span
class=
"text-gray-400"
>
{{
t
(
'
usage.billed
'
)
}}
</span>
<span
class=
"font-semibold text-green-400"
>
$
{{
row
.
actual_cost
.
toFixed
(
6
)
}}
</span>
<span
class=
"font-semibold text-green-400"
>
$
{{
row
.
actual_cost
.
toFixed
(
6
)
}}
</span
>
</div>
</div>
<!-- Tooltip Arrow (left side) -->
<div
class=
"absolute right-full top-1/2 -translate-y-1/2 w-0 h-0 border-t-[6px] border-t-transparent border-b-[6px] border-b-transparent border-r-[6px] border-r-gray-900 dark:border-r-gray-800"
></div>
<div
class=
"absolute right-full top-1/2 h-0 w-0 -translate-y-1/2 border-b-[6px] border-r-[6px] border-t-[6px] border-b-transparent border-r-gray-900 border-t-transparent dark:border-r-gray-800"
></div>
</div>
</div>
</div>
...
...
@@ -214,28 +342,37 @@
<
template
#cell-billing_type=
"{ row }"
>
<span
class=
"inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
:class=
"row.billing_type === 1
? 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200'
: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200'"
class=
"inline-flex items-center rounded px-2 py-0.5 text-xs font-medium"
:class=
"
row.billing_type === 1
? 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200'
: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200'
"
>
{{
row
.
billing_type
===
1
?
t
(
'
usage.subscription
'
)
:
t
(
'
usage.balance
'
)
}}
</span>
</
template
>
<
template
#cell-first_token=
"{ row }"
>
<span
v-if=
"row.first_token_ms != null"
class=
"text-sm text-gray-600 dark:text-gray-400"
>
<span
v-if=
"row.first_token_ms != null"
class=
"text-sm text-gray-600 dark:text-gray-400"
>
{{
formatDuration
(
row
.
first_token_ms
)
}}
</span>
<span
v-else
class=
"text-sm text-gray-400 dark:text-gray-500"
>
-
</span>
</
template
>
<
template
#cell-duration=
"{ row }"
>
<span
class=
"text-sm text-gray-600 dark:text-gray-400"
>
{{
formatDuration
(
row
.
duration_ms
)
}}
</span>
<span
class=
"text-sm text-gray-600 dark:text-gray-400"
>
{{
formatDuration
(
row
.
duration_ms
)
}}
</span>
</
template
>
<
template
#cell-created_at=
"{ value }"
>
<span
class=
"text-sm text-gray-600 dark:text-gray-400"
>
{{
formatDateTime
(
value
)
}}
</span>
<span
class=
"text-sm text-gray-600 dark:text-gray-400"
>
{{
formatDateTime
(
value
)
}}
</span>
</
template
>
<
template
#empty
>
...
...
@@ -294,7 +431,7 @@ const loading = ref(false)
const
apiKeyOptions
=
computed
(()
=>
{
return
[
{
value
:
null
,
label
:
t
(
'
usage.allApiKeys
'
)
},
...
apiKeys
.
value
.
map
(
key
=>
({
...
apiKeys
.
value
.
map
(
(
key
)
=>
({
value
:
key
.
id
,
label
:
key
.
name
}))
...
...
@@ -325,7 +462,11 @@ const initializeDateRange = () => {
}
// Handle date range change from DateRangePicker
const
onDateRangeChange
=
(
range
:
{
startDate
:
string
;
endDate
:
string
;
preset
:
string
|
null
})
=>
{
const
onDateRangeChange
=
(
range
:
{
startDate
:
string
endDate
:
string
preset
:
string
|
null
})
=>
{
filters
.
value
.
start_date
=
range
.
startDate
filters
.
value
.
end_date
=
range
.
endDate
applyFilters
()
...
...
@@ -448,8 +589,20 @@ const exportToCSV = () => {
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
=>
[
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
,
...
...
@@ -463,10 +616,7 @@ const exportToCSV = () => {
log
.
created_at
])
const
csvContent
=
[
headers
.
join
(
'
,
'
),
...
rows
.
map
(
row
=>
row
.
join
(
'
,
'
))
].
join
(
'
\n
'
)
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
)
...
...
Prev
1
2
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