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
195e227c
Commit
195e227c
authored
Jan 06, 2026
by
song
Browse files
merge: 合并 upstream/main 并保留本地图片计费功能
parents
6fa704d6
752882a0
Changes
187
Show whitespace changes
Inline
Side-by-side
frontend/src/views/user/KeysView.vue
View file @
195e227c
...
...
@@ -9,30 +9,10 @@
class=
"btn btn-secondary"
:title=
"t('common.refresh')"
>
<svg
: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"
/>
</svg>
<Icon
name=
"refresh"
size=
"md"
:class=
"loading ? 'animate-spin' : ''"
/>
</button>
<button
@
click=
"showCreateModal = true"
class=
"btn btn-primary"
data-tour=
"keys-create-btn"
>
<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>
<Icon
name=
"plus"
size=
"md"
class=
"mr-2"
/>
{{
t
(
'
keys.createKey
'
)
}}
</button>
</div>
...
...
@@ -55,30 +35,13 @@
"
:title=
"copiedKeyId === row.id ? t('keys.copied') : t('keys.copyToClipboard')"
>
<
svg
<
Icon
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=
"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"
name=
"check"
size=
"sm"
:stroke-width=
"2"
/>
<
/svg
>
<
Icon
v-else
name=
"clipboard"
size=
"sm"
/
>
</button>
</div>
</
template
>
...
...
@@ -141,7 +104,7 @@
<
template
#cell-status=
"{ value }"
>
<span
:class=
"['badge', value === 'active' ? 'badge-success' : 'badge-gray']"
>
{{
value
}}
{{
t
(
'
admin.accounts.status.
'
+
value
)
}}
</span>
</
template
>
...
...
@@ -156,19 +119,7 @@
@
click=
"openUseKeyModal(row)"
class=
"flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-green-50 hover:text-green-600 dark:hover:bg-green-900/20 dark:hover:text-green-400"
>
<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>
<Icon
name=
"terminal"
size=
"sm"
/>
<span
class=
"text-xs"
>
{{
t
(
'
keys.useKey
'
)
}}
</span>
</button>
<!-- Import to CC Switch Button -->
...
...
@@ -176,19 +127,7 @@
@
click=
"importToCcswitch(row)"
class=
"flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-900/20 dark:hover:text-blue-400"
>
<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>
<Icon
name=
"upload"
size=
"sm"
/>
<span
class=
"text-xs"
>
{{
t
(
'
keys.importToCcSwitch
'
)
}}
</span>
</button>
<!-- Toggle Status Button -->
...
...
@@ -201,34 +140,8 @@
: 'text-gray-500 hover:bg-green-50 hover:text-green-600 dark:hover:bg-green-900/20 dark:hover:text-green-400'
]"
>
<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=
"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>
<Icon
v-if=
"row.status === 'active'"
name=
"ban"
size=
"sm"
/>
<Icon
v-else
name=
"checkCircle"
size=
"sm"
/>
<span
class=
"text-xs"
>
{{
row
.
status
===
'
active
'
?
t
(
'
keys.disable
'
)
:
t
(
'
keys.enable
'
)
}}
</span>
</button>
<!-- Edit Button -->
...
...
@@ -236,19 +149,7 @@
@
click=
"editKey(row)"
class=
"flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400"
>
<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>
<Icon
name=
"edit"
size=
"sm"
/>
<span
class=
"text-xs"
>
{{
t
(
'
common.edit
'
)
}}
</span>
</button>
<!-- Delete Button -->
...
...
@@ -256,19 +157,7 @@
@
click=
"confirmDelete(row)"
class=
"flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
>
<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>
<Icon
name=
"trash"
size=
"sm"
/>
<span
class=
"text-xs"
>
{{
t
(
'
common.delete
'
)
}}
</span>
</button>
</div>
...
...
@@ -335,12 +224,14 @@
/>
<span
v-else
class=
"text-gray-400"
>
{{
t
(
'
keys.selectGroup
'
)
}}
</span>
</
template
>
<
template
#option=
"{ option }"
>
<Group
Badge
<
template
#option=
"{ option
, selected
}"
>
<Group
OptionItem
:name=
"(option as unknown as GroupOption).label"
:platform=
"(option as unknown as GroupOption).platform"
:subscription-type=
"(option as unknown as GroupOption).subscriptionType"
:rate-multiplier=
"(option as unknown as GroupOption).rate"
:description=
"(option as unknown as GroupOption).description"
:selected=
"selected"
/>
</
template
>
</Select>
...
...
@@ -469,21 +360,25 @@
@
click=
"handleCcsClientSelect('claude')"
class=
"flex flex-col items-center gap-2 p-4 rounded-xl border-2 border-gray-200 dark:border-dark-600 hover:border-primary-500 dark:hover:border-primary-500 hover:bg-primary-50 dark:hover:bg-primary-900/20 transition-all"
>
<svg
class=
"w-8 h-8 text-gray-600 dark:text-gray-400"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"m6.75 7.5 3 2.25-3 2.25m4.5 0h3m-9 8.25h13.5A2.25 2.25 0 0 0 21 17.25V6.75A2.25 2.25 0 0 0 18.75 4.5H5.25A2.25 2.25 0 0 0 3 6.75v10.5A2.25 2.25 0 0 0 5.25 20.25Z"
/>
</svg>
<span
class=
"font-medium text-gray-900 dark:text-white"
>
{{ t('keys.ccsClientSelect.claudeCode') }}
</span>
<span
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{ t('keys.ccsClientSelect.claudeCodeDesc') }}
</span>
<Icon
name=
"terminal"
size=
"xl"
class=
"text-gray-600 dark:text-gray-400"
/>
<span
class=
"font-medium text-gray-900 dark:text-white"
>
{{
t('keys.ccsClientSelect.claudeCode')
}}
</span>
<span
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t('keys.ccsClientSelect.claudeCodeDesc')
}}
</span>
</button>
<button
@
click=
"handleCcsClientSelect('gemini')"
class=
"flex flex-col items-center gap-2 p-4 rounded-xl border-2 border-gray-200 dark:border-dark-600 hover:border-primary-500 dark:hover:border-primary-500 hover:bg-primary-50 dark:hover:bg-primary-900/20 transition-all"
>
<svg
class=
"w-8 h-8 text-gray-600 dark:text-gray-400"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M9.813 15.904 9 18.75l-.813-2.846a4.5 4.5 0 0 0-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 0 0 3.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 0 0 3.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 0 0-3.09 3.09ZM18.259 8.715 18 9.75l-.259-1.035a3.375 3.375 0 0 0-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 0 0 2.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 0 0 2.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 0 0-2.456 2.456ZM16.894 20.567 16.5 21.75l-.394-1.183a2.25 2.25 0 0 0-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 0 0 1.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 0 0 1.423 1.423l1.183.394-1.183.394a2.25 2.25 0 0 0-1.423 1.423Z"
/>
</svg>
<span
class=
"font-medium text-gray-900 dark:text-white"
>
{{ t('keys.ccsClientSelect.geminiCli') }}
</span>
<span
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{ t('keys.ccsClientSelect.geminiCliDesc') }}
</span>
<Icon
name=
"sparkles"
size=
"xl"
class=
"text-gray-600 dark:text-gray-400"
/>
<span
class=
"font-medium text-gray-900 dark:text-white"
>
{{
t('keys.ccsClientSelect.geminiCli')
}}
</span>
<span
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t('keys.ccsClientSelect.geminiCliDesc')
}}
</span>
</button>
</div>
</div>
...
...
@@ -501,7 +396,8 @@
<div
v-if=
"groupSelectorKeyId !== null && dropdownPosition"
ref=
"dropdownRef"
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 duration-200 dark:bg-dark-800 dark:ring-white/10"
class=
"animate-in fade-in slide-in-from-top-2 fixed z-[100000020] w-64 overflow-hidden rounded-xl bg-white shadow-lg ring-1 ring-black/5 duration-200 dark:bg-dark-800 dark:ring-white/10"
style=
"pointer-events: auto !important;"
:style=
"{ top: dropdownPosition.top + 'px', left: dropdownPosition.left + 'px' }"
>
<div
class=
"max-h-64 overflow-y-auto p-1.5"
>
...
...
@@ -516,26 +412,19 @@
? 'bg-primary-50 dark:bg-primary-900/20'
: 'hover:bg-gray-100 dark:hover:bg-dark-700'
]"
:title=
"option.description || undefined"
>
<Group
Badge
<Group
OptionItem
:name=
"option.label"
:platform=
"option.platform"
:subscription-type=
"option.subscriptionType"
:rate-multiplier=
"option.rate"
/>
<svg
v-if=
"
:description=
"option.description"
:selected=
"
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"
stroke-width=
"2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M5 13l4 4L19 7"
/>
</svg>
/>
</button>
</div>
</div>
...
...
@@ -544,25 +433,27 @@
</template>
<
script
setup
lang=
"ts"
>
import
{
ref
,
computed
,
onMounted
,
onUnmounted
,
type
ComponentPublicInstance
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
useOnboardingStore
}
from
'
@/stores/onboarding
'
import
{
useClipboard
}
from
'
@/composables/useClipboard
'
import
{
ref
,
computed
,
onMounted
,
onUnmounted
,
type
ComponentPublicInstance
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
useOnboardingStore
}
from
'
@/stores/onboarding
'
import
{
useClipboard
}
from
'
@/composables/useClipboard
'
const
{
t
}
=
useI18n
()
import
{
keysAPI
,
authAPI
,
usageAPI
,
userGroupsAPI
}
from
'
@/api
'
import
AppLayout
from
'
@/components/layout/AppLayout.vue
'
import
TablePageLayout
from
'
@/components/layout/TablePageLayout.vue
'
import
DataTable
from
'
@/components/common/DataTable.vue
'
import
Pagination
from
'
@/components/common/Pagination.vue
'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
import
ConfirmDialog
from
'
@/components/common/ConfirmDialog.vue
'
import
EmptyState
from
'
@/components/common/EmptyState.vue
'
import
Select
from
'
@/components/common/Select.vue
'
import
UseKeyModal
from
'
@/components/keys/UseKeyModal.vue
'
import
GroupBadge
from
'
@/components/common/GroupBadge.vue
'
import
type
{
ApiKey
,
Group
,
PublicSettings
,
SubscriptionType
,
GroupPlatform
}
from
'
@/types
'
import
DataTable
from
'
@/components/common/DataTable.vue
'
import
Pagination
from
'
@/components/common/Pagination.vue
'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
import
ConfirmDialog
from
'
@/components/common/ConfirmDialog.vue
'
import
EmptyState
from
'
@/components/common/EmptyState.vue
'
import
Select
from
'
@/components/common/Select.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
UseKeyModal
from
'
@/components/keys/UseKeyModal.vue
'
import
GroupBadge
from
'
@/components/common/GroupBadge.vue
'
import
GroupOptionItem
from
'
@/components/common/GroupOptionItem.vue
'
import
type
{
ApiKey
,
Group
,
PublicSettings
,
SubscriptionType
,
GroupPlatform
}
from
'
@/types
'
import
type
{
Column
}
from
'
@/components/common/types
'
import
type
{
BatchApiKeyUsageStats
}
from
'
@/api/usage
'
import
{
formatDateTime
}
from
'
@/utils/format
'
...
...
@@ -570,6 +461,7 @@ import { formatDateTime } from '@/utils/format'
interface
GroupOption
{
value
:
number
label
:
string
description
:
string
|
null
rate
:
number
subscriptionType
:
SubscriptionType
platform
:
GroupPlatform
...
...
@@ -665,6 +557,7 @@ const groupOptions = computed(() =>
groups
.
value
.
map
((
group
)
=>
({
value
:
group
.
id
,
label
:
group
.
name
,
description
:
group
.
description
,
rate
:
group
.
rate_multiplier
,
subscriptionType
:
group
.
subscription_type
,
platform
:
group
.
platform
...
...
frontend/src/views/user/ProfileView.vue
View file @
195e227c
<
template
>
<AppLayout>
<div
class=
"mx-auto max-w-4xl space-y-6"
>
<!-- Account Stats Summary -->
<div
class=
"grid grid-cols-1 gap-6 sm:grid-cols-3"
>
<StatCard
:title=
"t('profile.accountBalance')"
:value=
"formatCurrency(user?.balance || 0)"
:icon=
"WalletIcon"
icon-variant=
"success"
/>
<StatCard
:title=
"t('profile.concurrencyLimit')"
:value=
"user?.concurrency || 0"
:icon=
"BoltIcon"
icon-variant=
"warning"
/>
<StatCard
:title=
"t('profile.memberSince')"
:value=
"formatDate(user?.created_at || '', 'YYYY-MM')"
:icon=
"CalendarIcon"
icon-variant=
"primary"
/>
<StatCard
:title=
"t('profile.accountBalance')"
:value=
"formatCurrency(user?.balance || 0)"
:icon=
"WalletIcon"
icon-variant=
"success"
/>
<StatCard
:title=
"t('profile.concurrencyLimit')"
:value=
"user?.concurrency || 0"
:icon=
"BoltIcon"
icon-variant=
"warning"
/>
<StatCard
:title=
"t('profile.memberSince')"
:value=
"formatDate(user?.created_at || '',
{ year: 'numeric', month: 'long' })" :icon="CalendarIcon" icon-variant="primary" />
</div>
<!-- User Information -->
<div
class=
"card overflow-hidden"
>
<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=
"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=
"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']"
>
{{
user
?.
status
}}
</span>
</div>
</div>
</div>
</div>
<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=
"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=
"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>
</div>
</div>
<!-- Contact Support Section -->
<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"
>
<ProfileInfoCard
:user=
"user"
/>
<div
v-if=
"contactInfo"
class=
"card border-primary-200 bg-primary-50 dark:bg-primary-900/20 p-6"
>
<div
class=
"flex items-center gap-4"
>
<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=
"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>
</div>
<!-- Edit Profile Section -->
<div
class=
"card"
>
<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"
>
<div>
<label
for=
"username"
class=
"input-label"
>
{{
t
(
'
profile.username
'
)
}}
</label>
<input
id=
"username"
v-model=
"profileForm.username"
type=
"text"
class=
"input"
:placeholder=
"t('profile.enterUsername')"
/>
</div>
<div
class=
"flex justify-end pt-4"
>
<button
type=
"submit"
:disabled=
"updatingProfile"
class=
"btn btn-primary"
>
{{
updatingProfile
?
t
(
'
profile.updating
'
)
:
t
(
'
profile.updateProfile
'
)
}}
</button>
</div>
</form>
</div>
</div>
<!-- Change Password Section -->
<div
class=
"card"
>
<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"
>
<div>
<label
for=
"old_password"
class=
"input-label"
>
{{
t
(
'
profile.currentPassword
'
)
}}
</label>
<input
id=
"old_password"
v-model=
"passwordForm.old_password"
type=
"password"
required
autocomplete=
"current-password"
class=
"input"
/>
</div>
<div>
<label
for=
"new_password"
class=
"input-label"
>
{{
t
(
'
profile.newPassword
'
)
}}
</label>
<input
id=
"new_password"
v-model=
"passwordForm.new_password"
type=
"password"
required
autocomplete=
"new-password"
class=
"input"
/>
<p
class=
"input-hint"
>
{{
t
(
'
profile.passwordHint
'
)
}}
</p>
</div>
<div>
<label
for=
"confirm_password"
class=
"input-label"
>
{{
t
(
'
profile.confirmNewPassword
'
)
}}
</label>
<input
id=
"confirm_password"
v-model=
"passwordForm.confirm_password"
type=
"password"
required
autocomplete=
"new-password"
class=
"input"
/>
<p
v-if=
"passwordForm.new_password && passwordForm.confirm_password && passwordForm.new_password !== passwordForm.confirm_password"
class=
"input-error-text"
>
{{
t
(
'
profile.passwordsNotMatch
'
)
}}
</p>
</div>
<div
class=
"flex justify-end pt-4"
>
<button
type=
"submit"
:disabled=
"changingPassword"
class=
"btn btn-primary"
>
{{
changingPassword
?
t
(
'
profile.changingPassword
'
)
:
t
(
'
profile.changePasswordButton
'
)
}}
</button>
</div>
</form>
<div
class=
"p-3 bg-primary-100 rounded-xl text-primary-600"
><Icon
name=
"chat"
size=
"lg"
/></div>
<div><h3
class=
"font-semibold text-primary-800 dark:text-primary-200"
>
{{
t
(
'
common.contactSupport
'
)
}}
</h3><p
class=
"text-sm font-medium"
>
{{
contactInfo
}}
</p></div>
</div>
</div>
<ProfileEditForm
:initial-username=
"user?.username || ''"
/>
<ProfilePasswordForm
/>
</div>
</AppLayout>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
ref
,
computed
,
h
,
onMounted
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAuthStore
}
from
'
@/stores/auth
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
formatDate
}
from
'
@/utils/format
'
const
{
t
}
=
useI18n
()
import
{
userAPI
,
authAPI
}
from
'
@/api
'
import
AppLayout
from
'
@/components/layout/AppLayout.vue
'
import
{
ref
,
computed
,
h
,
onMounted
}
from
'
vue
'
;
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAuthStore
}
from
'
@/stores/auth
'
;
import
{
formatDate
}
from
'
@/utils/format
'
import
{
authAPI
}
from
'
@/api
'
;
import
AppLayout
from
'
@/components/layout/AppLayout.vue
'
import
StatCard
from
'
@/components/common/StatCard.vue
'
import
ProfileInfoCard
from
'
@/components/user/profile/ProfileInfoCard.vue
'
import
ProfileEditForm
from
'
@/components/user/profile/ProfileEditForm.vue
'
import
ProfilePasswordForm
from
'
@/components/user/profile/ProfilePasswordForm.vue
'
import
{
Icon
}
from
'
@/components/icons
'
// 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
'
})
]
)
}
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
'
})
]
)
}
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
'
})
]
)
}
const
authStore
=
useAuthStore
()
const
appStore
=
useAppStore
()
const
user
=
computed
(()
=>
authStore
.
user
)
const
passwordForm
=
ref
({
old_password
:
''
,
new_password
:
''
,
confirm_password
:
''
})
const
profileForm
=
ref
({
username
:
''
})
const
changingPassword
=
ref
(
false
)
const
updatingProfile
=
ref
(
false
)
const
{
t
}
=
useI18n
();
const
authStore
=
useAuthStore
();
const
user
=
computed
(()
=>
authStore
.
user
)
const
contactInfo
=
ref
(
''
)
onMounted
(
async
()
=>
{
try
{
const
settings
=
await
authAPI
.
getPublicSettings
()
contactInfo
.
value
=
settings
.
contact_info
||
''
// Initialize profile form with current user data
if
(
user
.
value
)
{
profileForm
.
value
.
username
=
user
.
value
.
username
||
''
}
}
catch
(
error
)
{
console
.
error
(
'
Failed to load contact info:
'
,
error
)
}
})
const
formatCurrency
=
(
value
:
number
):
string
=>
{
return
`$
${
value
.
toFixed
(
2
)}
`
}
const
handleChangePassword
=
async
()
=>
{
// Validate password match
if
(
passwordForm
.
value
.
new_password
!==
passwordForm
.
value
.
confirm_password
)
{
appStore
.
showError
(
t
(
'
profile.passwordsNotMatch
'
))
return
}
// Validate password length
if
(
passwordForm
.
value
.
new_password
.
length
<
8
)
{
appStore
.
showError
(
t
(
'
profile.passwordTooShort
'
))
return
}
changingPassword
.
value
=
true
try
{
await
userAPI
.
changePassword
(
passwordForm
.
value
.
old_password
,
passwordForm
.
value
.
new_password
)
// Clear form
passwordForm
.
value
=
{
old_password
:
''
,
new_password
:
''
,
confirm_password
:
''
}
appStore
.
showSuccess
(
t
(
'
profile.passwordChangeSuccess
'
))
}
catch
(
error
:
any
)
{
appStore
.
showError
(
error
.
response
?.
data
?.
detail
||
t
(
'
profile.passwordChangeFailed
'
))
}
finally
{
changingPassword
.
value
=
false
}
}
const
handleUpdateProfile
=
async
()
=>
{
// Basic validation
if
(
!
profileForm
.
value
.
username
.
trim
())
{
appStore
.
showError
(
t
(
'
profile.usernameRequired
'
))
return
}
updatingProfile
.
value
=
true
try
{
const
updatedUser
=
await
userAPI
.
updateProfile
({
username
:
profileForm
.
value
.
username
})
// Update auth store with new user data
authStore
.
user
=
updatedUser
const
WalletIcon
=
{
render
:
()
=>
h
(
'
svg
'
,
{
fill
:
'
none
'
,
viewBox
:
'
0 0 24 24
'
,
stroke
:
'
currentColor
'
,
'
stroke-width
'
:
'
1.5
'
},
[
h
(
'
path
'
,
{
d
:
'
M21 12a2.25 2.25 0 00-2.25-2.25H15a3 3 0 11-6 0H5.25A2.25 2.25 0 003 12
'
})])
}
const
BoltIcon
=
{
render
:
()
=>
h
(
'
svg
'
,
{
fill
:
'
none
'
,
viewBox
:
'
0 0 24 24
'
,
stroke
:
'
currentColor
'
,
'
stroke-width
'
:
'
1.5
'
},
[
h
(
'
path
'
,
{
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
'
,
{
d
:
'
M6.75 3v2.25M17.25 3v2.25
'
})])
}
appStore
.
showSuccess
(
t
(
'
profile.updateSuccess
'
))
}
catch
(
error
:
any
)
{
appStore
.
showError
(
error
.
response
?.
data
?.
detail
||
t
(
'
profile.updateFailed
'
))
}
finally
{
updatingProfile
.
value
=
false
}
}
onMounted
(
async
()
=>
{
try
{
const
s
=
await
authAPI
.
getPublicSettings
();
contactInfo
.
value
=
s
.
contact_info
||
''
}
catch
{}
})
const
formatCurrency
=
(
v
:
number
)
=>
`$
${
v
.
toFixed
(
2
)}
`
</
script
>
\ No newline at end of file
frontend/src/views/user/RedeemView.vue
View file @
195e227c
...
...
@@ -7,19 +7,7 @@
<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>
<Icon
name=
"creditCard"
size=
"xl"
class=
"text-white"
/>
</div>
<p
class=
"text-sm font-medium text-primary-100"
>
{{
t
(
'
redeem.currentBalance
'
)
}}
</p>
<p
class=
"mt-2 text-4xl font-bold text-white"
>
...
...
@@ -41,19 +29,7 @@
</label>
<div
class=
"relative mt-1"
>
<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>
<Icon
name=
"gift"
size=
"md"
class=
"text-gray-400 dark:text-dark-500"
/>
</div>
<input
id=
"code"
...
...
@@ -95,20 +71,7 @@
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=
"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>
<Icon
v-else
name=
"checkCircle"
size=
"md"
class=
"mr-2"
/>
{{
submitting
?
t
(
'
redeem.redeeming
'
)
:
t
(
'
redeem.redeemButton
'
)
}}
</button>
</form>
...
...
@@ -126,19 +89,7 @@
<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>
<Icon
name=
"checkCircle"
size=
"md"
class=
"text-emerald-600 dark:text-emerald-400"
/>
</div>
<div
class=
"flex-1"
>
<h3
class=
"text-sm font-semibold text-emerald-800 dark:text-emerald-300"
>
...
...
@@ -192,19 +143,11 @@
<
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
"
<
Icon
name
=
"
exclamationCircle
"
size
=
"
md
"
class
=
"
text-red-600 dark:text-red-400
"
/>
<
/svg
>
<
/div
>
<
div
class
=
"
flex-1
"
>
<
h3
class
=
"
text-sm font-semibold text-red-800 dark:text-red-300
"
>
...
...
@@ -228,19 +171,7 @@
<
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
>
<
Icon
name
=
"
infoCircle
"
size
=
"
md
"
class
=
"
text-primary-600 dark:text-primary-400
"
/>
<
/div
>
<
div
class
=
"
flex-1
"
>
<
h3
class
=
"
text-sm font-semibold text-primary-800 dark:text-primary-300
"
>
...
...
@@ -317,60 +248,34 @@
]
"
>
<!--
余额类型图标
-->
<
svg
<
Icon
v
-
if
=
"
isBalanceType(item.type)
"
:
class
=
"
[
'h-5 w-5',
name
=
"
dollar
"
size
=
"
md
"
:
class
=
"
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
"
"
/>
<
/svg
>
<!--
订阅类型图标
-->
<
svg
<
Icon
v
-
else
-
if
=
"
isSubscriptionType(item.type)
"
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
"
name
=
"
badge
"
size
=
"
md
"
class
=
"
text-purple-600 dark:text-purple-400
"
/>
<
/svg
>
<!--
并发类型图标
-->
<
svg
<
Icon
v
-
else
:
class
=
"
[
'h-5 w-5',
name
=
"
bolt
"
size
=
"
md
"
:
class
=
"
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
"
"
/>
<
/svg
>
<
/div
>
<
div
>
<
p
class
=
"
text-sm font-medium text-gray-900 dark:text-white
"
>
...
...
@@ -416,19 +321,7 @@
<
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
>
<
Icon
name
=
"
clock
"
size
=
"
xl
"
class
=
"
text-gray-400 dark:text-dark-500
"
/>
<
/div
>
<
p
class
=
"
text-sm text-gray-500 dark:text-dark-400
"
>
{{
t
(
'
redeem.historyWillAppear
'
)
}}
...
...
@@ -448,6 +341,7 @@ import { useAppStore } from '@/stores/app'
import
{
useSubscriptionStore
}
from
'
@/stores/subscriptions
'
import
{
redeemAPI
,
authAPI
,
type
RedeemHistoryItem
}
from
'
@/api
'
import
AppLayout
from
'
@/components/layout/AppLayout.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
{
formatDateTime
}
from
'
@/utils/format
'
const
{
t
}
=
useI18n
()
...
...
@@ -531,6 +425,7 @@ const fetchHistory = async () => {
const
handleRedeem
=
async
()
=>
{
if
(
!
redeemCode
.
value
.
trim
())
{
appStore
.
showError
(
t
(
'
redeem.pleaseEnterCode
'
))
return
}
...
...
frontend/src/views/user/SubscriptionsView.vue
View file @
195e227c
...
...
@@ -13,19 +13,7 @@
<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>
<Icon
name=
"creditCard"
size=
"xl"
class=
"text-gray-400"
/>
</div>
<h3
class=
"mb-2 text-lg font-semibold text-gray-900 dark:text-white"
>
{{
t
(
'
userSubscriptions.noActiveSubscriptions
'
)
}}
...
...
@@ -50,19 +38,7 @@
<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>
<Icon
name=
"creditCard"
size=
"md"
class=
"text-purple-600 dark:text-purple-400"
/>
</div>
<div>
<h3
class=
"font-semibold text-gray-900 dark:text-white"
>
...
...
@@ -265,6 +241,7 @@ import { useAppStore } from '@/stores/app'
import
subscriptionsAPI
from
'
@/api/subscriptions
'
import
type
{
UserSubscription
}
from
'
@/types
'
import
AppLayout
from
'
@/components/layout/AppLayout.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
{
formatDateOnly
}
from
'
@/utils/format
'
const
{
t
}
=
useI18n
()
...
...
frontend/src/views/user/UsageView.vue
View file @
195e227c
...
...
@@ -7,19 +7,7 @@
<div
class=
"card p-4"
>
<div
class=
"flex items-center gap-3"
>
<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>
<Icon
name=
"document"
size=
"md"
class=
"text-blue-600 dark:text-blue-400"
/>
</div>
<div>
<p
class=
"text-xs font-medium text-gray-500 dark:text-gray-400"
>
...
...
@@ -39,19 +27,7 @@
<div
class=
"card p-4"
>
<div
class=
"flex items-center gap-3"
>
<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>
<Icon
name=
"cube"
size=
"md"
class=
"text-amber-600 dark:text-amber-400"
/>
</div>
<div>
<p
class=
"text-xs font-medium text-gray-500 dark:text-gray-400"
>
...
...
@@ -72,19 +48,7 @@
<div
class=
"card p-4"
>
<div
class=
"flex items-center gap-3"
>
<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>
<Icon
name=
"dollar"
size=
"md"
class=
"text-green-600 dark:text-green-400"
/>
</div>
<div
class=
"min-w-0 flex-1"
>
<p
class=
"text-xs font-medium text-gray-500 dark:text-gray-400"
>
...
...
@@ -106,19 +70,7 @@
<div
class=
"card p-4"
>
<div
class=
"flex items-center gap-3"
>
<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>
<Icon
name=
"clock"
size=
"md"
class=
"text-purple-600 dark:text-purple-400"
/>
</div>
<div>
<p
class=
"text-xs font-medium text-gray-500 dark:text-gray-400"
>
...
...
@@ -244,38 +196,14 @@
<div
class=
"flex items-center gap-2"
>
<!-- Input -->
<div
class=
"inline-flex items-center gap-1"
>
<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>
<Icon
name=
"arrowDown"
size=
"sm"
class=
"text-emerald-500"
/>
<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=
"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>
<Icon
name=
"arrowUp"
size=
"sm"
class=
"text-violet-500"
/>
<span
class=
"font-medium text-gray-900 dark:text-white"
>
{{
row
.
output_tokens
.
toLocaleString
()
}}
</span>
...
...
@@ -288,38 +216,14 @@
>
<!-- Cache Read -->
<div
v-if=
"row.cache_read_tokens > 0"
class=
"inline-flex items-center gap-1"
>
<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>
<Icon
name=
"inbox"
size=
"sm"
class=
"text-sky-500"
/>
<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=
"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>
<Icon
name=
"edit"
size=
"sm"
class=
"text-amber-500"
/>
<span
class=
"font-medium text-amber-600 dark:text-amber-400"
>
{{
formatCacheTokens
(
row
.
cache_creation_tokens
)
}}
</span>
...
...
@@ -335,17 +239,11 @@
<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"
<Icon
name=
"infoCircle"
size=
"xs"
class=
"text-gray-400 group-hover:text-blue-500 dark:text-gray-500 dark:group-hover:text-blue-400"
/>
</svg>
</div>
</div>
</div>
...
...
@@ -365,17 +263,11 @@
<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"
<Icon
name=
"infoCircle"
size=
"xs"
class=
"text-gray-400 group-hover:text-blue-500 dark:text-gray-500 dark:group-hover:text-blue-400"
/>
</svg>
</div>
</div>
</div>
...
...
@@ -535,6 +427,7 @@ import Pagination from '@/components/common/Pagination.vue'
import
EmptyState
from
'
@/components/common/EmptyState.vue
'
import
Select
from
'
@/components/common/Select.vue
'
import
DateRangePicker
from
'
@/components/common/DateRangePicker.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
type
{
UsageLog
,
ApiKey
,
UsageQueryParams
,
UsageStatsResponse
}
from
'
@/types
'
import
type
{
Column
}
from
'
@/components/common/types
'
import
{
formatDateTime
}
from
'
@/utils/format
'
...
...
frontend/vite.config.js
deleted
100644 → 0
View file @
6fa704d6
import
{
defineConfig
}
from
'
vite
'
;
import
vue
from
'
@vitejs/plugin-vue
'
;
import
checker
from
'
vite-plugin-checker
'
;
import
{
resolve
}
from
'
path
'
;
export
default
defineConfig
({
plugins
:
[
vue
(),
checker
({
typescript
:
true
,
vueTsc
:
true
})
],
resolve
:
{
alias
:
{
'
@
'
:
resolve
(
__dirname
,
'
src
'
)
}
},
build
:
{
outDir
:
'
../backend/internal/web/dist
'
,
emptyOutDir
:
true
},
server
:
{
host
:
'
0.0.0.0
'
,
port
:
3000
,
proxy
:
{
'
/api
'
:
{
target
:
'
http://localhost:8080
'
,
changeOrigin
:
true
},
'
/setup
'
:
{
target
:
'
http://localhost:8080
'
,
changeOrigin
:
true
}
}
}
});
frontend/vite.config.ts
View file @
195e227c
...
...
@@ -13,9 +13,16 @@ export default defineConfig({
],
resolve
:
{
alias
:
{
'
@
'
:
resolve
(
__dirname
,
'
src
'
)
'
@
'
:
resolve
(
__dirname
,
'
src
'
),
// 使用 vue-i18n 运行时版本,避免 CSP unsafe-eval 问题
'
vue-i18n
'
:
'
vue-i18n/dist/vue-i18n.runtime.esm-bundler.js
'
}
},
define
:
{
// 启用 vue-i18n JIT 编译,在 CSP 环境下处理消息插值
// JIT 编译器生成 AST 对象而非 JS 代码,无需 unsafe-eval
__INTLIFY_JIT_COMPILATION__
:
true
},
build
:
{
outDir
:
'
../backend/internal/web/dist
'
,
emptyOutDir
:
true
...
...
@@ -25,11 +32,11 @@ export default defineConfig({
port
:
3000
,
proxy
:
{
'
/api
'
:
{
target
:
'
http://localhost:8080
'
,
target
:
process
.
env
.
VITE_DEV_PROXY_TARGET
||
'
http://localhost:8080
'
,
changeOrigin
:
true
},
'
/setup
'
:
{
target
:
'
http://localhost:8080
'
,
target
:
process
.
env
.
VITE_DEV_PROXY_TARGET
||
'
http://localhost:8080
'
,
changeOrigin
:
true
}
}
...
...
Prev
1
…
6
7
8
9
10
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