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/components/admin/user/UserEditModal.vue
0 → 100644
View file @
195e227c
<
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"
>
<Icon
name=
"refresh"
size=
"md"
/>
</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
'
import
Icon
from
'
@/components/icons/Icon.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
if
(
!
form
.
email
.
trim
())
{
appStore
.
showError
(
t
(
'
admin.users.emailRequired
'
))
return
}
if
(
form
.
concurrency
<
1
)
{
appStore
.
showError
(
t
(
'
admin.users.concurrencyMin
'
))
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
>
frontend/src/components/common/BaseDialog.vue
View file @
195e227c
...
...
@@ -21,15 +21,7 @@
class=
"-mr-2 rounded-xl p-2 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 dark:text-dark-500 dark:hover:bg-dark-700 dark:hover:text-dark-300"
aria-label=
"Close modal"
>
<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=
"M6 18L18 6M6 6l12 12"
/>
</svg>
<Icon
name=
"x"
size=
"md"
/>
</button>
</div>
...
...
@@ -50,6 +42,7 @@
<
script
setup
lang=
"ts"
>
import
{
computed
,
watch
,
onMounted
,
onUnmounted
,
ref
,
nextTick
}
from
'
vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
// 生成唯一ID以避免多个对话框时ID冲突
let
dialogIdCounter
=
0
...
...
frontend/src/components/common/DataTable.vue
View file @
195e227c
...
...
@@ -66,19 +66,11 @@
>
<slot
name=
"empty"
>
<div
class=
"flex flex-col items-center"
>
<svg
<Icon
name=
"inbox"
size=
"xl"
class=
"mb-4 h-12 w-12 text-gray-400 dark:text-dark-500"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"
/>
</svg>
<p
class=
"text-lg font-medium text-gray-900 dark:text-gray-100"
>
{{
t
(
'
empty.noData
'
)
}}
</p>
...
...
@@ -117,6 +109,7 @@
import
{
computed
,
ref
,
onMounted
,
onUnmounted
,
watch
,
nextTick
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
type
{
Column
}
from
'
./types
'
import
Icon
from
'
@/components/icons/Icon.vue
'
const
{
t
}
=
useI18n
()
...
...
frontend/src/components/common/DateRangePicker.vue
View file @
195e227c
...
...
@@ -6,33 +6,17 @@
:class=
"['date-picker-trigger', isOpen && 'date-picker-trigger-open']"
>
<span
class=
"date-picker-icon"
>
<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 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"
/>
</svg>
<Icon
name=
"calendar"
size=
"sm"
/>
</span>
<span
class=
"date-picker-value"
>
{{
displayValue
}}
</span>
<span
class=
"date-picker-chevron"
>
<svg
:class=
"['h-4 w-4 transition-transform duration-200', isOpen && 'rotate-180']"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M19.5 8.25l-7.5 7.5-7.5-7.5"
/>
</svg>
<Icon
name=
"chevronDown"
size=
"sm"
:class=
"['transition-transform duration-200', isOpen && 'rotate-180']"
/>
</span>
</button>
...
...
@@ -65,19 +49,7 @@
/>
</div>
<div
class=
"date-picker-separator"
>
<svg
class=
"h-4 w-4 text-gray-400"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M17.25 8.25L21 12m0 0l-3.75 3.75M21 12H3"
/>
</svg>
<Icon
name=
"arrowRight"
size=
"sm"
class=
"text-gray-400"
/>
</div>
<div
class=
"date-picker-field"
>
<label
class=
"date-picker-label"
>
{{
t
(
'
dates.endDate
'
)
}}
</label>
...
...
@@ -106,6 +78,7 @@
<
script
setup
lang=
"ts"
>
import
{
ref
,
computed
,
watch
,
onMounted
,
onUnmounted
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
Icon
from
'
@/components/icons/Icon.vue
'
interface
DatePreset
{
labelKey
:
string
...
...
frontend/src/components/common/EmptyState.vue
View file @
195e227c
...
...
@@ -43,16 +43,7 @@
@
click=
"!actionTo && $emit('action')"
class=
"btn btn-primary"
>
<svg
v-if=
"actionIcon"
class=
"mr-2 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=
"M12 4.5v15m7.5-7.5h-15"
/>
</svg>
<Icon
v-if=
"actionIcon"
name=
"plus"
size=
"md"
class=
"mr-2"
/>
{{
actionText
}}
</component>
</slot>
...
...
@@ -64,6 +55,7 @@
import
{
computed
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
type
{
Component
}
from
'
vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
const
{
t
}
=
useI18n
()
...
...
frontend/src/components/common/GroupOptionItem.vue
0 → 100644
View file @
195e227c
<
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 @
195e227c
<
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 @
195e227c
<
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/LocaleSwitcher.vue
View file @
195e227c
...
...
@@ -7,16 +7,12 @@
>
<span
class=
"text-base"
>
{{
currentLocale
?.
flag
}}
</span>
<span
class=
"hidden sm:inline"
>
{{
currentLocale
?.
code
.
toUpperCase
()
}}
</span>
<svg
class=
"h-3.5 w-3.5 text-gray-400 transition-transform duration-200"
<Icon
name=
"chevronDown"
size=
"xs"
class=
"text-gray-400 transition-transform duration-200"
:class=
"
{ 'rotate-180': isOpen }"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M19.5 8.25l-7.5 7.5-7.5-7.5"
/>
</svg>
/>
</button>
<transition
name=
"dropdown"
>
...
...
@@ -36,16 +32,7 @@
>
<span
class=
"text-base"
>
{{
locale
.
flag
}}
</span>
<span>
{{
locale
.
name
}}
</span>
<svg
v-if=
"locale.code === currentLocaleCode"
class=
"ml-auto h-4 w-4 text-primary-500"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M4.5 12.75l6 6 9-13.5"
/>
</svg>
<Icon
v-if=
"locale.code === currentLocaleCode"
name=
"check"
size=
"sm"
class=
"ml-auto text-primary-500"
/>
</button>
</div>
</transition>
...
...
@@ -55,6 +42,7 @@
<
script
setup
lang=
"ts"
>
import
{
ref
,
computed
,
onMounted
,
onBeforeUnmount
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
{
setLocale
,
availableLocales
}
from
'
@/i18n
'
const
{
locale
}
=
useI18n
()
...
...
frontend/src/components/common/Pagination.vue
View file @
195e227c
...
...
@@ -63,13 +63,7 @@
class
=
"
relative inline-flex items-center rounded-l-md border border-gray-300 bg-white px-2 py-2 text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-dark-600 dark:bg-dark-700 dark:text-gray-400 dark:hover:bg-dark-600
"
:
aria
-
label
=
"
t('pagination.previous')
"
>
<
svg
class
=
"
h-5 w-5
"
fill
=
"
currentColor
"
viewBox
=
"
0 0 20 20
"
>
<
path
fill
-
rule
=
"
evenodd
"
d
=
"
M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z
"
clip
-
rule
=
"
evenodd
"
/>
<
/svg
>
<
Icon
name
=
"
chevronLeft
"
size
=
"
md
"
/>
<
/button
>
<!--
Page
numbers
-->
...
...
@@ -100,13 +94,7 @@
class
=
"
relative inline-flex items-center rounded-r-md border border-gray-300 bg-white px-2 py-2 text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-dark-600 dark:bg-dark-700 dark:text-gray-400 dark:hover:bg-dark-600
"
:
aria
-
label
=
"
t('pagination.next')
"
>
<
svg
class
=
"
h-5 w-5
"
fill
=
"
currentColor
"
viewBox
=
"
0 0 20 20
"
>
<
path
fill
-
rule
=
"
evenodd
"
d
=
"
M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z
"
clip
-
rule
=
"
evenodd
"
/>
<
/svg
>
<
Icon
name
=
"
chevronRight
"
size
=
"
md
"
/>
<
/button
>
<
/nav
>
<
/div
>
...
...
@@ -116,6 +104,7 @@
<
script
setup
lang
=
"
ts
"
>
import
{
computed
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
Select
from
'
./Select.vue
'
const
{
t
}
=
useI18n
()
...
...
frontend/src/components/common/PlatformTypeBadge.vue
View file @
195e227c
...
...
@@ -23,35 +23,9 @@
/>
</svg>
<!-- Setup Token icon -->
<svg
v-else-if=
"type === 'setup-token'"
class=
"h-3 w-3"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z"
/>
</svg>
<Icon
v-else-if=
"type === 'setup-token'"
name=
"shield"
size=
"xs"
/>
<!-- API Key icon -->
<svg
v-else
class=
"h-3 w-3"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"2"
>
<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>
<Icon
v-else
name=
"key"
size=
"xs"
/>
<span>
{{
typeLabel
}}
</span>
</span>
</div>
...
...
@@ -61,6 +35,7 @@
import
{
computed
}
from
'
vue
'
import
type
{
AccountPlatform
,
AccountType
}
from
'
@/types
'
import
PlatformIcon
from
'
./PlatformIcon.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
interface
Props
{
platform
:
AccountPlatform
...
...
frontend/src/components/common/ProxySelector.vue
View file @
195e227c
...
...
@@ -14,15 +14,11 @@
{{
selectedLabel
}}
</span>
<span
class=
"select-icon"
>
<svg
:class=
"['h-5 w-5 transition-transform duration-200', isOpen && 'rotate-180']"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M19.5 8.25l-7.5 7.5-7.5-7.5"
/>
</svg>
<Icon
name=
"chevronDown"
size=
"md"
:class=
"['transition-transform duration-200', isOpen && 'rotate-180']"
/>
</span>
</button>
...
...
@@ -31,19 +27,7 @@
<!-- Search and Batch Test Header -->
<div
class=
"select-header"
>
<div
class=
"select-search"
>
<svg
class=
"h-4 w-4 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>
<Icon
name=
"search"
size=
"sm"
class=
"text-gray-400"
/>
<input
ref=
"searchInputRef"
v-model=
"searchQuery"
...
...
@@ -76,20 +60,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=
"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=
"M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 010 1.972l-11.54 6.347a1.125 1.125 0 01-1.667-.986V5.653z"
/>
</svg>
<Icon
v-else
name=
"play"
size=
"sm"
/>
</button>
</div>
...
...
@@ -101,16 +72,7 @@
:class=
"['select-option', modelValue === null && 'select-option-selected']"
>
<span
class=
"select-option-label"
>
{{
t
(
'
admin.accounts.noProxy
'
)
}}
</span>
<svg
v-if=
"modelValue === null"
class=
"h-4 w-4 text-primary-500"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M4.5 12.75l6 6 9-13.5"
/>
</svg>
<Icon
v-if=
"modelValue === null"
name=
"check"
size=
"sm"
class=
"text-primary-500"
/>
</div>
<!-- Proxy options -->
...
...
@@ -184,32 +146,15 @@
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=
"h-3.5 w-3.5"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 010 1.972l-11.54 6.347a1.125 1.125 0 01-1.667-.986V5.653z"
/>
</svg>
<Icon
v-else
name=
"play"
size=
"xs"
/>
</button>
<
svg
<
Icon
v-if=
"modelValue === proxy.id"
class=
"h-4 w-4 flex-shrink-0 text-primary-500"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M4.5 12.75l6 6 9-13.5"
/>
</svg>
name=
"check"
size=
"sm"
class=
"flex-shrink-0 text-primary-500"
/>
</div>
<!-- Empty state -->
...
...
@@ -226,6 +171,7 @@
import
{
ref
,
reactive
,
computed
,
onMounted
,
onUnmounted
,
nextTick
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
type
{
Proxy
}
from
'
@/types
'
const
{
t
}
=
useI18n
()
...
...
frontend/src/components/common/SearchInput.vue
0 → 100644
View file @
195e227c
<
template
>
<div
class=
"relative w-full"
>
<div
class=
"pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3"
>
<Icon
name=
"search"
size=
"md"
class=
"text-gray-400"
/>
</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
'
import
Icon
from
'
@/components/icons/Icon.vue
'
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 @
195e227c
<
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"
>
...
...
@@ -17,44 +23,31 @@
</slot>
</span>
<span
class=
"select-icon"
>
<svg
:class=
"['h-5 w-5 transition-transform duration-200', isOpen && 'rotate-180']"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M19.5 8.25l-7.5 7.5-7.5-7.5"
/>
</svg>
<Icon
name=
"chevronDown"
size=
"md"
:class=
"['transition-transform duration-200', isOpen && 'rotate-180']"
/>
</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"
>
<svg
class=
"h-4 w-4 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>
<Icon
name=
"search"
size=
"sm"
class=
"text-gray-400"
/>
<input
ref=
"searchInputRef"
v-model=
"searchQuery"
...
...
@@ -66,25 +59,31 @@
</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>
<
svg
<
Icon
v-if=
"isSelected(option)"
class=
"h-4 w-4 text-primary-500"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M4.5 12.75l6 6 9-13.5"
/>
</svg>
name=
"check"
size=
"sm"
class=
"text-primary-500"
:stroke-width=
"2"
/>
</slot>
</div>
...
...
@@ -102,9 +101,13 @@
<
script
setup
lang=
"ts"
>
import
{
ref
,
computed
,
watch
,
onMounted
,
onUnmounted
,
nextTick
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
Icon
from
'
@/components/icons/Icon.vue
'
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 +141,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 +168,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 +213,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 +253,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 +392,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 +416,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 +425,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 +433,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 +450,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 @
195e227c
<
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/StatCard.vue
View file @
195e227c
...
...
@@ -8,18 +8,12 @@
<div
class=
"mt-1 flex items-baseline gap-2"
>
<p
class=
"stat-value"
>
{{
formattedValue
}}
</p>
<span
v-if=
"change !== undefined"
:class=
"['stat-trend', trendClass]"
>
<
svg
<
Icon
v-if=
"changeType !== 'neutral'"
:class=
"['h-3 w-3', changeType === 'down' && 'rotate-180']"
fill=
"currentColor"
viewBox=
"0 0 20 20"
>
<path
fill-rule=
"evenodd"
d=
"M5.293 9.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L11 7.414V15a1 1 0 11-2 0V7.414L6.707 9.707a1 1 0 01-1.414 0z"
clip-rule=
"evenodd"
name=
"arrowUp"
size=
"xs"
:class=
"changeType === 'down' && 'rotate-180'"
/>
</svg>
{{
formattedChange
}}
</span>
</div>
...
...
@@ -30,6 +24,7 @@
<
script
setup
lang=
"ts"
>
import
{
computed
}
from
'
vue
'
import
type
{
Component
}
from
'
vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
type
ChangeType
=
'
up
'
|
'
down
'
|
'
neutral
'
type
IconVariant
=
'
primary
'
|
'
success
'
|
'
warning
'
|
'
danger
'
...
...
frontend/src/components/common/StatusBadge.vue
0 → 100644
View file @
195e227c
<
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/SubscriptionProgressMini.vue
View file @
195e227c
...
...
@@ -6,19 +6,7 @@
class=
"flex cursor-pointer items-center gap-2 rounded-xl bg-purple-50 px-3 py-1.5 transition-colors hover:bg-purple-100 dark:bg-purple-900/20 dark:hover:bg-purple-900/30"
:title=
"t('subscriptionProgress.viewDetails')"
>
<svg
class=
"h-4 w-4 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=
"sm"
class=
"text-purple-600 dark:text-purple-400"
/>
<div
class=
"flex items-center gap-1.5"
>
<!-- Combined progress indicator -->
<div
class=
"flex items-center gap-0.5"
>
...
...
@@ -192,6 +180,7 @@
<
script
setup
lang
=
"
ts
"
>
import
{
ref
,
computed
,
onMounted
,
onBeforeUnmount
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
{
useSubscriptionStore
}
from
'
@/stores
'
import
type
{
UserSubscription
}
from
'
@/types
'
...
...
frontend/src/components/common/TextArea.vue
0 → 100644
View file @
195e227c
<
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/common/Toast.vue
View file @
195e227c
...
...
@@ -27,9 +27,10 @@
<div
class=
"flex items-start gap-3"
>
<!-- Icon -->
<div
class=
"mt-0.5 flex-shrink-0"
>
<component
:is=
"getIcon(toast.type)"
:class=
"['h-5 w-5', getIconColor(toast.type)]"
<Icon
:name=
"getToastIconName(toast.type)"
size=
"md"
:class=
"getIconColor(toast.type)"
aria-hidden=
"true"
/>
</div>
...
...
@@ -57,13 +58,7 @@
class=
"-m-1 flex-shrink-0 rounded p-1 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 dark:text-gray-500 dark:hover:bg-dark-700 dark:hover:text-gray-300"
aria-label=
"Close notification"
>
<svg
class=
"h-4 w-4"
fill=
"currentColor"
viewBox=
"0 0 20 20"
>
<path
fill-rule=
"evenodd"
d=
"M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clip-rule=
"evenodd"
/>
</svg>
<Icon
name=
"x"
size=
"sm"
/>
</button>
</div>
</div>
...
...
@@ -82,77 +77,26 @@
</
template
>
<
script
setup
lang=
"ts"
>
import
{
computed
,
onMounted
,
onUnmounted
,
h
}
from
'
vue
'
import
{
computed
,
onMounted
,
onUnmounted
}
from
'
vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
{
useAppStore
}
from
'
@/stores/app
'
const
appStore
=
useAppStore
()
const
toasts
=
computed
(()
=>
appStore
.
toasts
)
const
getIcon
=
(
type
:
string
)
=>
{
const
icons
=
{
success
:
()
=>
h
(
'
svg
'
,
{
fill
:
'
currentColor
'
,
viewBox
:
'
0 0 20 20
'
},
[
h
(
'
path
'
,
{
'
fill-rule
'
:
'
evenodd
'
,
d
:
'
M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z
'
,
'
clip-rule
'
:
'
evenodd
'
})
]
),
error
:
()
=>
h
(
'
svg
'
,
{
fill
:
'
currentColor
'
,
viewBox
:
'
0 0 20 20
'
},
[
h
(
'
path
'
,
{
'
fill-rule
'
:
'
evenodd
'
,
d
:
'
M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z
'
,
'
clip-rule
'
:
'
evenodd
'
})
]
),
warning
:
()
=>
h
(
'
svg
'
,
{
fill
:
'
currentColor
'
,
viewBox
:
'
0 0 20 20
'
},
[
h
(
'
path
'
,
{
'
fill-rule
'
:
'
evenodd
'
,
d
:
'
M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z
'
,
'
clip-rule
'
:
'
evenodd
'
})
]
),
info
:
()
=>
h
(
'
svg
'
,
{
fill
:
'
currentColor
'
,
viewBox
:
'
0 0 20 20
'
},
[
h
(
'
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
'
})
]
)
const
getToastIconName
=
(
type
:
string
):
'
checkCircle
'
|
'
xCircle
'
|
'
exclamationTriangle
'
|
'
infoCircle
'
=>
{
switch
(
type
)
{
case
'
success
'
:
return
'
checkCircle
'
case
'
error
'
:
return
'
xCircle
'
case
'
warning
'
:
return
'
exclamationTriangle
'
case
'
info
'
:
default
:
return
'
infoCircle
'
}
return
icons
[
type
as
keyof
typeof
icons
]
||
icons
.
info
}
const
getIconColor
=
(
type
:
string
):
string
=>
{
...
...
Prev
1
…
3
4
5
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