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
fb313356
Commit
fb313356
authored
Jan 05, 2026
by
yangjianbo
Browse files
Merge branch 'main' into test-dev
parents
048ed061
91f9d4c7
Changes
84
Show whitespace changes
Inline
Side-by-side
frontend/src/components/admin/user/UserAllowedGroupsModal.vue
0 → 100644
View file @
fb313356
<
template
>
<BaseDialog
:show=
"show"
:title=
"t('admin.users.setAllowedGroups')"
width=
"normal"
@
close=
"$emit('close')"
>
<div
v-if=
"user"
class=
"space-y-4"
>
<div
class=
"flex items-center gap-3 rounded-xl bg-gray-50 p-4 dark:bg-dark-700"
>
<div
class=
"flex h-10 w-10 items-center justify-center rounded-full bg-primary-100"
>
<span
class=
"text-lg font-medium text-primary-700"
>
{{
user
.
email
.
charAt
(
0
).
toUpperCase
()
}}
</span>
</div>
<p
class=
"font-medium text-gray-900 dark:text-white"
>
{{
user
.
email
}}
</p>
</div>
<div
v-if=
"loading"
class=
"flex justify-center py-8"
><svg
class=
"h-8 w-8 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>
<div
v-else
>
<p
class=
"mb-3 text-sm text-gray-600"
>
{{
t
(
'
admin.users.allowedGroupsHint
'
)
}}
</p>
<div
class=
"max-h-64 space-y-2 overflow-y-auto"
>
<label
v-for=
"group in groups"
:key=
"group.id"
class=
"flex cursor-pointer items-center gap-3 rounded-lg border border-gray-200 p-3 hover:bg-gray-50"
:class=
"
{'border-primary-300 bg-primary-50': selectedIds.includes(group.id)}">
<input
type=
"checkbox"
:value=
"group.id"
v-model=
"selectedIds"
class=
"h-4 w-4 rounded border-gray-300 text-primary-600"
/>
<div
class=
"flex-1"
><p
class=
"font-medium text-gray-900"
>
{{
group
.
name
}}
</p><p
v-if=
"group.description"
class=
"truncate text-sm text-gray-500"
>
{{
group
.
description
}}
</p></div>
<div
class=
"flex items-center gap-2"
><span
class=
"badge badge-gray text-xs"
>
{{
group
.
platform
}}
</span><span
v-if=
"group.is_exclusive"
class=
"badge badge-purple text-xs"
>
{{
t
(
'
admin.groups.exclusive
'
)
}}
</span></div>
</label>
</div>
<div
class=
"mt-4 border-t border-gray-200 pt-4"
>
<label
class=
"flex cursor-pointer items-center gap-3 rounded-lg border border-gray-200 p-3 hover:bg-gray-50"
:class=
"
{'border-green-300 bg-green-50': selectedIds.length === 0}">
<input
type=
"radio"
:checked=
"selectedIds.length === 0"
@
change=
"selectedIds = []"
class=
"h-4 w-4 border-gray-300 text-green-600"
/>
<div
class=
"flex-1"
><p
class=
"font-medium text-gray-900"
>
{{
t
(
'
admin.users.allowAllGroups
'
)
}}
</p><p
class=
"text-sm text-gray-500"
>
{{
t
(
'
admin.users.allowAllGroupsHint
'
)
}}
</p></div>
</label>
</div>
</div>
</div>
<template
#footer
>
<div
class=
"flex justify-end gap-3"
>
<button
@
click=
"$emit('close')"
class=
"btn btn-secondary"
>
{{
t
(
'
common.cancel
'
)
}}
</button>
<button
@
click=
"handleSave"
:disabled=
"submitting"
class=
"btn btn-primary"
>
{{
submitting
?
t
(
'
common.saving
'
)
:
t
(
'
common.save
'
)
}}
</button>
</div>
</
template
>
</BaseDialog>
</template>
<
script
setup
lang=
"ts"
>
import
{
ref
,
watch
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
type
{
User
,
Group
}
from
'
@/types
'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
const
props
=
defineProps
<
{
show
:
boolean
,
user
:
User
|
null
}
>
()
const
emit
=
defineEmits
([
'
close
'
,
'
success
'
]);
const
{
t
}
=
useI18n
();
const
appStore
=
useAppStore
()
const
groups
=
ref
<
Group
[]
>
([]);
const
selectedIds
=
ref
<
number
[]
>
([]);
const
loading
=
ref
(
false
);
const
submitting
=
ref
(
false
)
watch
(()
=>
props
.
show
,
(
v
)
=>
{
if
(
v
&&
props
.
user
)
{
selectedIds
.
value
=
props
.
user
.
allowed_groups
||
[];
load
()
}
})
const
load
=
async
()
=>
{
loading
.
value
=
true
;
try
{
const
res
=
await
adminAPI
.
groups
.
list
(
1
,
1000
);
groups
.
value
=
res
.
items
.
filter
(
g
=>
g
.
subscription_type
===
'
standard
'
&&
g
.
status
===
'
active
'
)
}
catch
{}
finally
{
loading
.
value
=
false
}
}
const
handleSave
=
async
()
=>
{
if
(
!
props
.
user
)
return
;
submitting
.
value
=
true
try
{
await
adminAPI
.
users
.
update
(
props
.
user
.
id
,
{
allowed_groups
:
selectedIds
.
value
.
length
>
0
?
selectedIds
.
value
:
null
})
appStore
.
showSuccess
(
t
(
'
admin.users.allowedGroupsUpdated
'
));
emit
(
'
success
'
);
emit
(
'
close
'
)
}
catch
{}
finally
{
submitting
.
value
=
false
}
}
</
script
>
\ No newline at end of file
frontend/src/components/admin/user/UserApiKeysModal.vue
0 → 100644
View file @
fb313356
<
template
>
<BaseDialog
:show=
"show"
:title=
"t('admin.users.userApiKeys')"
width=
"wide"
@
close=
"$emit('close')"
>
<div
v-if=
"user"
class=
"space-y-4"
>
<div
class=
"flex items-center gap-3 rounded-xl bg-gray-50 p-4 dark:bg-dark-700"
>
<div
class=
"flex h-10 w-10 items-center justify-center rounded-full bg-primary-100 dark:bg-primary-900/30"
>
<span
class=
"text-lg font-medium text-primary-700 dark:text-primary-300"
>
{{
user
.
email
.
charAt
(
0
).
toUpperCase
()
}}
</span>
</div>
<div><p
class=
"font-medium text-gray-900 dark:text-white"
>
{{
user
.
email
}}
</p><p
class=
"text-sm text-gray-500 dark:text-dark-400"
>
{{
user
.
username
}}
</p></div>
</div>
<div
v-if=
"loading"
class=
"flex justify-center py-8"
><svg
class=
"h-8 w-8 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>
<div
v-else-if=
"apiKeys.length === 0"
class=
"py-8 text-center"
><p
class=
"text-sm text-gray-500"
>
{{
t
(
'
admin.users.noApiKeys
'
)
}}
</p></div>
<div
v-else
class=
"max-h-96 space-y-3 overflow-y-auto"
>
<div
v-for=
"key in apiKeys"
:key=
"key.id"
class=
"rounded-xl border border-gray-200 bg-white p-4 dark:border-dark-600 dark:bg-dark-800"
>
<div
class=
"flex items-start justify-between"
>
<div
class=
"min-w-0 flex-1"
>
<div
class=
"mb-1 flex items-center gap-2"
><span
class=
"font-medium text-gray-900 dark:text-white"
>
{{
key
.
name
}}
</span><span
:class=
"['badge text-xs', key.status === 'active' ? 'badge-success' : 'badge-danger']"
>
{{
key
.
status
}}
</span></div>
<p
class=
"truncate font-mono text-sm text-gray-500"
>
{{
key
.
key
.
substring
(
0
,
20
)
}}
...
{{
key
.
key
.
substring
(
key
.
key
.
length
-
8
)
}}
</p>
</div>
</div>
<div
class=
"mt-3 flex flex-wrap gap-4 text-xs text-gray-500"
>
<div
class=
"flex items-center gap-1"
><span>
{{
t
(
'
admin.users.group
'
)
}}
:
{{
key
.
group
?.
name
||
t
(
'
admin.users.none
'
)
}}
</span></div>
<div
class=
"flex items-center gap-1"
><span>
{{
t
(
'
admin.users.columns.created
'
)
}}
:
{{
formatDateTime
(
key
.
created_at
)
}}
</span></div>
</div>
</div>
</div>
</div>
</BaseDialog>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
ref
,
watch
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
{
formatDateTime
}
from
'
@/utils/format
'
import
type
{
User
,
ApiKey
}
from
'
@/types
'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
const
props
=
defineProps
<
{
show
:
boolean
,
user
:
User
|
null
}
>
()
defineEmits
([
'
close
'
]);
const
{
t
}
=
useI18n
()
const
apiKeys
=
ref
<
ApiKey
[]
>
([]);
const
loading
=
ref
(
false
)
watch
(()
=>
props
.
show
,
(
v
)
=>
{
if
(
v
&&
props
.
user
)
load
()
})
const
load
=
async
()
=>
{
if
(
!
props
.
user
)
return
;
loading
.
value
=
true
try
{
const
res
=
await
adminAPI
.
users
.
getUserApiKeys
(
props
.
user
.
id
);
apiKeys
.
value
=
res
.
items
||
[]
}
catch
{}
finally
{
loading
.
value
=
false
}
}
</
script
>
\ No newline at end of file
frontend/src/components/admin/user/UserBalanceModal.vue
0 → 100644
View file @
fb313356
<
template
>
<BaseDialog
:show=
"show"
:title=
"operation === 'add' ? t('admin.users.deposit') : t('admin.users.withdraw')"
width=
"narrow"
@
close=
"$emit('close')"
>
<form
v-if=
"user"
id=
"balance-form"
@
submit.prevent=
"handleBalanceSubmit"
class=
"space-y-5"
>
<div
class=
"flex items-center gap-3 rounded-xl bg-gray-50 p-4 dark:bg-dark-700"
>
<div
class=
"flex h-10 w-10 items-center justify-center rounded-full bg-primary-100"
><span
class=
"text-lg font-medium text-primary-700"
>
{{
user
.
email
.
charAt
(
0
).
toUpperCase
()
}}
</span></div>
<div
class=
"flex-1"
><p
class=
"font-medium text-gray-900"
>
{{
user
.
email
}}
</p><p
class=
"text-sm text-gray-500"
>
{{
t
(
'
admin.users.currentBalance
'
)
}}
: $
{{
user
.
balance
.
toFixed
(
2
)
}}
</p></div>
</div>
<div>
<label
class=
"input-label"
>
{{
operation
===
'
add
'
?
t
(
'
admin.users.depositAmount
'
)
:
t
(
'
admin.users.withdrawAmount
'
)
}}
</label>
<div
class=
"relative"
><div
class=
"absolute left-3 top-1/2 -translate-y-1/2 font-medium text-gray-500"
>
$
</div><input
v-model.number=
"form.amount"
type=
"number"
step=
"0.01"
min=
"0.01"
required
class=
"input pl-8"
/></div>
</div>
<div><label
class=
"input-label"
>
{{
t
(
'
admin.users.notes
'
)
}}
</label><textarea
v-model=
"form.notes"
rows=
"3"
class=
"input"
></textarea></div>
<div
v-if=
"form.amount > 0"
class=
"rounded-xl border border-blue-200 bg-blue-50 p-4"
><div
class=
"flex items-center justify-between text-sm"
><span>
{{
t
(
'
admin.users.newBalance
'
)
}}
:
</span><span
class=
"font-bold"
>
$
{{
calculateNewBalance
().
toFixed
(
2
)
}}
</span></div></div>
</form>
<template
#footer
>
<div
class=
"flex justify-end gap-3"
>
<button
@
click=
"$emit('close')"
class=
"btn btn-secondary"
>
{{
t
(
'
common.cancel
'
)
}}
</button>
<button
type=
"submit"
form=
"balance-form"
:disabled=
"submitting || !form.amount"
class=
"btn"
:class=
"operation === 'add' ? 'bg-emerald-600 text-white' : 'btn-danger'"
>
{{
submitting
?
t
(
'
common.saving
'
)
:
t
(
'
common.confirm
'
)
}}
</button>
</div>
</
template
>
</BaseDialog>
</template>
<
script
setup
lang=
"ts"
>
import
{
reactive
,
ref
,
watch
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
type
{
User
}
from
'
@/types
'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
const
props
=
defineProps
<
{
show
:
boolean
,
user
:
User
|
null
,
operation
:
'
add
'
|
'
subtract
'
}
>
()
const
emit
=
defineEmits
([
'
close
'
,
'
success
'
]);
const
{
t
}
=
useI18n
();
const
appStore
=
useAppStore
()
const
submitting
=
ref
(
false
);
const
form
=
reactive
({
amount
:
0
,
notes
:
''
})
watch
(()
=>
props
.
show
,
(
v
)
=>
{
if
(
v
)
{
form
.
amount
=
0
;
form
.
notes
=
''
}
})
const
calculateNewBalance
=
()
=>
(
props
.
user
?
(
props
.
operation
===
'
add
'
?
props
.
user
.
balance
+
form
.
amount
:
props
.
user
.
balance
-
form
.
amount
)
:
0
)
const
handleBalanceSubmit
=
async
()
=>
{
if
(
!
props
.
user
)
return
;
submitting
.
value
=
true
try
{
await
adminAPI
.
users
.
updateBalance
(
props
.
user
.
id
,
form
.
amount
,
props
.
operation
,
form
.
notes
)
appStore
.
showSuccess
(
t
(
'
common.success
'
));
emit
(
'
success
'
);
emit
(
'
close
'
)
}
catch
{}
finally
{
submitting
.
value
=
false
}
}
</
script
>
\ No newline at end of file
frontend/src/components/admin/user/UserCreateModal.vue
0 → 100644
View file @
fb313356
<
template
>
<BaseDialog
:show=
"show"
:title=
"t('admin.users.createUser')"
width=
"normal"
@
close=
"$emit('close')"
>
<form
id=
"create-user-form"
@
submit.prevent=
"submit"
class=
"space-y-5"
>
<div>
<label
class=
"input-label"
>
{{
t
(
'
admin.users.email
'
)
}}
</label>
<input
v-model=
"form.email"
type=
"email"
required
class=
"input"
:placeholder=
"t('admin.users.enterEmail')"
/>
</div>
<div>
<label
class=
"input-label"
>
{{
t
(
'
admin.users.password
'
)
}}
</label>
<div
class=
"flex gap-2"
>
<div
class=
"relative flex-1"
>
<input
v-model=
"form.password"
type=
"text"
required
class=
"input pr-10"
:placeholder=
"t('admin.users.enterPassword')"
/>
</div>
<button
type=
"button"
@
click=
"generateRandomPassword"
class=
"btn btn-secondary px-3"
>
<svg
class=
"h-5 w-5"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
><path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"1.5"
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>
</div>
</div>
<div>
<label
class=
"input-label"
>
{{
t
(
'
admin.users.username
'
)
}}
</label>
<input
v-model=
"form.username"
type=
"text"
class=
"input"
:placeholder=
"t('admin.users.enterUsername')"
/>
</div>
<div
class=
"grid grid-cols-2 gap-4"
>
<div>
<label
class=
"input-label"
>
{{
t
(
'
admin.users.columns.balance
'
)
}}
</label>
<input
v-model.number=
"form.balance"
type=
"number"
step=
"any"
class=
"input"
/>
</div>
<div>
<label
class=
"input-label"
>
{{
t
(
'
admin.users.columns.concurrency
'
)
}}
</label>
<input
v-model.number=
"form.concurrency"
type=
"number"
class=
"input"
/>
</div>
</div>
</form>
<template
#footer
>
<div
class=
"flex justify-end gap-3"
>
<button
@
click=
"$emit('close')"
type=
"button"
class=
"btn btn-secondary"
>
{{
t
(
'
common.cancel
'
)
}}
</button>
<button
type=
"submit"
form=
"create-user-form"
:disabled=
"loading"
class=
"btn btn-primary"
>
{{
loading
?
t
(
'
admin.users.creating
'
)
:
t
(
'
common.create
'
)
}}
</button>
</div>
</
template
>
</BaseDialog>
</template>
<
script
setup
lang=
"ts"
>
import
{
reactive
,
watch
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
;
import
{
adminAPI
}
from
'
@/api/admin
'
import
{
useForm
}
from
'
@/composables/useForm
'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
const
props
=
defineProps
<
{
show
:
boolean
}
>
()
const
emit
=
defineEmits
([
'
close
'
,
'
success
'
]);
const
{
t
}
=
useI18n
()
const
form
=
reactive
({
email
:
''
,
password
:
''
,
username
:
''
,
notes
:
''
,
balance
:
0
,
concurrency
:
1
})
const
{
loading
,
submit
}
=
useForm
({
form
,
submitFn
:
async
(
data
)
=>
{
await
adminAPI
.
users
.
create
(
data
)
emit
(
'
success
'
);
emit
(
'
close
'
)
},
successMsg
:
t
(
'
admin.users.userCreated
'
)
})
watch
(()
=>
props
.
show
,
(
v
)
=>
{
if
(
v
)
Object
.
assign
(
form
,
{
email
:
''
,
password
:
''
,
username
:
''
,
notes
:
''
,
balance
:
0
,
concurrency
:
1
})
})
const
generateRandomPassword
=
()
=>
{
const
chars
=
'
ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%^&*
'
let
p
=
''
;
for
(
let
i
=
0
;
i
<
16
;
i
++
)
p
+=
chars
.
charAt
(
Math
.
floor
(
Math
.
random
()
*
chars
.
length
))
form
.
password
=
p
}
</
script
>
frontend/src/components/admin/user/UserEditModal.vue
0 → 100644
View file @
fb313356
<
template
>
<BaseDialog
:show=
"show"
:title=
"t('admin.users.editUser')"
width=
"normal"
@
close=
"$emit('close')"
>
<form
v-if=
"user"
id=
"edit-user-form"
@
submit.prevent=
"handleUpdateUser"
class=
"space-y-5"
>
<div>
<label
class=
"input-label"
>
{{
t
(
'
admin.users.email
'
)
}}
</label>
<input
v-model=
"form.email"
type=
"email"
class=
"input"
/>
</div>
<div>
<label
class=
"input-label"
>
{{
t
(
'
admin.users.password
'
)
}}
</label>
<div
class=
"flex gap-2"
>
<div
class=
"relative flex-1"
>
<input
v-model=
"form.password"
type=
"text"
class=
"input pr-10"
:placeholder=
"t('admin.users.enterNewPassword')"
/>
<button
v-if=
"form.password"
type=
"button"
@
click=
"copyPassword"
class=
"absolute right-2 top-1/2 -translate-y-1/2 rounded-lg p-1 transition-colors hover:bg-gray-100 dark:hover:bg-dark-700"
:class=
"passwordCopied ? 'text-green-500' : 'text-gray-400'"
>
<svg
v-if=
"passwordCopied"
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"
/></svg>
</button>
</div>
<button
type=
"button"
@
click=
"generatePassword"
class=
"btn btn-secondary px-3"
>
<svg
class=
"h-5 w-5"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
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>
</button>
</div>
</div>
<div>
<label
class=
"input-label"
>
{{
t
(
'
admin.users.username
'
)
}}
</label>
<input
v-model=
"form.username"
type=
"text"
class=
"input"
/>
</div>
<div>
<label
class=
"input-label"
>
{{
t
(
'
admin.users.notes
'
)
}}
</label>
<textarea
v-model=
"form.notes"
rows=
"3"
class=
"input"
></textarea>
</div>
<div>
<label
class=
"input-label"
>
{{
t
(
'
admin.users.columns.concurrency
'
)
}}
</label>
<input
v-model.number=
"form.concurrency"
type=
"number"
class=
"input"
/>
</div>
<UserAttributeForm
v-model=
"form.customAttributes"
:user-id=
"user?.id"
/>
</form>
<template
#footer
>
<div
class=
"flex justify-end gap-3"
>
<button
@
click=
"$emit('close')"
type=
"button"
class=
"btn btn-secondary"
>
{{
t
(
'
common.cancel
'
)
}}
</button>
<button
type=
"submit"
form=
"edit-user-form"
:disabled=
"submitting"
class=
"btn btn-primary"
>
{{
submitting
?
t
(
'
admin.users.updating
'
)
:
t
(
'
common.update
'
)
}}
</button>
</div>
</
template
>
</BaseDialog>
</template>
<
script
setup
lang=
"ts"
>
import
{
ref
,
reactive
,
watch
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
useClipboard
}
from
'
@/composables/useClipboard
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
type
{
User
,
UserAttributeValuesMap
}
from
'
@/types
'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
import
UserAttributeForm
from
'
@/components/user/UserAttributeForm.vue
'
const
props
=
defineProps
<
{
show
:
boolean
,
user
:
User
|
null
}
>
()
const
emit
=
defineEmits
([
'
close
'
,
'
success
'
])
const
{
t
}
=
useI18n
();
const
appStore
=
useAppStore
();
const
{
copyToClipboard
}
=
useClipboard
()
const
submitting
=
ref
(
false
);
const
passwordCopied
=
ref
(
false
)
const
form
=
reactive
({
email
:
''
,
password
:
''
,
username
:
''
,
notes
:
''
,
concurrency
:
1
,
customAttributes
:
{}
as
UserAttributeValuesMap
})
watch
(()
=>
props
.
user
,
(
u
)
=>
{
if
(
u
)
{
Object
.
assign
(
form
,
{
email
:
u
.
email
,
password
:
''
,
username
:
u
.
username
||
''
,
notes
:
u
.
notes
||
''
,
concurrency
:
u
.
concurrency
,
customAttributes
:
{}
})
passwordCopied
.
value
=
false
}
},
{
immediate
:
true
})
const
generatePassword
=
()
=>
{
const
chars
=
'
ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%^&*
'
let
p
=
''
;
for
(
let
i
=
0
;
i
<
16
;
i
++
)
p
+=
chars
.
charAt
(
Math
.
floor
(
Math
.
random
()
*
chars
.
length
))
form
.
password
=
p
}
const
copyPassword
=
async
()
=>
{
if
(
form
.
password
&&
await
copyToClipboard
(
form
.
password
,
t
(
'
admin.users.passwordCopied
'
)))
{
passwordCopied
.
value
=
true
;
setTimeout
(()
=>
passwordCopied
.
value
=
false
,
2000
)
}
}
const
handleUpdateUser
=
async
()
=>
{
if
(
!
props
.
user
)
return
submitting
.
value
=
true
try
{
const
data
:
any
=
{
email
:
form
.
email
,
username
:
form
.
username
,
notes
:
form
.
notes
,
concurrency
:
form
.
concurrency
}
if
(
form
.
password
.
trim
())
data
.
password
=
form
.
password
.
trim
()
await
adminAPI
.
users
.
update
(
props
.
user
.
id
,
data
)
if
(
Object
.
keys
(
form
.
customAttributes
).
length
>
0
)
await
adminAPI
.
userAttributes
.
updateUserAttributeValues
(
props
.
user
.
id
,
form
.
customAttributes
)
appStore
.
showSuccess
(
t
(
'
admin.users.userUpdated
'
))
emit
(
'
success
'
);
emit
(
'
close
'
)
}
catch
(
e
:
any
)
{
appStore
.
showError
(
e
.
response
?.
data
?.
detail
||
t
(
'
admin.users.failedToUpdate
'
))
}
finally
{
submitting
.
value
=
false
}
}
</
script
>
\ No newline at end of file
frontend/src/components/common/GroupOptionItem.vue
0 → 100644
View file @
fb313356
<
template
>
<div
class=
"flex min-w-0 flex-1 items-center justify-between gap-2"
>
<div
class=
"flex min-w-0 flex-1 flex-col items-start gap-1"
:title=
"description || undefined"
>
<GroupBadge
:name=
"name"
:platform=
"platform"
:subscription-type=
"subscriptionType"
:rate-multiplier=
"rateMultiplier"
/>
<span
v-if=
"description"
class=
"w-full truncate text-left text-xs text-gray-500 dark:text-gray-400"
>
{{
description
}}
</span>
</div>
<svg
v-if=
"showCheckmark && selected"
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>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
GroupBadge
from
'
./GroupBadge.vue
'
import
type
{
SubscriptionType
,
GroupPlatform
}
from
'
@/types
'
interface
Props
{
name
:
string
platform
:
GroupPlatform
subscriptionType
?:
SubscriptionType
rateMultiplier
?:
number
description
?:
string
|
null
selected
?:
boolean
showCheckmark
?:
boolean
}
withDefaults
(
defineProps
<
Props
>
(),
{
subscriptionType
:
'
standard
'
,
selected
:
false
,
showCheckmark
:
true
})
</
script
>
frontend/src/components/common/GroupSelector.vue
View file @
fb313356
<
template
>
<div>
<label
class=
"input-label"
>
Groups
<span
class=
"font-normal text-gray-400"
>
(
{{
modelValue
.
length
}
}
selected)
</span>
{{
t
(
'
admin.users.groups
'
)
}}
<span
class=
"font-normal text-gray-400"
>
{{
t
(
'
common.selectedCount
'
,
{
count
:
modelValue
.
length
}
)
}}
<
/span
>
<
/label
>
<
div
class
=
"
grid max-h-32 grid-cols-2 gap-1 overflow-y-auto rounded-lg border border-gray-200 bg-gray-50 p-2 dark:border-dark-600 dark:bg-dark-800
"
...
...
@@ -32,7 +32,7 @@
v
-
if
=
"
filteredGroups.length === 0
"
class
=
"
col-span-2 py-2 text-center text-sm text-gray-500 dark:text-gray-400
"
>
No g
roups
a
vailable
{{
t
(
'
common.noG
roups
A
vailable
'
)
}}
<
/div
>
<
/div
>
<
/div
>
...
...
frontend/src/components/common/Input.vue
0 → 100644
View file @
fb313356
<
template
>
<div
class=
"w-full"
>
<label
v-if=
"label"
:for=
"id"
class=
"input-label mb-1.5 block"
>
{{
label
}}
<span
v-if=
"required"
class=
"text-red-500"
>
*
</span>
</label>
<div
class=
"relative"
>
<!-- Prefix Icon Slot -->
<div
v-if=
"$slots.prefix"
class=
"pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3.5 text-gray-400 dark:text-dark-400"
>
<slot
name=
"prefix"
></slot>
</div>
<input
:id=
"id"
ref=
"inputRef"
:type=
"type"
:value=
"modelValue"
:disabled=
"disabled"
:required=
"required"
:placeholder=
"placeholderText"
:autocomplete=
"autocomplete"
:readonly=
"readonly"
:class=
"[
'input w-full transition-all duration-200',
$slots.prefix ? 'pl-11' : '',
$slots.suffix ? 'pr-11' : '',
error ? 'input-error ring-2 ring-red-500/20' : '',
disabled ? 'cursor-not-allowed bg-gray-100 opacity-60 dark:bg-dark-900' : ''
]"
@
input=
"onInput"
@
change=
"$emit('change', ($event.target as HTMLInputElement).value)"
@
blur=
"$emit('blur', $event)"
@
focus=
"$emit('focus', $event)"
@
keyup.enter=
"$emit('enter', $event)"
/>
<!-- Suffix Slot (e.g. Password Toggle or Clear Button) -->
<div
v-if=
"$slots.suffix"
class=
"absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 dark:text-dark-400"
>
<slot
name=
"suffix"
></slot>
</div>
</div>
<!-- Hint / Error Text -->
<p
v-if=
"error"
class=
"input-error-text mt-1.5"
>
{{
error
}}
</p>
<p
v-else-if=
"hint"
class=
"input-hint mt-1.5"
>
{{
hint
}}
</p>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
computed
,
ref
}
from
'
vue
'
interface
Props
{
modelValue
:
string
|
number
|
null
|
undefined
type
?:
string
label
?:
string
placeholder
?:
string
disabled
?:
boolean
required
?:
boolean
readonly
?:
boolean
error
?:
string
hint
?:
string
id
?:
string
autocomplete
?:
string
}
const
props
=
withDefaults
(
defineProps
<
Props
>
(),
{
type
:
'
text
'
,
disabled
:
false
,
required
:
false
,
readonly
:
false
})
const
emit
=
defineEmits
<
{
(
e
:
'
update:modelValue
'
,
value
:
string
):
void
(
e
:
'
change
'
,
value
:
string
):
void
(
e
:
'
blur
'
,
event
:
FocusEvent
):
void
(
e
:
'
focus
'
,
event
:
FocusEvent
):
void
(
e
:
'
enter
'
,
event
:
KeyboardEvent
):
void
}
>
()
const
inputRef
=
ref
<
HTMLInputElement
|
null
>
(
null
)
const
placeholderText
=
computed
(()
=>
props
.
placeholder
||
''
)
const
onInput
=
(
event
:
Event
)
=>
{
const
value
=
(
event
.
target
as
HTMLInputElement
).
value
emit
(
'
update:modelValue
'
,
value
)
}
// Expose focus method
defineExpose
({
focus
:
()
=>
inputRef
.
value
?.
focus
(),
select
:
()
=>
inputRef
.
value
?.
select
()
})
</
script
>
frontend/src/components/common/SearchInput.vue
0 → 100644
View file @
fb313356
<
template
>
<div
class=
"relative w-full"
>
<div
class=
"pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3"
>
<svg
class=
"h-5 w-5 text-gray-400"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
/>
</svg>
</div>
<input
:value=
"modelValue"
type=
"text"
class=
"input pl-10"
:placeholder=
"placeholder"
@
input=
"handleInput"
/>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
useDebounceFn
}
from
'
@vueuse/core
'
const
props
=
withDefaults
(
defineProps
<
{
modelValue
:
string
placeholder
?:
string
debounceMs
?:
number
}
>
(),
{
placeholder
:
'
Search...
'
,
debounceMs
:
300
})
const
emit
=
defineEmits
<
{
(
e
:
'
update:modelValue
'
,
value
:
string
):
void
(
e
:
'
search
'
,
value
:
string
):
void
}
>
()
const
debouncedEmitSearch
=
useDebounceFn
((
value
:
string
)
=>
{
emit
(
'
search
'
,
value
)
},
props
.
debounceMs
)
const
handleInput
=
(
event
:
Event
)
=>
{
const
value
=
(
event
.
target
as
HTMLInputElement
).
value
emit
(
'
update:modelValue
'
,
value
)
debouncedEmitSearch
(
value
)
}
</
script
>
frontend/src/components/common/Select.vue
View file @
fb313356
<
template
>
<div
class=
"relative"
ref=
"containerRef"
>
<button
ref=
"triggerRef"
type=
"button"
@
click=
"toggle"
:disabled=
"disabled"
:aria-expanded=
"isOpen"
:aria-haspopup=
"true"
aria-label=
"Select option"
:class=
"[
'select-trigger',
isOpen && 'select-trigger-open',
error && 'select-trigger-error',
disabled && 'select-trigger-disabled'
]"
@
keydown.down.prevent=
"onTriggerKeyDown"
@
keydown.up.prevent=
"onTriggerKeyDown"
>
<span
class=
"select-value"
>
<slot
name=
"selected"
:option=
"selectedOption"
>
...
...
@@ -29,16 +35,19 @@
</span>
</button>
<!-- Teleport dropdown to body to escape stacking context
(for driver.js overlay compatibility)
-->
<!-- Teleport dropdown to body to escape stacking context -->
<Teleport
to=
"body"
>
<Transition
name=
"select-dropdown"
>
<div
v-if=
"isOpen"
ref=
"dropdownRef"
class=
"select-dropdown-portal"
:class=
"[instanceId]"
:style=
"dropdownStyle"
role=
"listbox"
@
click.stop
@
mousedown.stop
@
keydown=
"onDropdownKeyDown"
>
<!-- Search input -->
<div
v-if=
"searchable"
class=
"select-search"
>
...
...
@@ -66,12 +75,21 @@
</div>
<!-- Options list -->
<div
class=
"select-options"
>
<div
class=
"select-options"
ref=
"optionsListRef"
>
<div
v-for=
"option in filteredOptions"
v-for=
"
(
option
, index)
in filteredOptions"
:key=
"`$
{typeof getOptionValue(option)}:${String(getOptionValue(option) ?? '')}`"
@click.stop="selectOption(option)"
:class="['select-option', isSelected(option)
&&
'select-option-selected']"
role="option"
:aria-selected="isSelected(option)"
:aria-disabled="isOptionDisabled(option)"
@click.stop="!isOptionDisabled(option)
&&
selectOption(option)"
@mouseenter="focusedIndex = index"
:class="[
'select-option',
isSelected(option)
&&
'select-option-selected',
isOptionDisabled(option)
&&
'select-option-disabled',
focusedIndex === index
&&
'select-option-focused'
]"
>
<slot
name=
"option"
:option=
"option"
:selected=
"isSelected(option)"
>
<span
class=
"select-option-label"
>
{{
getOptionLabel
(
option
)
}}
</span>
...
...
@@ -105,6 +123,9 @@ import { useI18n } from 'vue-i18n'
const
{
t
}
=
useI18n
()
// Instance ID for unique click-outside detection
const
instanceId
=
`select-
${
Math
.
random
().
toString
(
36
).
substring
(
2
,
9
)}
`
export
interface
SelectOption
{
value
:
string
|
number
|
boolean
|
null
label
:
string
...
...
@@ -138,23 +159,24 @@ const props = withDefaults(defineProps<Props>(), {
labelKey
:
'
label
'
})
// Use computed for i18n default values
const
placeholderText
=
computed
(()
=>
props
.
placeholder
??
t
(
'
common.selectOption
'
))
const
searchPlaceholderText
=
computed
(
()
=>
props
.
searchPlaceholder
??
t
(
'
common.searchPlaceholder
'
)
)
const
emptyTextDisplay
=
computed
(()
=>
props
.
emptyText
??
t
(
'
common.noOptionsFound
'
))
const
emit
=
defineEmits
<
Emits
>
()
const
isOpen
=
ref
(
false
)
const
searchQuery
=
ref
(
''
)
const
focusedIndex
=
ref
(
-
1
)
const
containerRef
=
ref
<
HTMLElement
|
null
>
(
null
)
const
triggerRef
=
ref
<
HTMLButtonElement
|
null
>
(
null
)
const
searchInputRef
=
ref
<
HTMLInputElement
|
null
>
(
null
)
const
dropdownRef
=
ref
<
HTMLElement
|
null
>
(
null
)
const
optionsListRef
=
ref
<
HTMLElement
|
null
>
(
null
)
const
dropdownPosition
=
ref
<
'
bottom
'
|
'
top
'
>
(
'
bottom
'
)
const
triggerRect
=
ref
<
DOMRect
|
null
>
(
null
)
// i18n placeholders
const
placeholderText
=
computed
(()
=>
props
.
placeholder
??
t
(
'
common.selectOption
'
))
const
searchPlaceholderText
=
computed
(()
=>
props
.
searchPlaceholder
??
t
(
'
common.searchPlaceholder
'
))
const
emptyTextDisplay
=
computed
(()
=>
props
.
emptyText
??
t
(
'
common.noOptionsFound
'
))
// Computed style for teleported dropdown
const
dropdownStyle
=
computed
(()
=>
{
if
(
!
triggerRect
.
value
)
return
{}
...
...
@@ -164,34 +186,39 @@ const dropdownStyle = computed(() => {
position
:
'
fixed
'
,
left
:
`
${
rect
.
left
}
px`
,
minWidth
:
`
${
rect
.
width
}
px`
,
zIndex
:
'
100000020
'
// Higher than driver.js overlay (99999998)
zIndex
:
'
100000020
'
}
if
(
dropdownPosition
.
value
===
'
top
'
)
{
style
.
bottom
=
`
${
window
.
innerHeight
-
rect
.
top
+
8
}
px`
style
.
bottom
=
`
${
window
.
innerHeight
-
rect
.
top
+
4
}
px`
}
else
{
style
.
top
=
`
${
rect
.
bottom
+
8
}
px`
style
.
top
=
`
${
rect
.
bottom
+
4
}
px`
}
return
style
})
const
getOptionValue
=
(
option
:
SelectOption
|
Record
<
string
,
unknown
>
):
string
|
number
|
boolean
|
null
|
undefined
=>
{
const
getOptionValue
=
(
option
:
any
):
any
=>
{
if
(
typeof
option
===
'
object
'
&&
option
!==
null
)
{
return
option
[
props
.
valueKey
]
as
string
|
number
|
boolean
|
null
|
undefined
return
option
[
props
.
valueKey
]
}
return
option
as
string
|
number
|
boolean
|
null
return
option
}
const
getOptionLabel
=
(
option
:
SelectOption
|
Record
<
string
,
unknown
>
):
string
=>
{
const
getOptionLabel
=
(
option
:
any
):
string
=>
{
if
(
typeof
option
===
'
object
'
&&
option
!==
null
)
{
return
String
(
option
[
props
.
labelKey
]
??
''
)
}
return
String
(
option
??
''
)
}
const
isOptionDisabled
=
(
option
:
any
):
boolean
=>
{
if
(
typeof
option
===
'
object
'
&&
option
!==
null
)
{
return
!!
option
.
disabled
}
return
false
}
const
selectedOption
=
computed
(()
=>
{
return
props
.
options
.
find
((
opt
)
=>
getOptionValue
(
opt
)
===
props
.
modelValue
)
||
null
})
...
...
@@ -204,36 +231,35 @@ const selectedLabel = computed(() => {
})
const
filteredOptions
=
computed
(()
=>
{
if
(
!
props
.
searchable
||
!
searchQuery
.
value
)
{
return
props
.
options
}
let
opts
=
props
.
options
as
any
[]
if
(
props
.
searchable
&&
searchQuery
.
value
)
{
const
query
=
searchQuery
.
value
.
toLowerCase
()
return
props
.
options
.
filter
((
opt
)
=>
{
const
label
=
getOptionLabel
(
opt
).
toLowerCase
()
return
label
.
includes
(
query
)
})
opts
=
opts
.
filter
((
opt
)
=>
getOptionLabel
(
opt
).
toLowerCase
().
includes
(
query
))
}
return
opts
})
const
isSelected
=
(
option
:
SelectOption
|
Record
<
string
,
unknown
>
):
boolean
=>
{
const
isSelected
=
(
option
:
any
):
boolean
=>
{
return
getOptionValue
(
option
)
===
props
.
modelValue
}
// Update trigger rect periodically while open to follow scroll/resize
const
updateTriggerRect
=
()
=>
{
if
(
containerRef
.
value
)
{
triggerRect
.
value
=
containerRef
.
value
.
getBoundingClientRect
()
}
}
const
calculateDropdownPosition
=
()
=>
{
if
(
!
containerRef
.
value
)
return
// Update trigger rect for positioning
triggerRect
.
value
=
containerRef
.
value
.
getBoundingClientRect
()
updateTriggerRect
()
nextTick
(()
=>
{
if
(
!
containerRef
.
value
||
!
dropdownRef
.
value
)
return
if
(
!
dropdownRef
.
value
||
!
triggerRect
.
value
)
return
const
dropdownHeight
=
dropdownRef
.
value
.
offsetHeight
||
240
const
spaceBelow
=
window
.
innerHeight
-
triggerRect
.
value
.
bottom
const
spaceAbove
=
triggerRect
.
value
.
top
const
rect
=
triggerRect
.
value
!
const
dropdownHeight
=
dropdownRef
.
value
.
offsetHeight
||
240
// Max height fallback
const
viewportHeight
=
window
.
innerHeight
const
spaceBelow
=
viewportHeight
-
rect
.
bottom
const
spaceAbove
=
rect
.
top
// If not enough space below but enough space above, show dropdown on top
if
(
spaceBelow
<
dropdownHeight
&&
spaceAbove
>
dropdownHeight
)
{
dropdownPosition
.
value
=
'
top
'
}
else
{
...
...
@@ -245,63 +271,108 @@ const calculateDropdownPosition = () => {
const
toggle
=
()
=>
{
if
(
props
.
disabled
)
return
isOpen
.
value
=
!
isOpen
.
value
if
(
isOpen
.
value
)
{
}
watch
(
isOpen
,
(
open
)
=>
{
if
(
open
)
{
calculateDropdownPosition
()
// Reset focused index to current selection or first item
const
selectedIdx
=
filteredOptions
.
value
.
findIndex
(
isSelected
)
focusedIndex
.
value
=
selectedIdx
>=
0
?
selectedIdx
:
0
if
(
props
.
searchable
)
{
nextTick
(()
=>
{
searchInputRef
.
value
?.
focus
()
})
nextTick
(()
=>
searchInputRef
.
value
?.
focus
())
}
// Add scroll listener to update position
window
.
addEventListener
(
'
scroll
'
,
updateTriggerRect
,
{
capture
:
true
,
passive
:
true
})
window
.
addEventListener
(
'
resize
'
,
calculateDropdownPosition
)
}
else
{
searchQuery
.
value
=
''
focusedIndex
.
value
=
-
1
window
.
removeEventListener
(
'
scroll
'
,
updateTriggerRect
,
{
capture
:
true
})
window
.
removeEventListener
(
'
resize
'
,
calculateDropdownPosition
)
}
}
}
)
const
selectOption
=
(
option
:
SelectOption
|
Record
<
string
,
unknown
>
)
=>
{
const
selectOption
=
(
option
:
any
)
=>
{
const
value
=
getOptionValue
(
option
)
??
null
emit
(
'
update:modelValue
'
,
value
)
emit
(
'
change
'
,
value
,
option
as
SelectOption
)
emit
(
'
change
'
,
value
,
option
)
isOpen
.
value
=
false
searchQuery
.
value
=
''
triggerRef
.
value
?.
focus
()
}
const
handleClickOutside
=
(
event
:
MouseEvent
)
=>
{
const
target
=
event
.
target
as
HTMLElement
// 使用 closest 检查点击是否在下拉菜单内部(更可靠,不依赖 ref)
if
(
target
.
closest
(
'
.select-dropdown-portal
'
))
{
return
// 点击在下拉菜单内,不关闭
// Keyboards
const
onTriggerKeyDown
=
()
=>
{
if
(
!
isOpen
.
value
)
{
isOpen
.
value
=
true
}
}
// 检查是否点击在触发器内
if
(
containerRef
.
value
&&
containerRef
.
value
.
contains
(
target
))
{
return
// 点击在触发器内,让 toggle 处理
const
onDropdownKeyDown
=
(
e
:
KeyboardEvent
)
=>
{
switch
(
e
.
key
)
{
case
'
ArrowDown
'
:
e
.
preventDefault
()
focusedIndex
.
value
=
(
focusedIndex
.
value
+
1
)
%
filteredOptions
.
value
.
length
scrollToFocused
()
break
case
'
ArrowUp
'
:
e
.
preventDefault
()
focusedIndex
.
value
=
(
focusedIndex
.
value
-
1
+
filteredOptions
.
value
.
length
)
%
filteredOptions
.
value
.
length
scrollToFocused
()
break
case
'
Enter
'
:
e
.
preventDefault
()
if
(
focusedIndex
.
value
>=
0
&&
focusedIndex
.
value
<
filteredOptions
.
value
.
length
)
{
const
opt
=
filteredOptions
.
value
[
focusedIndex
.
value
]
if
(
!
isOptionDisabled
(
opt
))
selectOption
(
opt
)
}
// 点击在外部,关闭下拉菜单
break
case
'
Escape
'
:
e
.
preventDefault
()
isOpen
.
value
=
false
searchQuery
.
value
=
''
triggerRef
.
value
?.
focus
()
break
case
'
Tab
'
:
isOpen
.
value
=
false
break
}
}
const
handleEscape
=
(
event
:
KeyboardEvent
)
=>
{
if
(
event
.
key
===
'
Escape
'
&&
isOpen
.
value
)
{
isOpen
.
value
=
false
searchQuery
.
value
=
''
const
scrollToFocused
=
()
=>
{
nextTick
(()
=>
{
const
list
=
optionsListRef
.
value
if
(
!
list
)
return
const
focusedEl
=
list
.
children
[
focusedIndex
.
value
]
as
HTMLElement
if
(
!
focusedEl
)
return
if
(
focusedEl
.
offsetTop
<
list
.
scrollTop
)
{
list
.
scrollTop
=
focusedEl
.
offsetTop
}
else
if
(
focusedEl
.
offsetTop
+
focusedEl
.
offsetHeight
>
list
.
scrollTop
+
list
.
offsetHeight
)
{
list
.
scrollTop
=
focusedEl
.
offsetTop
+
focusedEl
.
offsetHeight
-
list
.
offsetHeight
}
})
}
watch
(
isOpen
,
(
open
)
=>
{
if
(
!
open
)
{
searchQuery
.
value
=
''
const
handleClickOutside
=
(
event
:
MouseEvent
)
=>
{
const
target
=
event
.
target
as
HTMLElement
// Check if click is inside THIS specific instance's dropdown or trigger
const
isInDropdown
=
!!
target
.
closest
(
`.
${
instanceId
}
`
)
const
isInTrigger
=
containerRef
.
value
?.
contains
(
target
)
if
(
!
isInDropdown
&&
!
isInTrigger
&&
isOpen
.
value
)
{
isOpen
.
value
=
false
}
}
)
}
onMounted
(()
=>
{
document
.
addEventListener
(
'
click
'
,
handleClickOutside
)
document
.
addEventListener
(
'
keydown
'
,
handleEscape
)
})
onUnmounted
(()
=>
{
document
.
removeEventListener
(
'
click
'
,
handleClickOutside
)
document
.
removeEventListener
(
'
keydown
'
,
handleEscape
)
window
.
removeEventListener
(
'
scroll
'
,
updateTriggerRect
,
{
capture
:
true
})
window
.
removeEventListener
(
'
resize
'
,
calculateDropdownPosition
)
})
</
script
>
...
...
@@ -339,16 +410,14 @@ onUnmounted(() => {
}
</
style
>
<!-- Global styles for teleported dropdown -->
<
style
>
.select-dropdown-portal
{
@apply
w-max
max-w-[3
0
0px];
@apply
w-max
min-w-[160px]
max-w-[3
2
0px];
@apply
bg-white
dark
:
bg-dark-800
;
@apply
rounded-xl;
@apply
border
border-gray-200
dark
:
border-dark-700
;
@apply
shadow-lg
shadow-black/10
dark
:
shadow-black
/
30
;
@apply
overflow-hidden;
/* 确保下拉菜单在引导期间可点击(覆盖 driver.js 的 pointer-events 影响) */
pointer-events
:
auto
!important
;
}
...
...
@@ -365,7 +434,7 @@ onUnmounted(() => {
}
.select-dropdown-portal
.select-options
{
@apply
max-h-60
overflow-y-auto
py-1;
@apply
max-h-60
overflow-y-auto
py-1
outline-none
;
}
.select-dropdown-portal
.select-option
{
...
...
@@ -374,7 +443,6 @@ onUnmounted(() => {
@apply
text-gray-700
dark
:
text-gray-300
;
@apply
cursor-pointer
transition-colors
duration-150;
@apply
hover
:
bg-gray-50
dark
:
hover
:
bg-dark-700
;
/* 确保选项在引导期间可点击 */
pointer-events
:
auto
!important
;
}
...
...
@@ -383,6 +451,14 @@ onUnmounted(() => {
@apply
text-primary-700
dark
:
text-primary-300
;
}
.select-dropdown-portal
.select-option-focused
{
@apply
bg-gray-100
dark
:
bg-dark-700
;
}
.select-dropdown-portal
.select-option-disabled
{
@apply
cursor-not-allowed
opacity-40;
}
.select-dropdown-portal
.select-option-label
{
@apply
flex-1
min-w-0
truncate
text-left;
}
...
...
@@ -392,7 +468,6 @@ onUnmounted(() => {
@apply
text-gray-500
dark
:
text-dark-400
;
}
/* Dropdown animation */
.select-dropdown-enter-active
,
.select-dropdown-leave-active
{
transition
:
all
0.2s
ease
;
...
...
frontend/src/components/common/Skeleton.vue
0 → 100644
View file @
fb313356
<
template
>
<div
:class=
"[
'animate-pulse bg-gray-200 dark:bg-dark-700',
variant === 'circle' ? 'rounded-full' : 'rounded-lg',
customClass
]"
:style=
"style"
></div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
computed
}
from
'
vue
'
interface
Props
{
variant
?:
'
rect
'
|
'
circle
'
|
'
text
'
width
?:
string
|
number
height
?:
string
|
number
class
?:
string
}
const
props
=
withDefaults
(
defineProps
<
Props
>
(),
{
variant
:
'
rect
'
,
width
:
'
100%
'
})
const
customClass
=
computed
(()
=>
props
.
class
||
''
)
const
style
=
computed
(()
=>
{
const
s
:
Record
<
string
,
string
>
=
{}
if
(
props
.
width
)
{
s
.
width
=
typeof
props
.
width
===
'
number
'
?
`
${
props
.
width
}
px`
:
props
.
width
}
if
(
props
.
height
)
{
s
.
height
=
typeof
props
.
height
===
'
number
'
?
`
${
props
.
height
}
px`
:
props
.
height
}
else
if
(
props
.
variant
===
'
text
'
)
{
s
.
height
=
'
1em
'
s
.
marginTop
=
'
0.25em
'
s
.
marginBottom
=
'
0.25em
'
}
return
s
})
</
script
>
frontend/src/components/common/StatusBadge.vue
0 → 100644
View file @
fb313356
<
template
>
<div
class=
"flex items-center gap-1.5"
>
<span
:class=
"[
'inline-block h-2 w-2 rounded-full',
variantClass
]"
></span>
<span
class=
"text-sm text-gray-700 dark:text-gray-300"
>
{{
label
}}
</span>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
computed
}
from
'
vue
'
const
props
=
defineProps
<
{
status
:
string
label
:
string
}
>
()
const
variantClass
=
computed
(()
=>
{
switch
(
props
.
status
)
{
case
'
active
'
:
case
'
success
'
:
return
'
bg-green-500
'
case
'
disabled
'
:
case
'
inactive
'
:
case
'
warning
'
:
return
'
bg-yellow-500
'
case
'
error
'
:
case
'
danger
'
:
return
'
bg-red-500
'
default
:
return
'
bg-gray-400
'
}
})
</
script
>
frontend/src/components/common/TextArea.vue
0 → 100644
View file @
fb313356
<
template
>
<div
class=
"w-full"
>
<label
v-if=
"label"
:for=
"id"
class=
"input-label mb-1.5 block"
>
{{
label
}}
<span
v-if=
"required"
class=
"text-red-500"
>
*
</span>
</label>
<div
class=
"relative"
>
<textarea
:id=
"id"
ref=
"textAreaRef"
:value=
"modelValue"
:disabled=
"disabled"
:required=
"required"
:placeholder=
"placeholderText"
:readonly=
"readonly"
:rows=
"rows"
:class=
"[
'input w-full min-h-[80px] transition-all duration-200 resize-y',
error ? 'input-error ring-2 ring-red-500/20' : '',
disabled ? 'cursor-not-allowed bg-gray-100 opacity-60 dark:bg-dark-900' : ''
]"
@
input=
"onInput"
@
change=
"$emit('change', ($event.target as HTMLTextAreaElement).value)"
@
blur=
"$emit('blur', $event)"
@
focus=
"$emit('focus', $event)"
></textarea>
</div>
<!-- Hint / Error Text -->
<p
v-if=
"error"
class=
"input-error-text mt-1.5"
>
{{
error
}}
</p>
<p
v-else-if=
"hint"
class=
"input-hint mt-1.5"
>
{{
hint
}}
</p>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
computed
,
ref
}
from
'
vue
'
interface
Props
{
modelValue
:
string
|
null
|
undefined
label
?:
string
placeholder
?:
string
disabled
?:
boolean
required
?:
boolean
readonly
?:
boolean
error
?:
string
hint
?:
string
id
?:
string
rows
?:
number
|
string
}
const
props
=
withDefaults
(
defineProps
<
Props
>
(),
{
disabled
:
false
,
required
:
false
,
readonly
:
false
,
rows
:
3
})
const
emit
=
defineEmits
<
{
(
e
:
'
update:modelValue
'
,
value
:
string
):
void
(
e
:
'
change
'
,
value
:
string
):
void
(
e
:
'
blur
'
,
event
:
FocusEvent
):
void
(
e
:
'
focus
'
,
event
:
FocusEvent
):
void
}
>
()
const
textAreaRef
=
ref
<
HTMLTextAreaElement
|
null
>
(
null
)
const
placeholderText
=
computed
(()
=>
props
.
placeholder
||
''
)
const
onInput
=
(
event
:
Event
)
=>
{
const
value
=
(
event
.
target
as
HTMLTextAreaElement
).
value
emit
(
'
update:modelValue
'
,
value
)
}
// Expose focus method
defineExpose
({
focus
:
()
=>
textAreaRef
.
value
?.
focus
(),
select
:
()
=>
textAreaRef
.
value
?.
select
()
})
</
script
>
frontend/src/components/user/UserAttributeForm.vue
View file @
fb313356
...
...
@@ -52,18 +52,12 @@
/>
<!-- Select -->
<
s
elect
<
S
elect
v-else-if=
"attr.type === 'select'"
v-model=
"localValues[attr.id]"
:required=
"attr.required"
class=
"input"
:options=
"attr.options || []"
@
change=
"emitChange"
>
<option
value=
""
>
{{
t
(
'
common.selectOption
'
)
}}
</option>
<option
v-for=
"opt in attr.options"
:key=
"opt.value"
:value=
"opt.value"
>
{{
opt
.
label
}}
</option>
</select>
/>
<!-- Multi-Select (Checkboxes) -->
<div
v-else-if=
"attr.type === 'multi_select'"
class=
"space-y-2"
>
...
...
@@ -99,11 +93,9 @@
<
script
setup
lang=
"ts"
>
import
{
ref
,
watch
,
onMounted
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
type
{
UserAttributeDefinition
,
UserAttributeValuesMap
}
from
'
@/types
'
const
{
t
}
=
useI18n
()
import
Select
from
'
@/components/common/Select.vue
'
interface
Props
{
userId
?:
number
...
...
frontend/src/components/user/UserAttributesConfigModal.vue
View file @
fb313356
...
...
@@ -142,11 +142,10 @@
<!--
Type
-->
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.users.attributes.type
'
)
}}
<
/label
>
<
select
v
-
model
=
"
form.type
"
class
=
"
input
"
required
>
<
option
v
-
for
=
"
type in attributeTypes
"
:
key
=
"
type
"
:
value
=
"
type
"
>
{{
t
(
`admin.users.attributes.types.${type
}
`
)
}}
<
/option
>
<
/select
>
<
Select
v
-
model
=
"
form.type
"
:
options
=
"
attributeTypes.map(type => ({ value: type, label: t(`admin.users.attributes.types.${type
}
`)
}
))
"
/>
<
/div
>
<!--
Options
(
for
select
/
multi_select
)
-->
...
...
@@ -257,6 +256,7 @@ import { adminAPI } from '@/api/admin'
import
type
{
UserAttributeDefinition
,
UserAttributeType
,
UserAttributeOption
}
from
'
@/types
'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
import
ConfirmDialog
from
'
@/components/common/ConfirmDialog.vue
'
import
Select
from
'
@/components/common/Select.vue
'
const
{
t
}
=
useI18n
()
const
appStore
=
useAppStore
()
...
...
frontend/src/components/user/dashboard/UserDashboardCharts.vue
0 → 100644
View file @
fb313356
<
template
>
<div
class=
"space-y-6"
>
<!-- Date Range Filter -->
<div
class=
"card p-4"
>
<div
class=
"flex flex-wrap items-center gap-4"
>
<div
class=
"flex items-center gap-2"
>
<span
class=
"text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{
t
(
'
dashboard.timeRange
'
)
}}
:
</span>
<DateRangePicker
:start-date=
"startDate"
:end-date=
"endDate"
@
update:startDate=
"$emit('update:startDate', $event)"
@
update:endDate=
"$emit('update:endDate', $event)"
@
change=
"$emit('dateRangeChange', $event)"
/>
</div>
<div
class=
"ml-auto flex items-center gap-2"
>
<span
class=
"text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{
t
(
'
dashboard.granularity
'
)
}}
:
</span>
<div
class=
"w-28"
>
<Select
:model-value=
"granularity"
:options=
"[
{value:'day', label:t('dashboard.day')}, {value:'hour', label:t('dashboard.hour')}]" @update:model-value="$emit('update:granularity', $event)" @change="$emit('granularityChange')" />
</div>
</div>
</div>
</div>
<!-- Charts Grid -->
<div
class=
"grid grid-cols-1 gap-6 lg:grid-cols-2"
>
<!-- Model Distribution Chart -->
<div
class=
"card relative overflow-hidden p-4"
>
<div
v-if=
"loading"
class=
"absolute inset-0 z-10 flex items-center justify-center bg-white/50 backdrop-blur-sm dark:bg-dark-800/50"
>
<LoadingSpinner
size=
"md"
/>
</div>
<h3
class=
"mb-4 text-sm font-semibold text-gray-900 dark:text-white"
>
{{
t
(
'
dashboard.modelDistribution
'
)
}}
</h3>
<div
class=
"flex items-center gap-6"
>
<div
class=
"h-48 w-48"
>
<Doughnut
v-if=
"modelData"
:data=
"modelData"
:options=
"doughnutOptions"
/>
<div
v-else
class=
"flex h-full items-center justify-center text-sm text-gray-500 dark:text-gray-400"
>
{{
t
(
'
dashboard.noDataAvailable
'
)
}}
</div>
</div>
<div
class=
"max-h-48 flex-1 overflow-y-auto"
>
<table
class=
"w-full text-xs"
>
<thead>
<tr
class=
"text-gray-500 dark:text-gray-400"
>
<th
class=
"pb-2 text-left"
>
{{
t
(
'
dashboard.model
'
)
}}
</th>
<th
class=
"pb-2 text-right"
>
{{
t
(
'
dashboard.requests
'
)
}}
</th>
<th
class=
"pb-2 text-right"
>
{{
t
(
'
dashboard.tokens
'
)
}}
</th>
<th
class=
"pb-2 text-right"
>
{{
t
(
'
dashboard.actual
'
)
}}
</th>
<th
class=
"pb-2 text-right"
>
{{
t
(
'
dashboard.standard
'
)
}}
</th>
</tr>
</thead>
<tbody>
<tr
v-for=
"model in models"
:key=
"model.model"
class=
"border-t border-gray-100 dark:border-gray-700"
>
<td
class=
"max-w-[100px] truncate py-1.5 font-medium text-gray-900 dark:text-white"
:title=
"model.model"
>
{{
model
.
model
}}
</td>
<td
class=
"py-1.5 text-right text-gray-600 dark:text-gray-400"
>
{{
formatNumber
(
model
.
requests
)
}}
</td>
<td
class=
"py-1.5 text-right text-gray-600 dark:text-gray-400"
>
{{
formatTokens
(
model
.
total_tokens
)
}}
</td>
<td
class=
"py-1.5 text-right text-green-600 dark:text-green-400"
>
$
{{
formatCost
(
model
.
actual_cost
)
}}
</td>
<td
class=
"py-1.5 text-right text-gray-400 dark:text-gray-500"
>
$
{{
formatCost
(
model
.
cost
)
}}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Token Usage Trend Chart -->
<div
class=
"card relative overflow-hidden p-4"
>
<div
v-if=
"loading"
class=
"absolute inset-0 z-10 flex items-center justify-center bg-white/50 backdrop-blur-sm dark:bg-dark-800/50"
>
<LoadingSpinner
size=
"md"
/>
</div>
<h3
class=
"mb-4 text-sm font-semibold text-gray-900 dark:text-white"
>
{{
t
(
'
dashboard.tokenUsageTrend
'
)
}}
</h3>
<div
class=
"h-48"
>
<Line
v-if=
"trendData"
:data=
"trendData"
:options=
"lineOptions"
/>
<div
v-else
class=
"flex h-full items-center justify-center text-sm text-gray-500 dark:text-gray-400"
>
{{
t
(
'
dashboard.noDataAvailable
'
)
}}
</div>
</div>
</div>
</div>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
computed
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
LoadingSpinner
from
'
@/components/common/LoadingSpinner.vue
'
import
DateRangePicker
from
'
@/components/common/DateRangePicker.vue
'
import
Select
from
'
@/components/common/Select.vue
'
import
{
Line
,
Doughnut
}
from
'
vue-chartjs
'
import
type
{
TrendDataPoint
,
ModelStat
}
from
'
@/types
'
import
{
formatCostFixed
as
formatCost
,
formatNumberLocaleString
as
formatNumber
,
formatTokensK
as
formatTokens
}
from
'
@/utils/format
'
import
{
Chart
as
ChartJS
,
CategoryScale
,
LinearScale
,
PointElement
,
LineElement
,
ArcElement
,
Title
,
Tooltip
,
Legend
,
Filler
}
from
'
chart.js
'
ChartJS
.
register
(
CategoryScale
,
LinearScale
,
PointElement
,
LineElement
,
ArcElement
,
Title
,
Tooltip
,
Legend
,
Filler
)
const
props
=
defineProps
<
{
loading
:
boolean
,
startDate
:
string
,
endDate
:
string
,
granularity
:
string
,
trend
:
TrendDataPoint
[],
models
:
ModelStat
[]
}
>
()
defineEmits
([
'
update:startDate
'
,
'
update:endDate
'
,
'
update:granularity
'
,
'
dateRangeChange
'
,
'
granularityChange
'
])
const
{
t
}
=
useI18n
()
const
modelData
=
computed
(()
=>
!
props
.
models
?.
length
?
null
:
{
labels
:
props
.
models
.
map
((
m
:
ModelStat
)
=>
m
.
model
),
datasets
:
[{
data
:
props
.
models
.
map
((
m
:
ModelStat
)
=>
m
.
total_tokens
),
backgroundColor
:
[
'
#3b82f6
'
,
'
#10b981
'
,
'
#f59e0b
'
,
'
#ef4444
'
,
'
#8b5cf6
'
,
'
#ec4899
'
,
'
#06b6d4
'
,
'
#84cc16
'
]
}]
})
const
trendData
=
computed
(()
=>
!
props
.
trend
?.
length
?
null
:
{
labels
:
props
.
trend
.
map
((
d
:
TrendDataPoint
)
=>
d
.
date
),
datasets
:
[
{
label
:
t
(
'
dashboard.input
'
),
data
:
props
.
trend
.
map
((
d
:
TrendDataPoint
)
=>
d
.
input_tokens
),
borderColor
:
'
#3b82f6
'
,
backgroundColor
:
'
rgba(59, 130, 246, 0.1)
'
,
tension
:
0.3
,
fill
:
true
},
{
label
:
t
(
'
dashboard.output
'
),
data
:
props
.
trend
.
map
((
d
:
TrendDataPoint
)
=>
d
.
output_tokens
),
borderColor
:
'
#10b981
'
,
backgroundColor
:
'
rgba(16, 185, 129, 0.1)
'
,
tension
:
0.3
,
fill
:
true
}
]
})
const
doughnutOptions
=
{
responsive
:
true
,
maintainAspectRatio
:
false
,
plugins
:
{
legend
:
{
display
:
false
},
tooltip
:
{
callbacks
:
{
label
:
(
context
:
any
)
=>
`
${
context
.
label
}
:
${
formatTokens
(
context
.
parsed
)}
tokens`
}
}
}
}
const
lineOptions
=
{
responsive
:
true
,
maintainAspectRatio
:
false
,
plugins
:
{
legend
:
{
display
:
true
,
position
:
'
top
'
as
const
},
tooltip
:
{
callbacks
:
{
label
:
(
context
:
any
)
=>
`
${
context
.
dataset
.
label
}
:
${
formatTokens
(
context
.
parsed
.
y
)}
tokens`
}
}
},
scales
:
{
y
:
{
beginAtZero
:
true
,
ticks
:
{
callback
:
(
value
:
any
)
=>
formatTokens
(
value
)
}
}
}
}
</
script
>
frontend/src/components/user/dashboard/UserDashboardQuickActions.vue
0 → 100644
View file @
fb313356
<
template
>
<div
class=
"card"
>
<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
(
'
dashboard.quickActions
'
)
}}
</h2>
</div>
<div
class=
"space-y-3 p-4"
>
<button
@
click=
"router.push('/keys')"
class=
"group flex w-full items-center gap-4 rounded-xl bg-gray-50 p-4 text-left transition-all duration-200 hover:bg-gray-100 dark:bg-dark-800/50 dark:hover:bg-dark-800"
>
<div
class=
"flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl bg-primary-100 transition-transform group-hover:scale-105 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=
"M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"
/>
</svg>
</div>
<div
class=
"min-w-0 flex-1"
>
<p
class=
"text-sm font-medium text-gray-900 dark:text-white"
>
{{
t
(
'
dashboard.createApiKey
'
)
}}
</p>
<p
class=
"text-xs text-gray-500 dark:text-dark-400"
>
{{
t
(
'
dashboard.generateNewKey
'
)
}}
</p>
</div>
<svg
class=
"h-5 w-5 text-gray-400 transition-colors group-hover:text-primary-500 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=
"M8.25 4.5l7.5 7.5-7.5 7.5"
/>
</svg>
</button>
<button
@
click=
"router.push('/usage')"
class=
"group flex w-full items-center gap-4 rounded-xl bg-gray-50 p-4 text-left transition-all duration-200 hover:bg-gray-100 dark:bg-dark-800/50 dark:hover:bg-dark-800"
>
<div
class=
"flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl bg-emerald-100 transition-transform group-hover:scale-105 dark:bg-emerald-900/30"
>
<svg
class=
"h-6 w-6 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=
"M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z"
/>
</svg>
</div>
<div
class=
"min-w-0 flex-1"
>
<p
class=
"text-sm font-medium text-gray-900 dark:text-white"
>
{{
t
(
'
dashboard.viewUsage
'
)
}}
</p>
<p
class=
"text-xs text-gray-500 dark:text-dark-400"
>
{{
t
(
'
dashboard.checkDetailedLogs
'
)
}}
</p>
</div>
<svg
class=
"h-5 w-5 text-gray-400 transition-colors group-hover:text-emerald-500 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=
"M8.25 4.5l7.5 7.5-7.5 7.5"
/>
</svg>
</button>
<button
@
click=
"router.push('/redeem')"
class=
"group flex w-full items-center gap-4 rounded-xl bg-gray-50 p-4 text-left transition-all duration-200 hover:bg-gray-100 dark:bg-dark-800/50 dark:hover:bg-dark-800"
>
<div
class=
"flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl bg-amber-100 transition-transform group-hover:scale-105 dark:bg-amber-900/30"
>
<svg
class=
"h-6 w-6 text-amber-600 dark:text-amber-400"
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>
<div
class=
"min-w-0 flex-1"
>
<p
class=
"text-sm font-medium text-gray-900 dark:text-white"
>
{{
t
(
'
dashboard.redeemCode
'
)
}}
</p>
<p
class=
"text-xs text-gray-500 dark:text-dark-400"
>
{{
t
(
'
dashboard.addBalance
'
)
}}
</p>
</div>
<svg
class=
"h-5 w-5 text-gray-400 transition-colors group-hover:text-amber-500 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=
"M8.25 4.5l7.5 7.5-7.5 7.5"
/>
</svg>
</button>
</div>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
useRouter
}
from
'
vue-router
'
import
{
useI18n
}
from
'
vue-i18n
'
const
router
=
useRouter
()
const
{
t
}
=
useI18n
()
</
script
>
\ No newline at end of file
frontend/src/components/user/dashboard/UserDashboardRecentUsage.vue
0 → 100644
View file @
fb313356
<
template
>
<div
class=
"card"
>
<div
class=
"flex items-center justify-between 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
(
'
dashboard.recentUsage
'
)
}}
</h2>
<span
class=
"badge badge-gray"
>
{{
t
(
'
dashboard.last7Days
'
)
}}
</span>
</div>
<div
class=
"p-6"
>
<div
v-if=
"loading"
class=
"flex items-center justify-center py-12"
>
<LoadingSpinner
size=
"lg"
/>
</div>
<div
v-else-if=
"data.length === 0"
class=
"py-8"
>
<EmptyState
:title=
"t('dashboard.noUsageRecords')"
:description=
"t('dashboard.startUsingApi')"
/>
</div>
<div
v-else
class=
"space-y-3"
>
<div
v-for=
"log in data"
:key=
"log.id"
class=
"flex items-center justify-between rounded-xl bg-gray-50 p-4 transition-colors hover:bg-gray-100 dark:bg-dark-800/50 dark:hover:bg-dark-800"
>
<div
class=
"flex items-center gap-4"
>
<div
class=
"flex h-10 w-10 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=
"M9.75 3.104v5.714a2.25 2.25 0 01-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 014.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0112 15a9.065 9.065 0 00-6.23.693L5 14.5m14.8.8l1.402 1.402c1.232 1.232.65 3.318-1.067 3.611A48.309 48.309 0 0112 21c-2.773 0-5.491-.235-8.135-.687-1.718-.293-2.3-2.379-1.067-3.61L5 14.5"
/>
</svg>
</div>
<div>
<p
class=
"text-sm font-medium text-gray-900 dark:text-white"
>
{{
log
.
model
}}
</p>
<p
class=
"text-xs text-gray-500 dark:text-dark-400"
>
{{
formatDateTime
(
log
.
created_at
)
}}
</p>
</div>
</div>
<div
class=
"text-right"
>
<p
class=
"text-sm font-semibold"
>
<span
class=
"text-green-600 dark:text-green-400"
:title=
"t('dashboard.actual')"
>
$
{{
formatCost
(
log
.
actual_cost
)
}}
</span>
<span
class=
"font-normal text-gray-400 dark:text-gray-500"
:title=
"t('dashboard.standard')"
>
/ $
{{
formatCost
(
log
.
total_cost
)
}}
</span>
</p>
<p
class=
"text-xs text-gray-500 dark:text-dark-400"
>
{{
(
log
.
input_tokens
+
log
.
output_tokens
).
toLocaleString
()
}}
tokens
</p>
</div>
</div>
<router-link
to=
"/usage"
class=
"flex items-center justify-center gap-2 py-3 text-sm font-medium text-primary-600 transition-colors hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
>
{{
t
(
'
dashboard.viewAllUsage
'
)
}}
<svg
class=
"h-4 w-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3"
/>
</svg>
</router-link>
</div>
</div>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
useI18n
}
from
'
vue-i18n
'
import
LoadingSpinner
from
'
@/components/common/LoadingSpinner.vue
'
import
EmptyState
from
'
@/components/common/EmptyState.vue
'
import
{
formatDateTime
}
from
'
@/utils/format
'
import
type
{
UsageLog
}
from
'
@/types
'
defineProps
<
{
data
:
UsageLog
[]
loading
:
boolean
}
>
()
const
{
t
}
=
useI18n
()
const
formatCost
=
(
c
:
number
)
=>
c
.
toFixed
(
4
)
</
script
>
frontend/src/components/user/dashboard/UserDashboardStats.vue
0 → 100644
View file @
fb313356
<
template
>
<!-- Row 1: Core Stats -->
<div
class=
"grid grid-cols-2 gap-4 lg:grid-cols-4"
>
<!-- Balance -->
<div
v-if=
"!isSimple"
class=
"card p-4"
>
<div
class=
"flex items-center gap-3"
>
<div
class=
"rounded-lg bg-emerald-100 p-2 dark:bg-emerald-900/30"
>
<svg
class=
"h-5 w-5 text-emerald-600 dark:text-emerald-400"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
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>
<div>
<p
class=
"text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{
t
(
'
dashboard.balance
'
)
}}
</p>
<p
class=
"text-xl font-bold text-emerald-600 dark:text-emerald-400"
>
$
{{
formatBalance
(
balance
)
}}
</p>
<p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
common.available
'
)
}}
</p>
</div>
</div>
</div>
<!-- API Keys -->
<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=
"M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"
/>
</svg>
</div>
<div>
<p
class=
"text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{
t
(
'
dashboard.apiKeys
'
)
}}
</p>
<p
class=
"text-xl font-bold text-gray-900 dark:text-white"
>
{{
stats
?.
total_api_keys
||
0
}}
</p>
<p
class=
"text-xs text-green-600 dark:text-green-400"
>
{{
stats
?.
active_api_keys
||
0
}}
{{
t
(
'
common.active
'
)
}}
</p>
</div>
</div>
</div>
<!-- Today Requests -->
<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=
"M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z"
/>
</svg>
</div>
<div>
<p
class=
"text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{
t
(
'
dashboard.todayRequests
'
)
}}
</p>
<p
class=
"text-xl font-bold text-gray-900 dark:text-white"
>
{{
stats
?.
today_requests
||
0
}}
</p>
<p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
common.total
'
)
}}
:
{{
formatNumber
(
stats
?.
total_requests
||
0
)
}}
</p>
</div>
</div>
</div>
<!-- Today Cost -->
<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 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>
</div>
<div>
<p
class=
"text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{
t
(
'
dashboard.todayCost
'
)
}}
</p>
<p
class=
"text-xl font-bold text-gray-900 dark:text-white"
>
<span
class=
"text-purple-600 dark:text-purple-400"
:title=
"t('dashboard.actual')"
>
$
{{
formatCost
(
stats
?.
today_actual_cost
||
0
)
}}
</span>
<span
class=
"text-sm font-normal text-gray-400 dark:text-gray-500"
:title=
"t('dashboard.standard')"
>
/ $
{{
formatCost
(
stats
?.
today_cost
||
0
)
}}
</span>
</p>
<p
class=
"text-xs"
>
<span
class=
"text-gray-500 dark:text-gray-400"
>
{{
t
(
'
common.total
'
)
}}
:
</span>
<span
class=
"text-purple-600 dark:text-purple-400"
:title=
"t('dashboard.actual')"
>
$
{{
formatCost
(
stats
?.
total_actual_cost
||
0
)
}}
</span>
<span
class=
"text-gray-400 dark:text-gray-500"
:title=
"t('dashboard.standard')"
>
/ $
{{
formatCost
(
stats
?.
total_cost
||
0
)
}}
</span>
</p>
</div>
</div>
</div>
</div>
<!-- Row 2: Token Stats -->
<div
class=
"grid grid-cols-2 gap-4 lg:grid-cols-4"
>
<!-- Today Tokens -->
<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>
</div>
<div>
<p
class=
"text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{
t
(
'
dashboard.todayTokens
'
)
}}
</p>
<p
class=
"text-xl font-bold text-gray-900 dark:text-white"
>
{{
formatTokens
(
stats
?.
today_tokens
||
0
)
}}
</p>
<p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
dashboard.input
'
)
}}
:
{{
formatTokens
(
stats
?.
today_input_tokens
||
0
)
}}
/
{{
t
(
'
dashboard.output
'
)
}}
:
{{
formatTokens
(
stats
?.
today_output_tokens
||
0
)
}}
</p>
</div>
</div>
</div>
<!-- Total Tokens -->
<div
class=
"card p-4"
>
<div
class=
"flex items-center gap-3"
>
<div
class=
"rounded-lg bg-indigo-100 p-2 dark:bg-indigo-900/30"
>
<svg
class=
"h-5 w-5 text-indigo-600 dark:text-indigo-400"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125"
/>
</svg>
</div>
<div>
<p
class=
"text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{
t
(
'
dashboard.totalTokens
'
)
}}
</p>
<p
class=
"text-xl font-bold text-gray-900 dark:text-white"
>
{{
formatTokens
(
stats
?.
total_tokens
||
0
)
}}
</p>
<p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
dashboard.input
'
)
}}
:
{{
formatTokens
(
stats
?.
total_input_tokens
||
0
)
}}
/
{{
t
(
'
dashboard.output
'
)
}}
:
{{
formatTokens
(
stats
?.
total_output_tokens
||
0
)
}}
</p>
</div>
</div>
</div>
<!-- Performance (RPM/TPM) -->
<div
class=
"card p-4"
>
<div
class=
"flex items-center gap-3"
>
<div
class=
"rounded-lg bg-violet-100 p-2 dark:bg-violet-900/30"
>
<svg
class=
"h-5 w-5 text-violet-600 dark:text-violet-400"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
</div>
<div
class=
"flex-1"
>
<p
class=
"text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{
t
(
'
dashboard.performance
'
)
}}
</p>
<div
class=
"flex items-baseline gap-2"
>
<p
class=
"text-xl font-bold text-gray-900 dark:text-white"
>
{{
formatTokens
(
stats
?.
rpm
||
0
)
}}
</p>
<span
class=
"text-xs text-gray-500 dark:text-gray-400"
>
RPM
</span>
</div>
<div
class=
"flex items-baseline gap-2"
>
<p
class=
"text-sm font-semibold text-violet-600 dark:text-violet-400"
>
{{
formatTokens
(
stats
?.
tpm
||
0
)
}}
</p>
<span
class=
"text-xs text-gray-500 dark:text-gray-400"
>
TPM
</span>
</div>
</div>
</div>
</div>
<!-- Avg Response Time -->
<div
class=
"card p-4"
>
<div
class=
"flex items-center gap-3"
>
<div
class=
"rounded-lg bg-rose-100 p-2 dark:bg-rose-900/30"
>
<svg
class=
"h-5 w-5 text-rose-600 dark:text-rose-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
(
'
dashboard.avgResponse
'
)
}}
</p>
<p
class=
"text-xl font-bold text-gray-900 dark:text-white"
>
{{
formatDuration
(
stats
?.
average_duration_ms
||
0
)
}}
</p>
<p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
dashboard.averageTime
'
)
}}
</p>
</div>
</div>
</div>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
useI18n
}
from
'
vue-i18n
'
import
type
{
UserDashboardStats
as
UserStatsType
}
from
'
@/api/usage
'
defineProps
<
{
stats
:
UserStatsType
balance
:
number
isSimple
:
boolean
}
>
()
const
{
t
}
=
useI18n
()
const
formatBalance
=
(
b
:
number
)
=>
new
Intl
.
NumberFormat
(
'
en-US
'
,
{
minimumFractionDigits
:
2
,
maximumFractionDigits
:
2
}).
format
(
b
)
const
formatNumber
=
(
n
:
number
)
=>
n
.
toLocaleString
()
const
formatCost
=
(
c
:
number
)
=>
c
.
toFixed
(
4
)
const
formatTokens
=
(
t
:
number
)
=>
(
t
>=
1000
?
`
${(
t
/
1000
).
toFixed
(
1
)}
K`
:
t
.
toString
())
const
formatDuration
=
(
ms
:
number
)
=>
ms
>=
1000
?
`
${(
ms
/
1000
).
toFixed
(
2
)}
s`
:
`
${
ms
.
toFixed
(
0
)}
ms`
</
script
>
frontend/src/components/user/profile/ProfileEditForm.vue
0 → 100644
View file @
fb313356
<
template
>
<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=
"username"
type=
"text"
class=
"input"
:placeholder=
"t('profile.enterUsername')"
/>
</div>
<div
class=
"flex justify-end pt-4"
>
<button
type=
"submit"
:disabled=
"loading"
class=
"btn btn-primary"
>
{{
loading
?
t
(
'
profile.updating
'
)
:
t
(
'
profile.updateProfile
'
)
}}
</button>
</div>
</form>
</div>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
ref
,
watch
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAuthStore
}
from
'
@/stores/auth
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
userAPI
}
from
'
@/api
'
const
props
=
defineProps
<
{
initialUsername
:
string
}
>
()
const
{
t
}
=
useI18n
()
const
authStore
=
useAuthStore
()
const
appStore
=
useAppStore
()
const
username
=
ref
(
props
.
initialUsername
)
const
loading
=
ref
(
false
)
watch
(()
=>
props
.
initialUsername
,
(
val
)
=>
{
username
.
value
=
val
})
const
handleUpdateProfile
=
async
()
=>
{
if
(
!
username
.
value
.
trim
())
{
appStore
.
showError
(
t
(
'
profile.usernameRequired
'
))
return
}
loading
.
value
=
true
try
{
const
updatedUser
=
await
userAPI
.
updateProfile
({
username
:
username
.
value
})
authStore
.
user
=
updatedUser
appStore
.
showSuccess
(
t
(
'
profile.updateSuccess
'
))
}
catch
(
error
:
any
)
{
appStore
.
showError
(
error
.
response
?.
data
?.
detail
||
t
(
'
profile.updateFailed
'
))
}
finally
{
loading
.
value
=
false
}
}
</
script
>
Prev
1
2
3
4
5
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