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
642842c2
Commit
642842c2
authored
Dec 18, 2025
by
shaw
Browse files
First commit
parent
569f4882
Changes
201
Hide whitespace changes
Inline
Side-by-side
Too many changes to show.
To preserve performance only
201 of 201+
files are displayed.
Plain diff
Email patch
frontend/src/components/account/OAuthAuthorizationFlow.vue
0 → 100644
View file @
642842c2
<
template
>
<div
class=
"rounded-lg border border-blue-200 bg-blue-50 dark:border-blue-700 dark:bg-blue-900/30 p-6"
>
<div
class=
"flex items-start gap-4"
>
<div
class=
"flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-blue-500"
>
<svg
class=
"w-5 h-5 text-white"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244"
/>
</svg>
</div>
<div
class=
"flex-1"
>
<h4
class=
"mb-3 font-semibold text-blue-900 dark:text-blue-200"
>
{{
t
(
'
admin.accounts.oauth.title
'
)
}}
</h4>
<!-- Auth Method Selection -->
<div
class=
"mb-4"
>
<label
class=
"mb-2 block text-sm font-medium text-blue-800 dark:text-blue-300"
>
{{
methodLabel
}}
</label>
<div
class=
"flex gap-4"
>
<label
class=
"flex cursor-pointer items-center gap-2"
>
<input
v-model=
"inputMethod"
type=
"radio"
value=
"manual"
class=
"text-blue-600 focus:ring-blue-500"
/>
<span
class=
"text-sm text-blue-900 dark:text-blue-200"
>
{{
t
(
'
admin.accounts.oauth.manualAuth
'
)
}}
</span>
</label>
<label
class=
"flex cursor-pointer items-center gap-2"
>
<input
v-model=
"inputMethod"
type=
"radio"
value=
"cookie"
class=
"text-blue-600 focus:ring-blue-500"
/>
<span
class=
"text-sm text-blue-900 dark:text-blue-200"
>
{{
t
(
'
admin.accounts.oauth.cookieAutoAuth
'
)
}}
</span>
</label>
</div>
</div>
<!-- Cookie Auto-Auth Form -->
<div
v-if=
"inputMethod === 'cookie'"
class=
"space-y-4"
>
<div
class=
"rounded-lg border border-blue-300 dark:border-blue-600 bg-white/80 dark:bg-gray-800/80 p-4"
>
<p
class=
"mb-3 text-sm text-blue-700 dark:text-blue-300"
>
{{
t
(
'
admin.accounts.oauth.cookieAutoAuthDesc
'
)
}}
</p>
<!-- sessionKey Input -->
<div
class=
"mb-4"
>
<label
class=
"mb-2 flex items-center gap-2 text-sm font-semibold text-gray-700 dark:text-gray-300"
>
<svg
class=
"w-4 h-4 text-blue-500"
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>
{{
t
(
'
admin.accounts.oauth.sessionKey
'
)
}}
<span
v-if=
"parsedKeyCount > 1 && allowMultiple"
class=
"rounded-full bg-blue-500 px-2 py-0.5 text-xs text-white"
>
{{
t
(
'
admin.accounts.oauth.keysCount
'
,
{
count
:
parsedKeyCount
}
)
}}
<
/span
>
<
button
v
-
if
=
"
showHelp
"
type
=
"
button
"
class
=
"
text-blue-500 hover:text-blue-600
"
@
click
=
"
showHelpDialog = !showHelpDialog
"
>
<
svg
class
=
"
w-4 h-4
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
stroke
-
width
=
"
1.5
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
d
=
"
M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z
"
/>
<
/svg
>
<
/button
>
<
/label
>
<
textarea
v
-
model
=
"
sessionKeyInput
"
rows
=
"
3
"
class
=
"
input w-full font-mono text-sm resize-y
"
:
placeholder
=
"
allowMultiple ? t('admin.accounts.oauth.sessionKeyPlaceholder') : t('admin.accounts.oauth.sessionKeyPlaceholderSingle')
"
><
/textarea
>
<
p
v
-
if
=
"
parsedKeyCount > 1 && allowMultiple
"
class
=
"
mt-1 text-xs text-blue-600 dark:text-blue-400
"
>
{{
t
(
'
admin.accounts.oauth.batchCreateAccounts
'
,
{
count
:
parsedKeyCount
}
)
}}
<
/p
>
<
/div
>
<!--
Help
Section
-->
<
div
v
-
if
=
"
showHelpDialog && showHelp
"
class
=
"
mb-4 rounded-lg border border-amber-200 bg-amber-50 dark:border-amber-700 dark:bg-amber-900/30 p-3
"
>
<
h5
class
=
"
mb-2 font-semibold text-amber-800 dark:text-amber-200
"
>
{{
t
(
'
admin.accounts.oauth.howToGetSessionKey
'
)
}}
<
/h5
>
<
ol
class
=
"
list-inside list-decimal space-y-1 text-xs text-amber-700 dark:text-amber-300
"
>
<
li
v
-
html
=
"
t('admin.accounts.oauth.step1')
"
><
/li
>
<
li
v
-
html
=
"
t('admin.accounts.oauth.step2')
"
><
/li
>
<
li
v
-
html
=
"
t('admin.accounts.oauth.step3')
"
><
/li
>
<
li
v
-
html
=
"
t('admin.accounts.oauth.step4')
"
><
/li
>
<
li
v
-
html
=
"
t('admin.accounts.oauth.step5')
"
><
/li
>
<
li
v
-
html
=
"
t('admin.accounts.oauth.step6')
"
><
/li
>
<
/ol
>
<
p
class
=
"
mt-2 text-xs text-amber-600 dark:text-amber-400
"
v
-
html
=
"
t('admin.accounts.oauth.sessionKeyFormat')
"
><
/p
>
<
/div
>
<!--
Error
Message
-->
<
div
v
-
if
=
"
error
"
class
=
"
mb-4 rounded-lg border border-red-200 bg-red-50 dark:border-red-700 dark:bg-red-900/30 p-3
"
>
<
p
class
=
"
text-sm text-red-600 dark:text-red-400 whitespace-pre-line
"
>
{{
error
}}
<
/p
>
<
/div
>
<!--
Auth
Button
-->
<
button
type
=
"
button
"
class
=
"
btn btn-primary w-full
"
:
disabled
=
"
loading || !sessionKeyInput.trim()
"
@
click
=
"
handleCookieAuth
"
>
<
svg
v
-
if
=
"
loading
"
class
=
"
animate-spin -ml-1 mr-2 h-4 w-4
"
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
>
<
svg
v
-
else
class
=
"
w-4 h-4 mr-2
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
stroke
-
width
=
"
1.5
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
d
=
"
M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z
"
/>
<
/svg
>
{{
loading
?
t
(
'
admin.accounts.oauth.authorizing
'
)
:
t
(
'
admin.accounts.oauth.startAutoAuth
'
)
}}
<
/button
>
<
/div
>
<
/div
>
<!--
Manual
Authorization
Flow
-->
<
div
v
-
else
class
=
"
space-y-4
"
>
<
p
class
=
"
mb-4 text-sm text-blue-800 dark:text-blue-300
"
>
{{
t
(
'
admin.accounts.oauth.followSteps
'
)
}}
<
/p
>
<!--
Step
1
:
Generate
Auth
URL
-->
<
div
class
=
"
rounded-lg border border-blue-300 dark:border-blue-600 bg-white/80 dark:bg-gray-800/80 p-4
"
>
<
div
class
=
"
flex items-start gap-3
"
>
<
div
class
=
"
flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white
"
>
1
<
/div
>
<
div
class
=
"
flex-1
"
>
<
p
class
=
"
mb-2 font-medium text-blue-900 dark:text-blue-200
"
>
{{
t
(
'
admin.accounts.oauth.step1GenerateUrl
'
)
}}
<
/p
>
<
button
v
-
if
=
"
!authUrl
"
type
=
"
button
"
:
disabled
=
"
loading
"
class
=
"
btn btn-primary text-sm
"
@
click
=
"
handleGenerateUrl
"
>
<
svg
v
-
if
=
"
loading
"
class
=
"
animate-spin -ml-1 mr-2 h-4 w-4
"
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
>
<
svg
v
-
else
class
=
"
w-4 h-4 mr-2
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
stroke
-
width
=
"
1.5
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
d
=
"
M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244
"
/>
<
/svg
>
{{
loading
?
t
(
'
admin.accounts.oauth.generating
'
)
:
t
(
'
admin.accounts.oauth.generateAuthUrl
'
)
}}
<
/button
>
<
div
v
-
else
class
=
"
space-y-3
"
>
<
div
class
=
"
flex items-center gap-2
"
>
<
input
:
value
=
"
authUrl
"
readonly
type
=
"
text
"
class
=
"
input flex-1 bg-gray-50 dark:bg-gray-700 font-mono text-xs
"
/>
<
button
type
=
"
button
"
class
=
"
btn btn-secondary p-2
"
title
=
"
Copy URL
"
@
click
=
"
handleCopyUrl
"
>
<
svg
v
-
if
=
"
!copied
"
class
=
"
w-4 h-4
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
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
>
<
svg
v
-
else
class
=
"
w-4 h-4 text-green-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
>
<
/button
>
<
/div
>
<
button
type
=
"
button
"
class
=
"
text-xs text-blue-600 hover:text-blue-700 dark:text-blue-400
"
@
click
=
"
handleRegenerate
"
>
<
svg
class
=
"
w-3 h-3 inline mr-1
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
stroke
-
width
=
"
1.5
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
d
=
"
M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99
"
/>
<
/svg
>
{{
t
(
'
admin.accounts.oauth.regenerate
'
)
}}
<
/button
>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<!--
Step
2
:
Open
URL
and
authorize
-->
<
div
class
=
"
rounded-lg border border-blue-300 dark:border-blue-600 bg-white/80 dark:bg-gray-800/80 p-4
"
>
<
div
class
=
"
flex items-start gap-3
"
>
<
div
class
=
"
flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white
"
>
2
<
/div
>
<
div
class
=
"
flex-1
"
>
<
p
class
=
"
mb-2 font-medium text-blue-900 dark:text-blue-200
"
>
{{
t
(
'
admin.accounts.oauth.step2OpenUrl
'
)
}}
<
/p
>
<
p
class
=
"
text-sm text-blue-700 dark:text-blue-300
"
>
{{
t
(
'
admin.accounts.oauth.openUrlDesc
'
)
}}
<
/p
>
<
div
v
-
if
=
"
showProxyWarning
"
class
=
"
mt-2 rounded border border-yellow-300 dark:border-yellow-700 bg-yellow-50 dark:bg-yellow-900/30 p-3
"
>
<
p
class
=
"
text-xs text-yellow-800 dark:text-yellow-300
"
v
-
html
=
"
t('admin.accounts.oauth.proxyWarning')
"
>
<
/p
>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<!--
Step
3
:
Enter
authorization
code
-->
<
div
class
=
"
rounded-lg border border-blue-300 dark:border-blue-600 bg-white/80 dark:bg-gray-800/80 p-4
"
>
<
div
class
=
"
flex items-start gap-3
"
>
<
div
class
=
"
flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white
"
>
3
<
/div
>
<
div
class
=
"
flex-1
"
>
<
p
class
=
"
mb-2 font-medium text-blue-900 dark:text-blue-200
"
>
{{
t
(
'
admin.accounts.oauth.step3EnterCode
'
)
}}
<
/p
>
<
p
class
=
"
mb-3 text-sm text-blue-700 dark:text-blue-300
"
v
-
html
=
"
t('admin.accounts.oauth.authCodeDesc')
"
>
<
/p
>
<
div
>
<
label
class
=
"
input-label
"
>
<
svg
class
=
"
w-4 h-4 inline mr-1 text-blue-500
"
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
>
{{
t
(
'
admin.accounts.oauth.authCode
'
)
}}
<
/label
>
<
textarea
v
-
model
=
"
authCodeInput
"
rows
=
"
3
"
class
=
"
input w-full font-mono text-sm resize-none
"
:
placeholder
=
"
t('admin.accounts.oauth.authCodePlaceholder')
"
><
/textarea
>
<
p
class
=
"
mt-2 text-xs text-gray-500 dark:text-gray-400
"
>
<
svg
class
=
"
w-3 h-3 inline mr-1
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
stroke
-
width
=
"
1.5
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
d
=
"
M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z
"
/>
<
/svg
>
{{
t
(
'
admin.accounts.oauth.authCodeHint
'
)
}}
<
/p
>
<
/div
>
<!--
Error
Message
-->
<
div
v
-
if
=
"
error
"
class
=
"
mt-3 rounded-lg border border-red-200 bg-red-50 dark:border-red-700 dark:bg-red-900/30 p-3
"
>
<
p
class
=
"
text-sm text-red-600 dark:text-red-400 whitespace-pre-line
"
>
{{
error
}}
<
/p
>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<
/template
>
<
script
setup
lang
=
"
ts
"
>
import
{
ref
,
computed
,
watch
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useClipboard
}
from
'
@/composables/useClipboard
'
import
type
{
AddMethod
,
AuthInputMethod
}
from
'
@/composables/useAccountOAuth
'
interface
Props
{
addMethod
:
AddMethod
authUrl
?:
string
sessionId
?:
string
loading
?:
boolean
error
?:
string
showHelp
?:
boolean
showProxyWarning
?:
boolean
allowMultiple
?:
boolean
methodLabel
?:
string
}
const
props
=
withDefaults
(
defineProps
<
Props
>
(),
{
authUrl
:
''
,
sessionId
:
''
,
loading
:
false
,
error
:
''
,
showHelp
:
true
,
showProxyWarning
:
true
,
allowMultiple
:
false
,
methodLabel
:
'
Authorization Method
'
}
)
const
emit
=
defineEmits
<
{
'
generate-url
'
:
[]
'
exchange-code
'
:
[
code
:
string
]
'
cookie-auth
'
:
[
sessionKey
:
string
]
'
update:inputMethod
'
:
[
method
:
AuthInputMethod
]
}
>
()
const
{
t
}
=
useI18n
()
// Local state
const
inputMethod
=
ref
<
AuthInputMethod
>
(
'
manual
'
)
const
authCodeInput
=
ref
(
''
)
const
sessionKeyInput
=
ref
(
''
)
const
showHelpDialog
=
ref
(
false
)
// Clipboard
const
{
copied
,
copyToClipboard
}
=
useClipboard
()
// Computed
const
parsedKeyCount
=
computed
(()
=>
{
return
sessionKeyInput
.
value
.
split
(
'
\n
'
).
map
(
k
=>
k
.
trim
()).
filter
(
k
=>
k
).
length
}
)
// Watchers
watch
(
inputMethod
,
(
newVal
)
=>
{
emit
(
'
update:inputMethod
'
,
newVal
)
}
)
// Methods
const
handleGenerateUrl
=
()
=>
{
emit
(
'
generate-url
'
)
}
const
handleCopyUrl
=
()
=>
{
if
(
props
.
authUrl
)
{
copyToClipboard
(
props
.
authUrl
,
'
URL copied to clipboard
'
)
}
}
const
handleRegenerate
=
()
=>
{
authCodeInput
.
value
=
''
emit
(
'
generate-url
'
)
}
const
handleCookieAuth
=
()
=>
{
if
(
sessionKeyInput
.
value
.
trim
())
{
emit
(
'
cookie-auth
'
,
sessionKeyInput
.
value
)
}
}
// Expose methods and state
defineExpose
({
authCode
:
authCodeInput
,
sessionKey
:
sessionKeyInput
,
inputMethod
,
reset
:
()
=>
{
authCodeInput
.
value
=
''
sessionKeyInput
.
value
=
''
inputMethod
.
value
=
'
manual
'
showHelpDialog
.
value
=
false
}
}
)
<
/script
>
frontend/src/components/account/ReAuthAccountModal.vue
0 → 100644
View file @
642842c2
<
template
>
<Modal
:show=
"show"
:title=
"t('admin.accounts.reAuthorizeAccount')"
size=
"lg"
@
close=
"handleClose"
>
<div
v-if=
"account"
class=
"space-y-5"
>
<!-- Account Info -->
<div
class=
"rounded-lg border border-gray-200 dark:border-dark-600 bg-gray-50 dark:bg-dark-700 p-4"
>
<div
class=
"flex items-center gap-3"
>
<div
class=
"flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-orange-500 to-orange-600"
>
<svg
class=
"w-5 h-5 text-white"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"
/>
</svg>
</div>
<div>
<span
class=
"block font-semibold text-gray-900 dark:text-white"
>
{{
account
.
name
}}
</span>
<span
class=
"text-sm text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.accounts.claudeCodeAccount
'
)
}}
</span>
</div>
</div>
</div>
<!-- Add Method Selection -->
<div>
<label
class=
"input-label"
>
{{
t
(
'
admin.accounts.oauth.authMethod
'
)
}}
</label>
<div
class=
"flex gap-4 mt-2"
>
<label
class=
"flex cursor-pointer items-center"
>
<input
v-model=
"addMethod"
type=
"radio"
value=
"oauth"
class=
"mr-2 text-primary-600 focus:ring-primary-500"
/>
<span
class=
"text-sm text-gray-700 dark:text-gray-300"
>
Oauth
</span>
</label>
<label
class=
"flex cursor-pointer items-center"
>
<input
v-model=
"addMethod"
type=
"radio"
value=
"setup-token"
class=
"mr-2 text-primary-600 focus:ring-primary-500"
/>
<span
class=
"text-sm text-gray-700 dark:text-gray-300"
>
{{
t
(
'
admin.accounts.setupTokenLongLived
'
)
}}
</span>
</label>
</div>
</div>
<!-- OAuth Authorization Section -->
<OAuthAuthorizationFlow
ref=
"oauthFlowRef"
:add-method=
"addMethod"
:auth-url=
"oauth.authUrl.value"
:session-id=
"oauth.sessionId.value"
:loading=
"oauth.loading.value"
:error=
"oauth.error.value"
:show-help=
"false"
:show-proxy-warning=
"false"
:allow-multiple=
"false"
:method-label=
"t('admin.accounts.inputMethod')"
@
generate-url=
"handleGenerateUrl"
@
cookie-auth=
"handleCookieAuth"
/>
<div
class=
"flex justify-between gap-3 pt-4"
>
<button
type=
"button"
class=
"btn btn-secondary"
@
click=
"handleClose"
>
{{
t
(
'
common.cancel
'
)
}}
</button>
<button
v-if=
"oauthFlowRef?.inputMethod?.value === 'manual'"
type=
"button"
:disabled=
"!canExchangeCode"
class=
"btn btn-primary"
@
click=
"handleExchangeCode"
>
<svg
v-if=
"oauth.loading.value"
class=
"animate-spin -ml-1 mr-2 h-4 w-4"
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>
{{
oauth
.
loading
.
value
?
t
(
'
admin.accounts.oauth.verifying
'
)
:
t
(
'
admin.accounts.oauth.completeAuth
'
)
}}
</button>
</div>
</div>
</Modal>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
ref
,
computed
,
watch
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
{
useAccountOAuth
,
type
AddMethod
}
from
'
@/composables/useAccountOAuth
'
import
type
{
Account
}
from
'
@/types
'
import
Modal
from
'
@/components/common/Modal.vue
'
import
OAuthAuthorizationFlow
from
'
./OAuthAuthorizationFlow.vue
'
interface
Props
{
show
:
boolean
account
:
Account
|
null
}
const
props
=
defineProps
<
Props
>
()
const
emit
=
defineEmits
<
{
close
:
[]
reauthorized
:
[]
}
>
()
const
appStore
=
useAppStore
()
const
{
t
}
=
useI18n
()
// OAuth composable
const
oauth
=
useAccountOAuth
()
// Refs
const
oauthFlowRef
=
ref
<
InstanceType
<
typeof
OAuthAuthorizationFlow
>
|
null
>
(
null
)
// State
const
addMethod
=
ref
<
AddMethod
>
(
'
oauth
'
)
// Computed
const
canExchangeCode
=
computed
(()
=>
{
const
authCode
=
oauthFlowRef
.
value
?.
authCode
?.
value
||
''
return
authCode
.
trim
()
&&
oauth
.
sessionId
.
value
&&
!
oauth
.
loading
.
value
})
// Watchers
watch
(()
=>
props
.
show
,
(
newVal
)
=>
{
if
(
newVal
&&
props
.
account
)
{
// Initialize addMethod based on current account type
if
(
props
.
account
.
type
===
'
oauth
'
||
props
.
account
.
type
===
'
setup-token
'
)
{
addMethod
.
value
=
props
.
account
.
type
as
AddMethod
}
}
else
{
resetState
()
}
})
// Methods
const
resetState
=
()
=>
{
addMethod
.
value
=
'
oauth
'
oauth
.
resetState
()
oauthFlowRef
.
value
?.
reset
()
}
const
handleClose
=
()
=>
{
emit
(
'
close
'
)
}
const
handleGenerateUrl
=
async
()
=>
{
if
(
!
props
.
account
)
return
await
oauth
.
generateAuthUrl
(
addMethod
.
value
,
props
.
account
.
proxy_id
)
}
const
handleExchangeCode
=
async
()
=>
{
if
(
!
props
.
account
)
return
const
authCode
=
oauthFlowRef
.
value
?.
authCode
?.
value
||
''
if
(
!
authCode
.
trim
()
||
!
oauth
.
sessionId
.
value
)
return
oauth
.
loading
.
value
=
true
oauth
.
error
.
value
=
''
try
{
const
proxyConfig
=
props
.
account
.
proxy_id
?
{
proxy_id
:
props
.
account
.
proxy_id
}
:
{}
const
endpoint
=
addMethod
.
value
===
'
oauth
'
?
'
/admin/accounts/exchange-code
'
:
'
/admin/accounts/exchange-setup-token-code
'
const
tokenInfo
=
await
adminAPI
.
accounts
.
exchangeCode
(
endpoint
,
{
session_id
:
oauth
.
sessionId
.
value
,
code
:
authCode
.
trim
(),
...
proxyConfig
})
const
extra
=
oauth
.
buildExtraInfo
(
tokenInfo
)
// Update account with new credentials and type
await
adminAPI
.
accounts
.
update
(
props
.
account
.
id
,
{
type
:
addMethod
.
value
,
// Update type based on selected method
credentials
:
tokenInfo
,
extra
})
appStore
.
showSuccess
(
t
(
'
admin.accounts.reAuthorizedSuccess
'
))
emit
(
'
reauthorized
'
)
handleClose
()
}
catch
(
error
:
any
)
{
oauth
.
error
.
value
=
error
.
response
?.
data
?.
detail
||
t
(
'
admin.accounts.oauth.authFailed
'
)
appStore
.
showError
(
oauth
.
error
.
value
)
}
finally
{
oauth
.
loading
.
value
=
false
}
}
const
handleCookieAuth
=
async
(
sessionKey
:
string
)
=>
{
if
(
!
props
.
account
)
return
oauth
.
loading
.
value
=
true
oauth
.
error
.
value
=
''
try
{
const
proxyConfig
=
props
.
account
.
proxy_id
?
{
proxy_id
:
props
.
account
.
proxy_id
}
:
{}
const
endpoint
=
addMethod
.
value
===
'
oauth
'
?
'
/admin/accounts/cookie-auth
'
:
'
/admin/accounts/setup-token-cookie-auth
'
const
tokenInfo
=
await
adminAPI
.
accounts
.
exchangeCode
(
endpoint
,
{
session_id
:
''
,
code
:
sessionKey
.
trim
(),
...
proxyConfig
})
const
extra
=
oauth
.
buildExtraInfo
(
tokenInfo
)
// Update account with new credentials and type
await
adminAPI
.
accounts
.
update
(
props
.
account
.
id
,
{
type
:
addMethod
.
value
,
// Update type based on selected method
credentials
:
tokenInfo
,
extra
})
appStore
.
showSuccess
(
t
(
'
admin.accounts.reAuthorizedSuccess
'
))
emit
(
'
reauthorized
'
)
handleClose
()
}
catch
(
error
:
any
)
{
oauth
.
error
.
value
=
error
.
response
?.
data
?.
detail
||
t
(
'
admin.accounts.oauth.cookieAuthFailed
'
)
}
finally
{
oauth
.
loading
.
value
=
false
}
}
</
script
>
frontend/src/components/account/SetupTokenTimeWindow.vue
0 → 100644
View file @
642842c2
<
template
>
<div
class=
"space-y-1"
>
<!-- 5h Time Window Progress -->
<div
v-if=
"hasWindowInfo"
class=
"flex items-center gap-1"
>
<!-- Label badge -->
<span
class=
"text-[10px] font-medium px-1 rounded w-[32px] text-center shrink-0 bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300"
>
5h
</span>
<!-- Progress bar container -->
<div
class=
"w-8 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden shrink-0"
>
<div
:class=
"['h-full transition-all duration-300', barColorClass]"
:style=
"
{ width: progressWidth }"
>
</div>
</div>
<!-- Percentage -->
<span
:class=
"['text-[10px] font-medium w-[32px] text-right shrink-0', textColorClass]"
>
{{
displayPercent
}}
</span>
<!-- Reset time -->
<span
class=
"text-[10px] text-gray-400 shrink-0"
>
{{
formatResetTime
}}
</span>
</div>
<!-- No recent activity (had activity but window expired > 1 hour) -->
<div
v-else-if=
"hasExpiredWindow"
class=
"flex items-center gap-1"
>
<span
class=
"text-[10px] font-medium px-1 rounded w-[32px] text-center shrink-0 bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300"
>
5h
</span>
<span
class=
"text-[10px] text-gray-400 italic"
>
No recent activity
</span>
</div>
<!-- No window info yet (never had activity) -->
<div
v-else
class=
"flex items-center gap-1"
>
<span
class=
"text-[10px] font-medium px-1 rounded w-[32px] text-center shrink-0 bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300"
>
5h
</span>
<span
class=
"text-[10px] text-gray-400 italic"
>
No activity yet
</span>
</div>
<!-- Hint -->
<div
class=
"text-[10px] text-gray-400 italic"
>
Setup Token (time-based)
</div>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
computed
,
ref
,
onMounted
,
onUnmounted
}
from
'
vue
'
import
type
{
Account
}
from
'
@/types
'
const
props
=
defineProps
<
{
account
:
Account
}
>
()
// Update timer
const
currentTime
=
ref
(
new
Date
())
let
timer
:
ReturnType
<
typeof
setInterval
>
|
null
=
null
onMounted
(()
=>
{
// Update every second for more accurate countdown
timer
=
setInterval
(()
=>
{
currentTime
.
value
=
new
Date
()
},
1000
)
})
onUnmounted
(()
=>
{
if
(
timer
)
{
clearInterval
(
timer
)
}
})
// Check if we have window information but it's been expired for more than 1 hour
const
hasExpiredWindow
=
computed
(()
=>
{
if
(
!
props
.
account
.
session_window_start
||
!
props
.
account
.
session_window_end
)
{
return
false
}
const
end
=
new
Date
(
props
.
account
.
session_window_end
).
getTime
()
const
now
=
currentTime
.
value
.
getTime
()
const
expiredMs
=
now
-
end
// Window exists and expired more than 1 hour ago
return
expiredMs
>
1000
*
60
*
60
})
// Check if we have valid window information (not expired for more than 1 hour)
const
hasWindowInfo
=
computed
(()
=>
{
if
(
!
props
.
account
.
session_window_start
||
!
props
.
account
.
session_window_end
)
{
return
false
}
// If window is expired more than 1 hour, don't show progress bar
if
(
hasExpiredWindow
.
value
)
{
return
false
}
return
true
})
// Calculate time-based progress (0-100)
const
timeProgress
=
computed
(()
=>
{
if
(
!
props
.
account
.
session_window_start
||
!
props
.
account
.
session_window_end
)
{
return
0
}
const
start
=
new
Date
(
props
.
account
.
session_window_start
).
getTime
()
const
end
=
new
Date
(
props
.
account
.
session_window_end
).
getTime
()
const
now
=
currentTime
.
value
.
getTime
()
// Window hasn't started yet
if
(
now
<
start
)
{
return
0
}
// Window has ended
if
(
now
>=
end
)
{
return
100
}
// Calculate progress within window
const
total
=
end
-
start
const
elapsed
=
now
-
start
return
Math
.
round
((
elapsed
/
total
)
*
100
)
})
// Progress bar width
const
progressWidth
=
computed
(()
=>
{
return
`
${
Math
.
min
(
timeProgress
.
value
,
100
)}
%`
})
// Display percentage
const
displayPercent
=
computed
(()
=>
{
return
`
${
timeProgress
.
value
}
%`
})
// Progress bar color based on progress
const
barColorClass
=
computed
(()
=>
{
if
(
timeProgress
.
value
>=
100
)
{
return
'
bg-red-500
'
}
else
if
(
timeProgress
.
value
>=
80
)
{
return
'
bg-amber-500
'
}
else
{
return
'
bg-green-500
'
}
})
// Text color based on progress
const
textColorClass
=
computed
(()
=>
{
if
(
timeProgress
.
value
>=
100
)
{
return
'
text-red-600 dark:text-red-400
'
}
else
if
(
timeProgress
.
value
>=
80
)
{
return
'
text-amber-600 dark:text-amber-400
'
}
else
{
return
'
text-gray-600 dark:text-gray-400
'
}
})
// Format reset time (time remaining until window end)
const
formatResetTime
=
computed
(()
=>
{
if
(
!
props
.
account
.
session_window_end
)
{
return
'
N/A
'
}
const
end
=
new
Date
(
props
.
account
.
session_window_end
)
const
now
=
currentTime
.
value
const
diffMs
=
end
.
getTime
()
-
now
.
getTime
()
if
(
diffMs
<=
0
)
{
// 窗口已过期,计算过期了多久
const
expiredMs
=
Math
.
abs
(
diffMs
)
const
expiredHours
=
Math
.
floor
(
expiredMs
/
(
1000
*
60
*
60
))
if
(
expiredHours
>=
1
)
{
return
'
No recent activity
'
}
return
'
Window expired
'
}
const
diffHours
=
Math
.
floor
(
diffMs
/
(
1000
*
60
*
60
))
const
diffMins
=
Math
.
floor
((
diffMs
%
(
1000
*
60
*
60
))
/
(
1000
*
60
))
const
diffSecs
=
Math
.
floor
((
diffMs
%
(
1000
*
60
))
/
1000
)
if
(
diffHours
>
0
)
{
return
`
${
diffHours
}
h
${
diffMins
}
m`
}
else
if
(
diffMins
>
0
)
{
return
`
${
diffMins
}
m
${
diffSecs
}
s`
}
else
{
return
`
${
diffSecs
}
s`
}
})
</
script
>
frontend/src/components/account/UsageProgressBar.vue
0 → 100644
View file @
642842c2
<
template
>
<div
class=
"flex items-center gap-1"
>
<!-- Label badge (fixed width for alignment) -->
<span
:class=
"[
'text-[10px] font-medium px-1 rounded w-[32px] text-center shrink-0',
labelClass
]"
>
{{
label
}}
</span>
<!-- Progress bar container -->
<div
class=
"w-8 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden shrink-0"
>
<div
:class=
"['h-full transition-all duration-300', barClass]"
:style=
"
{ width: barWidth }"
>
</div>
</div>
<!-- Percentage -->
<span
:class=
"['text-[10px] font-medium w-[32px] text-right shrink-0', textClass]"
>
{{
displayPercent
}}
</span>
<!-- Reset time -->
<span
v-if=
"resetsAt"
class=
"text-[10px] text-gray-400 shrink-0"
>
{{
formatResetTime
}}
</span>
<!-- Window stats (only for 5h window) -->
<span
v-if=
"windowStats"
class=
"text-[10px] text-gray-400 shrink-0 ml-1"
>
(
{{
formatStats
}}
)
</span>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
computed
}
from
'
vue
'
import
type
{
WindowStats
}
from
'
@/types
'
const
props
=
defineProps
<
{
label
:
string
utilization
:
number
// Percentage (0-100+)
resetsAt
?:
string
|
null
color
:
'
indigo
'
|
'
emerald
'
|
'
purple
'
windowStats
?:
WindowStats
|
null
}
>
()
// Label background colors
const
labelClass
=
computed
(()
=>
{
const
colors
=
{
indigo
:
'
bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300
'
,
emerald
:
'
bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300
'
,
purple
:
'
bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300
'
}
return
colors
[
props
.
color
]
})
// Progress bar color based on utilization
const
barClass
=
computed
(()
=>
{
if
(
props
.
utilization
>=
100
)
{
return
'
bg-red-500
'
}
else
if
(
props
.
utilization
>=
80
)
{
return
'
bg-amber-500
'
}
else
{
return
'
bg-green-500
'
}
})
// Text color based on utilization
const
textClass
=
computed
(()
=>
{
if
(
props
.
utilization
>=
100
)
{
return
'
text-red-600 dark:text-red-400
'
}
else
if
(
props
.
utilization
>=
80
)
{
return
'
text-amber-600 dark:text-amber-400
'
}
else
{
return
'
text-gray-600 dark:text-gray-400
'
}
})
// Bar width (capped at 100%)
const
barWidth
=
computed
(()
=>
{
return
`
${
Math
.
min
(
props
.
utilization
,
100
)}
%`
})
// Display percentage (cap at 999% for readability)
const
displayPercent
=
computed
(()
=>
{
const
percent
=
Math
.
round
(
props
.
utilization
)
return
percent
>
999
?
'
>999%
'
:
`
${
percent
}
%`
})
// Format reset time
const
formatResetTime
=
computed
(()
=>
{
if
(
!
props
.
resetsAt
)
return
'
N/A
'
const
date
=
new
Date
(
props
.
resetsAt
)
const
now
=
new
Date
()
const
diffMs
=
date
.
getTime
()
-
now
.
getTime
()
if
(
diffMs
<=
0
)
return
'
Now
'
const
diffHours
=
Math
.
floor
(
diffMs
/
(
1000
*
60
*
60
))
const
diffMins
=
Math
.
floor
((
diffMs
%
(
1000
*
60
*
60
))
/
(
1000
*
60
))
if
(
diffHours
>=
24
)
{
const
days
=
Math
.
floor
(
diffHours
/
24
)
return
`
${
days
}
d
${
diffHours
%
24
}
h`
}
else
if
(
diffHours
>
0
)
{
return
`
${
diffHours
}
h
${
diffMins
}
m`
}
else
{
return
`
${
diffMins
}
m`
}
})
// Format window stats
const
formatStats
=
computed
(()
=>
{
if
(
!
props
.
windowStats
)
return
''
const
{
requests
,
tokens
,
cost
}
=
props
.
windowStats
// Format tokens (e.g., 1234567 -> 1.2M)
const
formatTokens
=
(
t
:
number
):
string
=>
{
if
(
t
>=
1000000
)
return
`
${(
t
/
1000000
).
toFixed
(
1
)}
M`
if
(
t
>=
1000
)
return
`
${(
t
/
1000
).
toFixed
(
1
)}
K`
return
t
.
toString
()
}
return
`
${
requests
}
req
${
formatTokens
(
tokens
)}
tok $
${
cost
.
toFixed
(
2
)}
`
})
</
script
>
frontend/src/components/account/index.ts
0 → 100644
View file @
642842c2
export
{
default
as
CreateAccountModal
}
from
'
./CreateAccountModal.vue
'
export
{
default
as
EditAccountModal
}
from
'
./EditAccountModal.vue
'
export
{
default
as
ReAuthAccountModal
}
from
'
./ReAuthAccountModal.vue
'
export
{
default
as
OAuthAuthorizationFlow
}
from
'
./OAuthAuthorizationFlow.vue
'
export
{
default
as
AccountStatusIndicator
}
from
'
./AccountStatusIndicator.vue
'
export
{
default
as
AccountUsageCell
}
from
'
./AccountUsageCell.vue
'
export
{
default
as
UsageProgressBar
}
from
'
./UsageProgressBar.vue
'
frontend/src/components/common/ConfirmDialog.vue
0 → 100644
View file @
642842c2
<
template
>
<Modal
:show=
"show"
:title=
"title"
size=
"sm"
@
close=
"handleCancel"
>
<div
class=
"space-y-4"
>
<p
class=
"text-sm text-gray-600 dark:text-gray-400"
>
{{
message
}}
</p>
</div>
<template
#footer
>
<div
class=
"flex justify-end space-x-3"
>
<button
@
click=
"handleCancel"
type=
"button"
class=
"px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-dark-700 border border-gray-300 dark:border-dark-600 rounded-md hover:bg-gray-50 dark:hover:bg-dark-600 focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-dark-800 focus:ring-primary-500"
>
{{
cancelText
}}
</button>
<button
@
click=
"handleConfirm"
type=
"button"
:class=
"[
'px-4 py-2 text-sm font-medium text-white rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-dark-800',
danger
? 'bg-red-600 hover:bg-red-700 focus:ring-red-500'
: 'bg-primary-600 hover:bg-primary-700 focus:ring-primary-500'
]"
>
{{
confirmText
}}
</button>
</div>
</
template
>
</Modal>
</template>
<
script
setup
lang=
"ts"
>
import
Modal
from
'
./Modal.vue
'
interface
Props
{
show
:
boolean
title
:
string
message
:
string
confirmText
?:
string
cancelText
?:
string
danger
?:
boolean
}
interface
Emits
{
(
e
:
'
confirm
'
):
void
(
e
:
'
cancel
'
):
void
}
const
props
=
withDefaults
(
defineProps
<
Props
>
(),
{
confirmText
:
'
Confirm
'
,
cancelText
:
'
Cancel
'
,
danger
:
false
})
const
emit
=
defineEmits
<
Emits
>
()
const
handleConfirm
=
()
=>
{
emit
(
'
confirm
'
)
}
const
handleCancel
=
()
=>
{
emit
(
'
cancel
'
)
}
</
script
>
frontend/src/components/common/DataTable.vue
0 → 100644
View file @
642842c2
<
template
>
<div
class=
"overflow-x-auto"
>
<table
class=
"min-w-full divide-y divide-gray-200 dark:divide-dark-700"
>
<thead
class=
"bg-gray-50 dark:bg-dark-800"
>
<tr>
<th
v-for=
"column in columns"
:key=
"column.key"
scope=
"col"
class=
"px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-dark-400 uppercase tracking-wider"
:class=
"
{ 'cursor-pointer hover:bg-gray-100 dark:hover:bg-dark-700': column.sortable }"
@click="column.sortable
&&
handleSort(column.key)"
>
<div
class=
"flex items-center space-x-1"
>
<span>
{{
column
.
label
}}
</span>
<span
v-if=
"column.sortable"
class=
"text-gray-400 dark:text-dark-500"
>
<svg
v-if=
"sortKey === column.key"
class=
"w-4 h-4"
:class=
"
{ 'transform rotate-180': sortOrder === 'desc' }"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fill-rule=
"evenodd"
d=
"M14.707 12.707a1 1 0 01-1.414 0L10 9.414l-3.293 3.293a1 1 0 01-1.414-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 010 1.414z"
clip-rule=
"evenodd"
/>
</svg>
<svg
v-else
class=
"w-4 h-4"
fill=
"currentColor"
viewBox=
"0 0 20 20"
>
<path
d=
"M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
/>
</svg>
</span>
</div>
</th>
</tr>
</thead>
<tbody
class=
"bg-white dark:bg-dark-900 divide-y divide-gray-200 dark:divide-dark-700"
>
<!-- Loading skeleton -->
<tr
v-if=
"loading"
v-for=
"i in 5"
:key=
"i"
>
<td
v-for=
"column in columns"
:key=
"column.key"
class=
"px-6 py-4 whitespace-nowrap"
>
<div
class=
"animate-pulse"
>
<div
class=
"h-4 bg-gray-200 dark:bg-dark-700 rounded w-3/4"
></div>
</div>
</td>
</tr>
<!-- Empty state -->
<tr
v-else-if=
"!data || data.length === 0"
>
<td
:colspan=
"columns.length"
class=
"px-6 py-12 text-center text-gray-500 dark:text-dark-400"
>
<slot
name=
"empty"
>
<div
class=
"flex flex-col items-center"
>
<svg
class=
"w-12 h-12 text-gray-400 dark:text-dark-500 mb-4"
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>
</div>
</slot>
</td>
</tr>
<!-- Data rows -->
<tr
v-else
v-for=
"(row, index) in sortedData"
:key=
"index"
class=
"hover:bg-gray-50 dark:hover:bg-dark-800"
>
<td
v-for=
"column in columns"
:key=
"column.key"
class=
"px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100"
>
<slot
:name=
"`cell-$
{column.key}`" :row="row" :value="row[column.key]">
{{
column
.
formatter
?
column
.
formatter
(
row
[
column
.
key
],
row
)
:
row
[
column
.
key
]
}}
</slot>
</td>
</tr>
</tbody>
</table>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
computed
,
ref
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
const
{
t
}
=
useI18n
()
export
interface
Column
{
key
:
string
label
:
string
sortable
?:
boolean
formatter
?:
(
value
:
any
,
row
:
any
)
=>
string
}
interface
Props
{
columns
:
Column
[]
data
:
any
[]
loading
?:
boolean
}
const
props
=
withDefaults
(
defineProps
<
Props
>
(),
{
loading
:
false
})
const
sortKey
=
ref
<
string
>
(
''
)
const
sortOrder
=
ref
<
'
asc
'
|
'
desc
'
>
(
'
asc
'
)
const
handleSort
=
(
key
:
string
)
=>
{
if
(
sortKey
.
value
===
key
)
{
sortOrder
.
value
=
sortOrder
.
value
===
'
asc
'
?
'
desc
'
:
'
asc
'
}
else
{
sortKey
.
value
=
key
sortOrder
.
value
=
'
asc
'
}
}
const
sortedData
=
computed
(()
=>
{
if
(
!
sortKey
.
value
||
!
props
.
data
)
return
props
.
data
return
[...
props
.
data
].
sort
((
a
,
b
)
=>
{
const
aVal
=
a
[
sortKey
.
value
]
const
bVal
=
b
[
sortKey
.
value
]
if
(
aVal
===
bVal
)
return
0
const
comparison
=
aVal
>
bVal
?
1
:
-
1
return
sortOrder
.
value
===
'
asc
'
?
comparison
:
-
comparison
})
})
</
script
>
frontend/src/components/common/DateRangePicker.vue
0 → 100644
View file @
642842c2
<
template
>
<div
class=
"relative"
ref=
"containerRef"
>
<button
type=
"button"
@
click=
"toggle"
:class=
"[
'date-picker-trigger',
isOpen && 'date-picker-trigger-open'
]"
>
<span
class=
"date-picker-icon"
>
<svg
class=
"w-4 h-4"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M6.75 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>
</span>
<span
class=
"date-picker-value"
>
{{
displayValue
}}
</span>
<span
class=
"date-picker-chevron"
>
<svg
:class=
"['w-4 h-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>
</span>
</button>
<Transition
name=
"date-picker-dropdown"
>
<div
v-if=
"isOpen"
class=
"date-picker-dropdown"
>
<!-- Quick presets -->
<div
class=
"date-picker-presets"
>
<button
v-for=
"preset in presets"
:key=
"preset.value"
@
click=
"selectPreset(preset)"
:class=
"[
'date-picker-preset',
isPresetActive(preset) && 'date-picker-preset-active'
]"
>
{{
t
(
preset
.
labelKey
)
}}
</button>
</div>
<div
class=
"date-picker-divider"
></div>
<!-- Custom date range inputs -->
<div
class=
"date-picker-custom"
>
<div
class=
"date-picker-field"
>
<label
class=
"date-picker-label"
>
{{
t
(
'
dates.startDate
'
)
}}
</label>
<input
type=
"date"
v-model=
"localStartDate"
:max=
"localEndDate || today"
class=
"date-picker-input"
@
change=
"onDateChange"
/>
</div>
<div
class=
"date-picker-separator"
>
<svg
class=
"w-4 h-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>
</div>
<div
class=
"date-picker-field"
>
<label
class=
"date-picker-label"
>
{{
t
(
'
dates.endDate
'
)
}}
</label>
<input
type=
"date"
v-model=
"localEndDate"
:min=
"localStartDate"
:max=
"today"
class=
"date-picker-input"
@
change=
"onDateChange"
/>
</div>
</div>
<!-- Apply button -->
<div
class=
"date-picker-actions"
>
<button
@
click=
"apply"
class=
"date-picker-apply"
>
{{
t
(
'
dates.apply
'
)
}}
</button>
</div>
</div>
</Transition>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
ref
,
computed
,
watch
,
onMounted
,
onUnmounted
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
interface
DatePreset
{
labelKey
:
string
value
:
string
getRange
:
()
=>
{
start
:
string
;
end
:
string
}
}
interface
Props
{
startDate
:
string
endDate
:
string
}
interface
Emits
{
(
e
:
'
update:startDate
'
,
value
:
string
):
void
(
e
:
'
update:endDate
'
,
value
:
string
):
void
(
e
:
'
change
'
,
range
:
{
startDate
:
string
;
endDate
:
string
;
preset
:
string
|
null
}):
void
}
const
props
=
defineProps
<
Props
>
()
const
emit
=
defineEmits
<
Emits
>
()
const
{
t
,
locale
}
=
useI18n
()
const
isOpen
=
ref
(
false
)
const
containerRef
=
ref
<
HTMLElement
|
null
>
(
null
)
const
localStartDate
=
ref
(
props
.
startDate
)
const
localEndDate
=
ref
(
props
.
endDate
)
const
activePreset
=
ref
<
string
|
null
>
(
'
7days
'
)
const
today
=
computed
(()
=>
new
Date
().
toISOString
().
split
(
'
T
'
)[
0
])
const
presets
:
DatePreset
[]
=
[
{
labelKey
:
'
dates.today
'
,
value
:
'
today
'
,
getRange
:
()
=>
{
const
t
=
today
.
value
return
{
start
:
t
,
end
:
t
}
}
},
{
labelKey
:
'
dates.yesterday
'
,
value
:
'
yesterday
'
,
getRange
:
()
=>
{
const
d
=
new
Date
()
d
.
setDate
(
d
.
getDate
()
-
1
)
const
yesterday
=
d
.
toISOString
().
split
(
'
T
'
)[
0
]
return
{
start
:
yesterday
,
end
:
yesterday
}
}
},
{
labelKey
:
'
dates.last7Days
'
,
value
:
'
7days
'
,
getRange
:
()
=>
{
const
end
=
today
.
value
const
d
=
new
Date
()
d
.
setDate
(
d
.
getDate
()
-
6
)
const
start
=
d
.
toISOString
().
split
(
'
T
'
)[
0
]
return
{
start
,
end
}
}
},
{
labelKey
:
'
dates.last14Days
'
,
value
:
'
14days
'
,
getRange
:
()
=>
{
const
end
=
today
.
value
const
d
=
new
Date
()
d
.
setDate
(
d
.
getDate
()
-
13
)
const
start
=
d
.
toISOString
().
split
(
'
T
'
)[
0
]
return
{
start
,
end
}
}
},
{
labelKey
:
'
dates.last30Days
'
,
value
:
'
30days
'
,
getRange
:
()
=>
{
const
end
=
today
.
value
const
d
=
new
Date
()
d
.
setDate
(
d
.
getDate
()
-
29
)
const
start
=
d
.
toISOString
().
split
(
'
T
'
)[
0
]
return
{
start
,
end
}
}
},
{
labelKey
:
'
dates.thisMonth
'
,
value
:
'
thisMonth
'
,
getRange
:
()
=>
{
const
now
=
new
Date
()
const
start
=
new
Date
(
now
.
getFullYear
(),
now
.
getMonth
(),
1
).
toISOString
().
split
(
'
T
'
)[
0
]
return
{
start
,
end
:
today
.
value
}
}
},
{
labelKey
:
'
dates.lastMonth
'
,
value
:
'
lastMonth
'
,
getRange
:
()
=>
{
const
now
=
new
Date
()
const
start
=
new
Date
(
now
.
getFullYear
(),
now
.
getMonth
()
-
1
,
1
).
toISOString
().
split
(
'
T
'
)[
0
]
const
end
=
new
Date
(
now
.
getFullYear
(),
now
.
getMonth
(),
0
).
toISOString
().
split
(
'
T
'
)[
0
]
return
{
start
,
end
}
}
}
]
const
displayValue
=
computed
(()
=>
{
if
(
activePreset
.
value
)
{
const
preset
=
presets
.
find
(
p
=>
p
.
value
===
activePreset
.
value
)
if
(
preset
)
return
t
(
preset
.
labelKey
)
}
if
(
localStartDate
.
value
&&
localEndDate
.
value
)
{
if
(
localStartDate
.
value
===
localEndDate
.
value
)
{
return
formatDate
(
localStartDate
.
value
)
}
return
`
${
formatDate
(
localStartDate
.
value
)}
-
${
formatDate
(
localEndDate
.
value
)}
`
}
return
t
(
'
dates.selectDateRange
'
)
})
const
formatDate
=
(
dateStr
:
string
):
string
=>
{
const
date
=
new
Date
(
dateStr
+
'
T00:00:00
'
)
const
dateLocale
=
locale
.
value
===
'
zh
'
?
'
zh-CN
'
:
'
en-US
'
return
date
.
toLocaleDateString
(
dateLocale
,
{
month
:
'
short
'
,
day
:
'
numeric
'
})
}
const
isPresetActive
=
(
preset
:
DatePreset
):
boolean
=>
{
return
activePreset
.
value
===
preset
.
value
}
const
selectPreset
=
(
preset
:
DatePreset
)
=>
{
const
range
=
preset
.
getRange
()
localStartDate
.
value
=
range
.
start
localEndDate
.
value
=
range
.
end
activePreset
.
value
=
preset
.
value
}
const
onDateChange
=
()
=>
{
// Check if current dates match any preset
activePreset
.
value
=
null
for
(
const
preset
of
presets
)
{
const
range
=
preset
.
getRange
()
if
(
range
.
start
===
localStartDate
.
value
&&
range
.
end
===
localEndDate
.
value
)
{
activePreset
.
value
=
preset
.
value
break
}
}
}
const
toggle
=
()
=>
{
isOpen
.
value
=
!
isOpen
.
value
}
const
apply
=
()
=>
{
emit
(
'
update:startDate
'
,
localStartDate
.
value
)
emit
(
'
update:endDate
'
,
localEndDate
.
value
)
emit
(
'
change
'
,
{
startDate
:
localStartDate
.
value
,
endDate
:
localEndDate
.
value
,
preset
:
activePreset
.
value
})
isOpen
.
value
=
false
}
const
handleClickOutside
=
(
event
:
MouseEvent
)
=>
{
if
(
containerRef
.
value
&&
!
containerRef
.
value
.
contains
(
event
.
target
as
Node
))
{
isOpen
.
value
=
false
}
}
const
handleEscape
=
(
event
:
KeyboardEvent
)
=>
{
if
(
event
.
key
===
'
Escape
'
&&
isOpen
.
value
)
{
isOpen
.
value
=
false
}
}
// Sync local state with props
watch
(()
=>
props
.
startDate
,
(
val
)
=>
{
localStartDate
.
value
=
val
onDateChange
()
})
watch
(()
=>
props
.
endDate
,
(
val
)
=>
{
localEndDate
.
value
=
val
onDateChange
()
})
onMounted
(()
=>
{
document
.
addEventListener
(
'
click
'
,
handleClickOutside
)
document
.
addEventListener
(
'
keydown
'
,
handleEscape
)
// Initialize active preset detection
onDateChange
()
})
onUnmounted
(()
=>
{
document
.
removeEventListener
(
'
click
'
,
handleClickOutside
)
document
.
removeEventListener
(
'
keydown
'
,
handleEscape
)
})
</
script
>
<
style
scoped
>
.date-picker-trigger
{
@apply
flex
items-center
gap-2;
@apply
px-3
py-2
rounded-lg
text-sm;
@apply
bg-white
dark
:
bg-dark-800
;
@apply
border
border-gray-200
dark
:
border-dark-600
;
@apply
text-gray-700
dark
:
text-gray-300
;
@apply
transition-all
duration-200;
@apply
focus
:
outline-none
focus
:
ring-2
focus
:
ring-primary-500
/
30
focus
:
border-primary-500
;
@apply
hover
:
border-gray-300
dark
:
hover
:
border-dark-500
;
@apply
cursor-pointer;
}
.date-picker-trigger-open
{
@apply
ring-2
ring-primary-500/30
border-primary-500;
}
.date-picker-icon
{
@apply
text-gray-400
dark
:
text-dark-400
;
}
.date-picker-value
{
@apply
font-medium;
}
.date-picker-chevron
{
@apply
text-gray-400
dark
:
text-dark-400
;
}
.date-picker-dropdown
{
@apply
absolute
z-[100]
mt-2
left-0;
@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;
@apply
min-w-[320px];
}
.date-picker-presets
{
@apply
grid
grid-cols-2
gap-1
p-2;
}
.date-picker-preset
{
@apply
px-3
py-1.5
text-xs
font-medium
rounded-md;
@apply
text-gray-600
dark
:
text-gray-400
;
@apply
hover
:
bg-gray-100
dark
:
hover
:
bg-dark-700
;
@apply
transition-colors
duration-150;
}
.date-picker-preset-active
{
@apply
bg-primary-100
dark
:
bg-primary-900
/
30
;
@apply
text-primary-700
dark
:
text-primary-300
;
}
.date-picker-divider
{
@apply
border-t
border-gray-100
dark
:
border-dark-700
;
}
.date-picker-custom
{
@apply
flex
items-end
gap-2
p-3;
}
.date-picker-field
{
@apply
flex-1;
}
.date-picker-label
{
@apply
block
text-xs
font-medium
text-gray-500
dark
:
text-gray-400
mb-1
;
}
.date-picker-input
{
@apply
w-full
px-2
py-1.5
text-sm
rounded-md;
@apply
bg-gray-50
dark
:
bg-dark-700
;
@apply
border
border-gray-200
dark
:
border-dark-600
;
@apply
text-gray-900
dark
:
text-gray-100
;
@apply
focus
:
outline-none
focus
:
ring-2
focus
:
ring-primary-500
/
30
focus
:
border-primary-500
;
}
.date-picker-input
::-webkit-calendar-picker-indicator
{
@apply
cursor-pointer
opacity-60
hover
:
opacity-100
;
filter
:
invert
(
0.5
);
}
.dark
.date-picker-input
::-webkit-calendar-picker-indicator
{
filter
:
invert
(
0.7
);
}
.date-picker-separator
{
@apply
flex
items-center
justify-center
pb-1;
}
.date-picker-actions
{
@apply
flex
justify-end
p-2
pt-0;
}
.date-picker-apply
{
@apply
px-4
py-1.5
text-sm
font-medium
rounded-lg;
@apply
bg-primary-600
text-white;
@apply
hover
:
bg-primary-700
;
@apply
transition-colors
duration-150;
}
/* Dropdown animation */
.date-picker-dropdown-enter-active
,
.date-picker-dropdown-leave-active
{
transition
:
all
0.2s
ease
;
}
.date-picker-dropdown-enter-from
,
.date-picker-dropdown-leave-to
{
opacity
:
0
;
transform
:
translateY
(
-8px
);
}
</
style
>
frontend/src/components/common/EmptyState.vue
0 → 100644
View file @
642842c2
<
template
>
<div
class=
"empty-state"
>
<!-- Icon -->
<div
class=
"w-20 h-20 mb-5 rounded-2xl bg-gray-100 dark:bg-dark-800 flex items-center justify-center"
>
<slot
name=
"icon"
>
<component
v-if=
"icon"
:is=
"icon"
class=
"empty-state-icon w-10 h-10"
aria-hidden=
"true"
/>
<svg
v-else
class=
"empty-state-icon w-10 h-10"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
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>
</slot>
</div>
<!-- Title -->
<h3
class=
"empty-state-title"
>
{{
title
}}
</h3>
<!-- Description -->
<p
class=
"empty-state-description"
>
{{
description
}}
</p>
<!-- Action -->
<div
v-if=
"actionText || $slots.action"
class=
"mt-6"
>
<slot
name=
"action"
>
<component
:is=
"actionTo ? 'RouterLink' : 'button'"
v-if=
"actionText"
:to=
"actionTo"
@
click=
"!actionTo && $emit('action')"
class=
"btn btn-primary"
>
<svg
v-if=
"actionIcon"
class=
"w-5 h-5 mr-2"
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>
{{
actionText
}}
</component>
</slot>
</div>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
type
{
Component
}
from
'
vue
'
import
{
RouterLink
}
from
'
vue-router
'
interface
Props
{
icon
?:
Component
|
string
title
?:
string
description
?:
string
actionText
?:
string
actionTo
?:
string
|
object
actionIcon
?:
boolean
message
?:
string
}
const
props
=
withDefaults
(
defineProps
<
Props
>
(),
{
title
:
'
No data found
'
,
description
:
''
,
actionIcon
:
true
})
defineEmits
([
'
action
'
])
</
script
>
frontend/src/components/common/GroupBadge.vue
0 → 100644
View file @
642842c2
<
template
>
<span
:class=
"[
'inline-flex items-center gap-1.5 px-2 py-0.5 rounded-md text-xs font-medium transition-colors',
isSubscription
? 'bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-400'
: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
]"
>
<!-- Subscription type icon (calendar) -->
<svg
v-if=
"isSubscription"
class=
"w-3 h-3"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"2"
>
<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>
<!-- Standard type icon (wallet) -->
<svg
v-else
class=
"w-3 h-3"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M21 12a2.25 2.25 0 00-2.25-2.25H15a3 3 0 11-6 0H5.25A2.25 2.25 0 003 12m18 0v6a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 18v-6m18 0V9M3 12V9m18 0a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 9m18 0V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v3"
/>
</svg>
<span
class=
"truncate"
>
{{
name
}}
</span>
<span
v-if=
"showRate && rateMultiplier !== undefined"
:class=
"[
'px-1 py-0.5 rounded text-[10px] font-semibold',
isSubscription
? 'bg-violet-200/60 text-violet-800 dark:bg-violet-800/40 dark:text-violet-300'
: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'
]"
>
{{
rateMultiplier
}}
x
</span>
</span>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
computed
}
from
'
vue
'
import
type
{
SubscriptionType
}
from
'
@/types
'
interface
Props
{
name
:
string
subscriptionType
?:
SubscriptionType
rateMultiplier
?:
number
showRate
?:
boolean
}
const
props
=
withDefaults
(
defineProps
<
Props
>
(),
{
subscriptionType
:
'
standard
'
,
showRate
:
true
})
const
isSubscription
=
computed
(()
=>
props
.
subscriptionType
===
'
subscription
'
)
</
script
>
frontend/src/components/common/GroupSelector.vue
0 → 100644
View file @
642842c2
<
template
>
<div>
<label
class=
"input-label"
>
Groups
<span
class=
"text-gray-400 font-normal"
>
(
{{
modelValue
.
length
}}
selected)
</span>
</label>
<div
class=
"grid grid-cols-2 gap-1 max-h-32 overflow-y-auto p-2 border border-gray-200 dark:border-dark-600 rounded-lg bg-gray-50 dark:bg-dark-800"
>
<label
v-for=
"group in groups"
:key=
"group.id"
class=
"flex items-center gap-2 px-2 py-1.5 rounded hover:bg-white dark:hover:bg-dark-700 cursor-pointer transition-colors"
:title=
"`$
{group.rate_multiplier}x rate · ${group.account_count || 0} accounts`"
>
<input
type=
"checkbox"
:value=
"group.id"
:checked=
"modelValue.includes(group.id)"
@
change=
"handleChange(group.id, ($event.target as HTMLInputElement).checked)"
class=
"w-3.5 h-3.5 text-primary-500 border-gray-300 dark:border-dark-500 rounded focus:ring-primary-500 shrink-0"
/>
<GroupBadge
:name=
"group.name"
:subscription-type=
"group.subscription_type"
:rate-multiplier=
"group.rate_multiplier"
class=
"flex-1 min-w-0"
/>
<span
class=
"text-xs text-gray-400 shrink-0"
>
{{
group
.
account_count
||
0
}}
</span>
</label>
<div
v-if=
"groups.length === 0"
class=
"col-span-2 text-center text-sm text-gray-500 dark:text-gray-400 py-2"
>
No groups available
</div>
</div>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
GroupBadge
from
'
./GroupBadge.vue
'
import
type
{
Group
}
from
'
@/types
'
interface
Props
{
modelValue
:
number
[]
groups
:
Group
[]
}
const
props
=
defineProps
<
Props
>
()
const
emit
=
defineEmits
<
{
'
update:modelValue
'
:
[
value
:
number
[]]
}
>
()
const
handleChange
=
(
groupId
:
number
,
checked
:
boolean
)
=>
{
const
newValue
=
checked
?
[...
props
.
modelValue
,
groupId
]
:
props
.
modelValue
.
filter
(
id
=>
id
!==
groupId
)
emit
(
'
update:modelValue
'
,
newValue
)
}
</
script
>
frontend/src/components/common/LoadingSpinner.vue
0 → 100644
View file @
642842c2
<
template
>
<div
:class=
"['spinner', sizeClasses, colorClass]"
role=
"status"
:aria-label=
"t('common.loading')"
>
<span
class=
"sr-only"
>
{{
t
(
'
common.loading
'
)
}}
</span>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
computed
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
const
{
t
}
=
useI18n
()
type
SpinnerSize
=
'
sm
'
|
'
md
'
|
'
lg
'
|
'
xl
'
type
SpinnerColor
=
'
primary
'
|
'
secondary
'
|
'
white
'
|
'
gray
'
interface
Props
{
size
?:
SpinnerSize
color
?:
SpinnerColor
}
const
props
=
withDefaults
(
defineProps
<
Props
>
(),
{
size
:
'
md
'
,
color
:
'
primary
'
})
const
sizeClasses
=
computed
(()
=>
{
const
sizes
:
Record
<
SpinnerSize
,
string
>
=
{
sm
:
'
w-4 h-4 border-2
'
,
md
:
'
w-8 h-8 border-2
'
,
lg
:
'
w-12 h-12 border-[3px]
'
,
xl
:
'
w-16 h-16 border-4
'
}
return
sizes
[
props
.
size
]
})
const
colorClass
=
computed
(()
=>
{
const
colors
:
Record
<
SpinnerColor
,
string
>
=
{
primary
:
'
text-primary-500
'
,
secondary
:
'
text-gray-500 dark:text-dark-400
'
,
white
:
'
text-white
'
,
gray
:
'
text-gray-400 dark:text-dark-500
'
}
return
colors
[
props
.
color
]
})
</
script
>
<
style
scoped
>
.spinner
{
@apply
inline-block
rounded-full
border-solid
border-current
border-r-transparent;
animation
:
spin
0.75s
linear
infinite
;
}
@keyframes
spin
{
from
{
transform
:
rotate
(
0deg
);
}
to
{
transform
:
rotate
(
360deg
);
}
}
</
style
>
frontend/src/components/common/LocaleSwitcher.vue
0 → 100644
View file @
642842c2
<
template
>
<div
class=
"relative"
ref=
"dropdownRef"
>
<button
@
click=
"toggleDropdown"
class=
"flex items-center gap-1.5 px-2 py-1.5 rounded-lg text-sm font-medium text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-dark-700 transition-colors"
:title=
"currentLocale?.name"
>
<span
class=
"text-base"
>
{{
currentLocale
?.
flag
}}
</span>
<span
class=
"hidden sm:inline"
>
{{
currentLocale
?.
code
.
toUpperCase
()
}}
</span>
<svg
class=
"w-3.5 h-3.5 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"
>
<div
v-if=
"isOpen"
class=
"absolute right-0 mt-1 w-32 rounded-lg bg-white dark:bg-dark-800 shadow-lg border border-gray-200 dark:border-dark-700 overflow-hidden z-50"
>
<button
v-for=
"locale in availableLocales"
:key=
"locale.code"
@
click=
"selectLocale(locale.code)"
class=
"w-full flex items-center gap-2 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-dark-700 transition-colors"
:class=
"
{ 'bg-primary-50 dark:bg-primary-900/20 text-primary-600 dark:text-primary-400': locale.code === currentLocaleCode }"
>
<span
class=
"text-base"
>
{{
locale
.
flag
}}
</span>
<span>
{{
locale
.
name
}}
</span>
<svg
v-if=
"locale.code === currentLocaleCode"
class=
"w-4 h-4 ml-auto 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>
</button>
</div>
</transition>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
ref
,
computed
,
onMounted
,
onBeforeUnmount
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
setLocale
,
availableLocales
}
from
'
@/i18n
'
const
{
locale
}
=
useI18n
()
const
isOpen
=
ref
(
false
)
const
dropdownRef
=
ref
<
HTMLElement
|
null
>
(
null
)
const
currentLocaleCode
=
computed
(()
=>
locale
.
value
)
const
currentLocale
=
computed
(()
=>
availableLocales
.
find
(
l
=>
l
.
code
===
locale
.
value
))
function
toggleDropdown
()
{
isOpen
.
value
=
!
isOpen
.
value
}
function
selectLocale
(
code
:
string
)
{
setLocale
(
code
)
isOpen
.
value
=
false
}
function
handleClickOutside
(
event
:
MouseEvent
)
{
if
(
dropdownRef
.
value
&&
!
dropdownRef
.
value
.
contains
(
event
.
target
as
Node
))
{
isOpen
.
value
=
false
}
}
onMounted
(()
=>
{
document
.
addEventListener
(
'
click
'
,
handleClickOutside
)
})
onBeforeUnmount
(()
=>
{
document
.
removeEventListener
(
'
click
'
,
handleClickOutside
)
})
</
script
>
<
style
scoped
>
.dropdown-enter-active
,
.dropdown-leave-active
{
transition
:
all
0.15s
ease
;
}
.dropdown-enter-from
,
.dropdown-leave-to
{
opacity
:
0
;
transform
:
scale
(
0.95
)
translateY
(
-4px
);
}
</
style
>
frontend/src/components/common/Modal.vue
0 → 100644
View file @
642842c2
<
template
>
<Teleport
to=
"body"
>
<div
v-if=
"show"
class=
"modal-overlay"
aria-labelledby=
"modal-title"
role=
"dialog"
aria-modal=
"true"
@
click.self=
"handleClose"
>
<!-- Modal panel -->
<div
:class=
"['modal-content', sizeClasses]"
@
click.stop
>
<!-- Header -->
<div
class=
"modal-header"
>
<h3
id=
"modal-title"
class=
"modal-title"
>
{{
title
}}
</h3>
<button
@
click=
"emit('close')"
class=
"p-2 -mr-2 rounded-xl text-gray-400 dark:text-dark-500 hover:text-gray-600 dark:hover:text-dark-300 hover:bg-gray-100 dark:hover:bg-dark-700 transition-colors"
aria-label=
"Close modal"
>
<svg
class=
"w-5 h-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>
</button>
</div>
<!-- Body -->
<div
class=
"modal-body"
>
<slot></slot>
</div>
<!-- Footer -->
<div
v-if=
"$slots.footer"
class=
"modal-footer"
>
<slot
name=
"footer"
></slot>
</div>
</div>
</div>
</Teleport>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
computed
,
watch
,
onMounted
,
onUnmounted
}
from
'
vue
'
type
ModalSize
=
'
sm
'
|
'
md
'
|
'
lg
'
|
'
xl
'
|
'
full
'
interface
Props
{
show
:
boolean
title
:
string
size
?:
ModalSize
closeOnEscape
?:
boolean
closeOnClickOutside
?:
boolean
}
interface
Emits
{
(
e
:
'
close
'
):
void
}
const
props
=
withDefaults
(
defineProps
<
Props
>
(),
{
size
:
'
md
'
,
closeOnEscape
:
true
,
closeOnClickOutside
:
false
})
const
emit
=
defineEmits
<
Emits
>
()
const
sizeClasses
=
computed
(()
=>
{
const
sizes
:
Record
<
ModalSize
,
string
>
=
{
sm
:
'
max-w-sm
'
,
md
:
'
max-w-md
'
,
lg
:
'
max-w-lg
'
,
xl
:
'
max-w-xl
'
,
full
:
'
max-w-4xl
'
}
return
sizes
[
props
.
size
]
})
const
handleClose
=
()
=>
{
if
(
props
.
closeOnClickOutside
)
{
emit
(
'
close
'
)
}
}
const
handleEscape
=
(
event
:
KeyboardEvent
)
=>
{
if
(
props
.
show
&&
props
.
closeOnEscape
&&
event
.
key
===
'
Escape
'
)
{
emit
(
'
close
'
)
}
}
// Prevent body scroll when modal is open
watch
(
()
=>
props
.
show
,
(
isOpen
)
=>
{
console
.
log
(
'
[Modal] show changed to:
'
,
isOpen
)
if
(
isOpen
)
{
document
.
body
.
style
.
overflow
=
'
hidden
'
}
else
{
document
.
body
.
style
.
overflow
=
''
}
},
{
immediate
:
true
}
)
onMounted
(()
=>
{
document
.
addEventListener
(
'
keydown
'
,
handleEscape
)
})
onUnmounted
(()
=>
{
document
.
removeEventListener
(
'
keydown
'
,
handleEscape
)
document
.
body
.
style
.
overflow
=
''
})
</
script
>
frontend/src/components/common/Pagination.vue
0 → 100644
View file @
642842c2
<
template
>
<div
class=
"flex items-center justify-between px-4 py-3 bg-white dark:bg-dark-800 border-t border-gray-200 dark:border-dark-700 sm:px-6"
>
<div
class=
"flex items-center justify-between flex-1 sm:hidden"
>
<!-- Mobile pagination -->
<button
@
click=
"goToPage(page - 1)"
:disabled=
"page === 1"
class=
"relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-dark-700 border border-gray-300 dark:border-dark-600 rounded-md hover:bg-gray-50 dark:hover:bg-dark-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
{{
t
(
'
pagination.previous
'
)
}}
</button>
<span
class=
"text-sm text-gray-700 dark:text-gray-300"
>
{{
t
(
'
pagination.pageOf
'
,
{
page
,
total
:
totalPages
}
)
}}
<
/span
>
<
button
@
click
=
"
goToPage(page + 1)
"
:
disabled
=
"
page === totalPages
"
class
=
"
relative inline-flex items-center px-4 py-2 ml-3 text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-dark-700 border border-gray-300 dark:border-dark-600 rounded-md hover:bg-gray-50 dark:hover:bg-dark-600 disabled:opacity-50 disabled:cursor-not-allowed
"
>
{{
t
(
'
pagination.next
'
)
}}
<
/button
>
<
/div
>
<
div
class
=
"
hidden sm:flex sm:flex-1 sm:items-center sm:justify-between
"
>
<!--
Desktop
pagination
info
-->
<
div
class
=
"
flex items-center space-x-4
"
>
<
p
class
=
"
text-sm text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
pagination.showing
'
)
}}
<
span
class
=
"
font-medium
"
>
{{
fromItem
}}
<
/span
>
{{
t
(
'
pagination.to
'
)
}}
<
span
class
=
"
font-medium
"
>
{{
toItem
}}
<
/span
>
{{
t
(
'
pagination.of
'
)
}}
<
span
class
=
"
font-medium
"
>
{{
total
}}
<
/span
>
{{
t
(
'
pagination.results
'
)
}}
<
/p
>
<!--
Page
size
selector
-->
<
div
class
=
"
flex items-center space-x-2
"
>
<
span
class
=
"
text-sm text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
pagination.perPage
'
)
}}
:
<
/span
>
<
div
class
=
"
w-20 page-size-select
"
>
<
Select
:
model
-
value
=
"
pageSize
"
:
options
=
"
pageSizeSelectOptions
"
@
update
:
model
-
value
=
"
handlePageSizeChange
"
/>
<
/div
>
<
/div
>
<
/div
>
<!--
Desktop
pagination
buttons
-->
<
nav
class
=
"
relative z-0 inline-flex -space-x-px rounded-md shadow-sm
"
aria
-
label
=
"
Pagination
"
>
<!--
Previous
button
-->
<
button
@
click
=
"
goToPage(page - 1)
"
:
disabled
=
"
page === 1
"
class
=
"
relative inline-flex items-center px-2 py-2 text-sm font-medium text-gray-500 dark:text-gray-400 bg-white dark:bg-dark-700 border border-gray-300 dark:border-dark-600 rounded-l-md hover:bg-gray-50 dark:hover:bg-dark-600 disabled:opacity-50 disabled:cursor-not-allowed
"
:
aria
-
label
=
"
t('pagination.previous')
"
>
<
svg
class
=
"
w-5 h-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
>
<
/button
>
<!--
Page
numbers
-->
<
button
v
-
for
=
"
pageNum in visiblePages
"
:
key
=
"
pageNum
"
@
click
=
"
typeof pageNum === 'number' && goToPage(pageNum)
"
:
disabled
=
"
typeof pageNum !== 'number'
"
:
class
=
"
[
'relative inline-flex items-center px-4 py-2 text-sm font-medium border',
pageNum === page
? 'z-10 bg-primary-50 dark:bg-primary-900/30 border-primary-500 text-primary-600 dark:text-primary-400'
: 'bg-white dark:bg-dark-700 border-gray-300 dark:border-dark-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-dark-600',
typeof pageNum !== 'number' && 'cursor-default'
]
"
:
aria
-
label
=
"
typeof pageNum === 'number' ? t('pagination.goToPage', { page: pageNum
}
) : undefined
"
:
aria
-
current
=
"
pageNum === page ? 'page' : undefined
"
>
{{
pageNum
}}
<
/button
>
<!--
Next
button
-->
<
button
@
click
=
"
goToPage(page + 1)
"
:
disabled
=
"
page === totalPages
"
class
=
"
relative inline-flex items-center px-2 py-2 text-sm font-medium text-gray-500 dark:text-gray-400 bg-white dark:bg-dark-700 border border-gray-300 dark:border-dark-600 rounded-r-md hover:bg-gray-50 dark:hover:bg-dark-600 disabled:opacity-50 disabled:cursor-not-allowed
"
:
aria
-
label
=
"
t('pagination.next')
"
>
<
svg
class
=
"
w-5 h-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
>
<
/button
>
<
/nav
>
<
/div
>
<
/div
>
<
/template
>
<
script
setup
lang
=
"
ts
"
>
import
{
computed
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
Select
from
'
./Select.vue
'
const
{
t
}
=
useI18n
()
interface
Props
{
total
:
number
page
:
number
pageSize
:
number
pageSizeOptions
?:
number
[]
}
interface
Emits
{
(
e
:
'
update:page
'
,
page
:
number
):
void
(
e
:
'
update:pageSize
'
,
pageSize
:
number
):
void
}
const
props
=
withDefaults
(
defineProps
<
Props
>
(),
{
pageSizeOptions
:
()
=>
[
10
,
20
,
50
,
100
]
}
)
const
emit
=
defineEmits
<
Emits
>
()
const
totalPages
=
computed
(()
=>
Math
.
ceil
(
props
.
total
/
props
.
pageSize
))
const
fromItem
=
computed
(()
=>
{
if
(
props
.
total
===
0
)
return
0
return
(
props
.
page
-
1
)
*
props
.
pageSize
+
1
}
)
const
toItem
=
computed
(()
=>
{
const
to
=
props
.
page
*
props
.
pageSize
return
to
>
props
.
total
?
props
.
total
:
to
}
)
const
pageSizeSelectOptions
=
computed
(()
=>
{
return
props
.
pageSizeOptions
.
map
(
size
=>
({
value
:
size
,
label
:
String
(
size
)
}
))
}
)
const
visiblePages
=
computed
(()
=>
{
const
pages
:
(
number
|
string
)[]
=
[]
const
maxVisible
=
7
const
total
=
totalPages
.
value
if
(
total
<=
maxVisible
)
{
// Show all pages if total is small
for
(
let
i
=
1
;
i
<=
total
;
i
++
)
{
pages
.
push
(
i
)
}
}
else
{
// Always show first page
pages
.
push
(
1
)
const
start
=
Math
.
max
(
2
,
props
.
page
-
2
)
const
end
=
Math
.
min
(
total
-
1
,
props
.
page
+
2
)
// Add ellipsis before if needed
if
(
start
>
2
)
{
pages
.
push
(
'
...
'
)
}
// Add middle pages
for
(
let
i
=
start
;
i
<=
end
;
i
++
)
{
pages
.
push
(
i
)
}
// Add ellipsis after if needed
if
(
end
<
total
-
1
)
{
pages
.
push
(
'
...
'
)
}
// Always show last page
pages
.
push
(
total
)
}
return
pages
}
)
const
goToPage
=
(
newPage
:
number
)
=>
{
if
(
newPage
>=
1
&&
newPage
<=
totalPages
.
value
&&
newPage
!==
props
.
page
)
{
emit
(
'
update:page
'
,
newPage
)
}
}
const
handlePageSizeChange
=
(
value
:
string
|
number
|
null
)
=>
{
if
(
value
===
null
)
return
const
newPageSize
=
typeof
value
===
'
string
'
?
parseInt
(
value
)
:
value
emit
(
'
update:pageSize
'
,
newPageSize
)
// Reset to first page when page size changes
if
(
props
.
page
!==
1
)
{
emit
(
'
update:page
'
,
1
)
}
}
<
/script
>
<
style
scoped
>
.
page
-
size
-
select
:
deep
(.
select
-
trigger
)
{
@
apply
py
-
1.5
px
-
3
text
-
sm
;
}
<
/style
>
frontend/src/components/common/ProxySelector.vue
0 → 100644
View file @
642842c2
<
template
>
<div
class=
"relative"
ref=
"containerRef"
>
<button
type=
"button"
@
click=
"toggle"
:disabled=
"disabled"
:class=
"[
'select-trigger',
isOpen && 'select-trigger-open',
disabled && 'select-trigger-disabled'
]"
>
<span
class=
"select-value"
>
{{
selectedLabel
}}
</span>
<span
class=
"select-icon"
>
<svg
:class=
"['w-5 h-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>
</span>
</button>
<Transition
name=
"select-dropdown"
>
<div
v-if=
"isOpen"
class=
"select-dropdown"
>
<!-- Search and Batch Test Header -->
<div
class=
"select-header"
>
<div
class=
"select-search"
>
<svg
class=
"w-4 h-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>
<input
ref=
"searchInputRef"
v-model=
"searchQuery"
type=
"text"
:placeholder=
"t('admin.proxies.searchProxies')"
class=
"select-search-input"
@
click.stop
/>
</div>
<button
v-if=
"proxies.length > 0"
type=
"button"
@
click.stop=
"handleBatchTest"
:disabled=
"batchTesting"
class=
"batch-test-btn"
:title=
"t('admin.proxies.batchTest')"
>
<svg
v-if=
"batchTesting"
class=
"w-4 h-4 animate-spin"
fill=
"none"
viewBox=
"0 0 24 24"
>
<circle
class=
"opacity-25"
cx=
"12"
cy=
"12"
r=
"10"
stroke=
"currentColor"
stroke-width=
"4"
></circle>
<path
class=
"opacity-75"
fill=
"currentColor"
d=
"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<svg
v-else
class=
"w-4 h-4"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"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>
</button>
</div>
<!-- Options list -->
<div
class=
"select-options"
>
<!-- No Proxy option -->
<div
@
click=
"selectOption(null)"
:class=
"[
'select-option',
modelValue === null && 'select-option-selected'
]"
>
<span
class=
"select-option-label"
>
{{
t
(
'
admin.accounts.noProxy
'
)
}}
</span>
<svg
v-if=
"modelValue === null"
class=
"w-4 h-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>
</div>
<!-- Proxy options -->
<div
v-for=
"proxy in filteredProxies"
:key=
"proxy.id"
@
click=
"selectOption(proxy.id)"
:class=
"[
'select-option',
modelValue === proxy.id && 'select-option-selected'
]"
>
<div
class=
"flex-1 min-w-0"
>
<div
class=
"flex items-center gap-2"
>
<span
class=
"truncate font-medium"
>
{{
proxy
.
name
}}
</span>
<!-- Account count badge -->
<span
v-if=
"proxy.account_count !== undefined"
class=
"flex-shrink-0 inline-flex items-center px-1.5 py-0.5 rounded text-xs bg-gray-100 dark:bg-dark-600 text-gray-600 dark:text-gray-400"
>
{{
proxy
.
account_count
}}
</span>
<!-- Test result badges -->
<template
v-if=
"testResults[proxy.id]"
>
<span
v-if=
"testResults[proxy.id].success"
class=
"flex-shrink-0 inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-400"
>
<span
v-if=
"testResults[proxy.id].country"
>
{{
testResults
[
proxy
.
id
].
country
}}
</span>
<span
v-if=
"testResults[proxy.id].latency_ms"
>
{{
testResults
[
proxy
.
id
].
latency_ms
}}
ms
</span>
</span>
<span
v-else
class=
"flex-shrink-0 inline-flex items-center px-1.5 py-0.5 rounded text-xs bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400"
>
{{
t
(
'
admin.proxies.testFailed
'
)
}}
</span>
</
template
>
</div>
<div
class=
"text-xs text-gray-500 dark:text-gray-400 truncate"
>
{{ proxy.protocol }}://{{ proxy.host }}:{{ proxy.port }}
</div>
</div>
<!-- Individual test button -->
<button
type=
"button"
@
click.stop=
"handleTestProxy(proxy)"
:disabled=
"testingProxyIds.has(proxy.id)"
class=
"test-btn"
:title=
"t('admin.proxies.testConnection')"
>
<svg
v-if=
"testingProxyIds.has(proxy.id)"
class=
"w-3.5 h-3.5 animate-spin"
fill=
"none"
viewBox=
"0 0 24 24"
>
<circle
class=
"opacity-25"
cx=
"12"
cy=
"12"
r=
"10"
stroke=
"currentColor"
stroke-width=
"4"
></circle>
<path
class=
"opacity-75"
fill=
"currentColor"
d=
"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<svg
v-else
class=
"w-3.5 h-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>
</button>
<svg
v-if=
"modelValue === proxy.id"
class=
"w-4 h-4 text-primary-500 flex-shrink-0"
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>
</div>
<!-- Empty state -->
<div
v-if=
"filteredProxies.length === 0 && searchQuery"
class=
"select-empty"
>
{{ t('common.noOptionsFound') }}
</div>
</div>
</div>
</Transition>
</div>
</template>
<
script
setup
lang=
"ts"
>
import
{
ref
,
reactive
,
computed
,
onMounted
,
onUnmounted
,
nextTick
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
type
{
Proxy
}
from
'
@/types
'
const
{
t
}
=
useI18n
()
interface
ProxyTestResult
{
success
:
boolean
message
:
string
latency_ms
?:
number
ip_address
?:
string
city
?:
string
region
?:
string
country
?:
string
}
interface
Props
{
modelValue
:
number
|
null
proxies
:
Proxy
[]
disabled
?:
boolean
}
const
props
=
withDefaults
(
defineProps
<
Props
>
(),
{
disabled
:
false
,
})
const
emit
=
defineEmits
<
{
'
update:modelValue
'
:
[
value
:
number
|
null
]
}
>
()
const
isOpen
=
ref
(
false
)
const
searchQuery
=
ref
(
''
)
const
containerRef
=
ref
<
HTMLElement
|
null
>
(
null
)
const
searchInputRef
=
ref
<
HTMLInputElement
|
null
>
(
null
)
// Test state
const
testResults
=
reactive
<
Record
<
number
,
ProxyTestResult
>>
({})
const
testingProxyIds
=
reactive
(
new
Set
<
number
>
())
const
batchTesting
=
ref
(
false
)
const
selectedProxy
=
computed
(()
=>
{
if
(
props
.
modelValue
===
null
)
return
null
return
props
.
proxies
.
find
(
p
=>
p
.
id
===
props
.
modelValue
)
||
null
})
const
selectedLabel
=
computed
(()
=>
{
if
(
!
selectedProxy
.
value
)
{
return
t
(
'
admin.accounts.noProxy
'
)
}
const
proxy
=
selectedProxy
.
value
return
`
${
proxy
.
name
}
(
${
proxy
.
protocol
}
://
${
proxy
.
host
}
:
${
proxy
.
port
}
)`
})
const
filteredProxies
=
computed
(()
=>
{
if
(
!
searchQuery
.
value
)
{
return
props
.
proxies
}
const
query
=
searchQuery
.
value
.
toLowerCase
()
return
props
.
proxies
.
filter
(
proxy
=>
{
const
name
=
proxy
.
name
.
toLowerCase
()
const
host
=
proxy
.
host
.
toLowerCase
()
return
name
.
includes
(
query
)
||
host
.
includes
(
query
)
})
})
const
toggle
=
()
=>
{
if
(
props
.
disabled
)
return
isOpen
.
value
=
!
isOpen
.
value
if
(
isOpen
.
value
)
{
nextTick
(()
=>
{
searchInputRef
.
value
?.
focus
()
})
}
}
const
selectOption
=
(
value
:
number
|
null
)
=>
{
emit
(
'
update:modelValue
'
,
value
)
isOpen
.
value
=
false
searchQuery
.
value
=
''
}
const
handleTestProxy
=
async
(
proxy
:
Proxy
)
=>
{
if
(
testingProxyIds
.
has
(
proxy
.
id
))
return
testingProxyIds
.
add
(
proxy
.
id
)
try
{
const
result
=
await
adminAPI
.
proxies
.
testProxy
(
proxy
.
id
)
testResults
[
proxy
.
id
]
=
result
}
catch
(
error
:
any
)
{
testResults
[
proxy
.
id
]
=
{
success
:
false
,
message
:
error
.
response
?.
data
?.
detail
||
'
Test failed
'
}
}
finally
{
testingProxyIds
.
delete
(
proxy
.
id
)
}
}
const
handleBatchTest
=
async
()
=>
{
if
(
batchTesting
.
value
||
props
.
proxies
.
length
===
0
)
return
batchTesting
.
value
=
true
// Test all proxies in parallel
const
testPromises
=
props
.
proxies
.
map
(
async
(
proxy
)
=>
{
testingProxyIds
.
add
(
proxy
.
id
)
try
{
const
result
=
await
adminAPI
.
proxies
.
testProxy
(
proxy
.
id
)
testResults
[
proxy
.
id
]
=
result
}
catch
(
error
:
any
)
{
testResults
[
proxy
.
id
]
=
{
success
:
false
,
message
:
error
.
response
?.
data
?.
detail
||
'
Test failed
'
}
}
finally
{
testingProxyIds
.
delete
(
proxy
.
id
)
}
})
await
Promise
.
all
(
testPromises
)
batchTesting
.
value
=
false
}
const
handleClickOutside
=
(
event
:
MouseEvent
)
=>
{
if
(
containerRef
.
value
&&
!
containerRef
.
value
.
contains
(
event
.
target
as
Node
))
{
isOpen
.
value
=
false
searchQuery
.
value
=
''
}
}
const
handleEscape
=
(
event
:
KeyboardEvent
)
=>
{
if
(
event
.
key
===
'
Escape
'
&&
isOpen
.
value
)
{
isOpen
.
value
=
false
searchQuery
.
value
=
''
}
}
onMounted
(()
=>
{
document
.
addEventListener
(
'
click
'
,
handleClickOutside
)
document
.
addEventListener
(
'
keydown
'
,
handleEscape
)
})
onUnmounted
(()
=>
{
document
.
removeEventListener
(
'
click
'
,
handleClickOutside
)
document
.
removeEventListener
(
'
keydown
'
,
handleEscape
)
})
</
script
>
<
style
scoped
>
.select-trigger
{
@apply
w-full
flex
items-center
justify-between
gap-2;
@apply
px-4
py-2.5
rounded-xl
text-sm;
@apply
bg-white
dark
:
bg-dark-800
;
@apply
border
border-gray-200
dark
:
border-dark-600
;
@apply
text-gray-900
dark
:
text-gray-100
;
@apply
transition-all
duration-200;
@apply
focus
:
outline-none
focus
:
ring-2
focus
:
ring-primary-500
/
30
focus
:
border-primary-500
;
@apply
hover
:
border-gray-300
dark
:
hover
:
border-dark-500
;
@apply
cursor-pointer;
}
.select-trigger-open
{
@apply
ring-2
ring-primary-500/30
border-primary-500;
}
.select-trigger-disabled
{
@apply
bg-gray-100
dark
:
bg-dark-900
cursor-not-allowed
opacity-60
;
}
.select-value
{
@apply
flex-1
text-left
truncate;
}
.select-icon
{
@apply
flex-shrink-0
text-gray-400
dark
:
text-dark-400
;
}
.select-dropdown
{
@apply
absolute
z-[100]
w-full
mt-2;
@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;
}
.select-header
{
@apply
flex
items-center
gap-2
px-3
py-2;
@apply
border-b
border-gray-100
dark
:
border-dark-700
;
}
.select-search
{
@apply
flex-1
flex
items-center
gap-2;
}
.select-search-input
{
@apply
flex-1
bg-transparent
text-sm;
@apply
text-gray-900
dark
:
text-gray-100
;
@apply
placeholder
:
text-gray-400
dark
:
placeholder
:
text-dark-400
;
@apply
focus
:
outline-none
;
}
.batch-test-btn
{
@apply
flex-shrink-0
p-1.5
rounded-lg;
@apply
text-gray-500
hover
:
text-emerald-600
dark
:
hover
:
text-emerald-400
;
@apply
hover
:
bg-emerald-50
dark
:
hover
:
bg-emerald-900
/
20
;
@apply
transition-colors
disabled
:
opacity-50
disabled
:
cursor-not-allowed
;
}
.select-options
{
@apply
max-h-60
overflow-y-auto
py-1;
}
.select-option
{
@apply
flex
items-center
justify-between
gap-2;
@apply
px-4
py-2.5
text-sm;
@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
;
}
.select-option-selected
{
@apply
bg-primary-50
dark
:
bg-primary-900
/
20
;
@apply
text-primary-700
dark
:
text-primary-300
;
}
.select-option-label
{
@apply
truncate;
}
.select-empty
{
@apply
px-4
py-8
text-center
text-sm;
@apply
text-gray-500
dark
:
text-dark-400
;
}
.test-btn
{
@apply
flex-shrink-0
p-1
rounded;
@apply
text-gray-400
hover
:
text-emerald-600
dark
:
hover
:
text-emerald-400
;
@apply
hover
:
bg-emerald-50
dark
:
hover
:
bg-emerald-900
/
20
;
@apply
transition-colors
disabled
:
opacity-50
disabled
:
cursor-not-allowed
;
}
/* Dropdown animation */
.select-dropdown-enter-active
,
.select-dropdown-leave-active
{
transition
:
all
0.2s
ease
;
}
.select-dropdown-enter-from
,
.select-dropdown-leave-to
{
opacity
:
0
;
transform
:
translateY
(
-8px
);
}
</
style
>
frontend/src/components/common/README.md
0 → 100644
View file @
642842c2
# Common Components
This directory contains reusable Vue 3 components built with Composition API, TypeScript, and TailwindCSS.
## Components
### DataTable.vue
A generic data table component with sorting, loading states, and custom cell rendering.
**Props:**
-
`columns: Column[]`
- Array of column definitions with key, label, sortable, and formatter
-
`data: any[]`
- Array of data objects to display
-
`loading?: boolean`
- Show loading skeleton
**Slots:**
-
`empty`
- Custom empty state content
-
`cell-{key}`
- Custom cell renderer for specific column (receives
`row`
and
`value`
)
**Usage:**
```
vue
<DataTable
:columns=
"[
{ key: 'name', label: 'Name', sortable: true },
{ key: 'email', label: 'Email' },
{ key: 'status', label: 'Status', formatter: (val) => val.toUpperCase() }
]"
:data=
"users"
:loading=
"isLoading"
>
<
template
#cell-actions=
"{ row }"
>
<button
@
click=
"editUser(row)"
>
Edit
</button>
</
template
>
</DataTable>
```
---
### Pagination.vue
Pagination component with page numbers, navigation, and page size selector.
**Props:**
-
`total: number`
- Total number of items
-
`page: number`
- Current page (1-indexed)
-
`pageSize: number`
- Items per page
-
`pageSizeOptions?: number[]`
- Available page size options (default: [10, 20, 50, 100])
**Events:**
-
`update:page`
- Emitted when page changes
-
`update:pageSize`
- Emitted when page size changes
**Usage:**
```
vue
<Pagination
:total=
"totalUsers"
:page=
"currentPage"
:pageSize=
"pageSize"
@
update:page=
"currentPage = $event"
@
update:pageSize=
"pageSize = $event"
/>
```
---
### Modal.vue
Modal dialog with customizable size and close behavior.
**Props:**
-
`show: boolean`
- Control modal visibility
-
`title: string`
- Modal title
-
`size?: 'sm' | 'md' | 'lg' | 'xl' | 'full'`
- Modal size (default: 'md')
-
`closeOnEscape?: boolean`
- Close on Escape key (default: true)
-
`closeOnClickOutside?: boolean`
- Close on backdrop click (default: true)
**Events:**
-
`close`
- Emitted when modal should close
**Slots:**
-
`default`
- Modal body content
-
`footer`
- Modal footer content
**Usage:**
```
vue
<Modal
:show=
"showModal"
title=
"Edit User"
size=
"lg"
@
close=
"showModal = false"
>
<form
@
submit.prevent=
"saveUser"
>
<!-- Form content -->
</form>
<
template
#footer
>
<button
@
click=
"showModal = false"
>
Cancel
</button>
<button
@
click=
"saveUser"
>
Save
</button>
</
template
>
</Modal>
```
---
### ConfirmDialog.vue
Confirmation dialog built on top of Modal component.
**Props:**
-
`show: boolean`
- Control dialog visibility
-
`title: string`
- Dialog title
-
`message: string`
- Confirmation message
-
`confirmText?: string`
- Confirm button text (default: 'Confirm')
-
`cancelText?: string`
- Cancel button text (default: 'Cancel')
-
`danger?: boolean`
- Use danger/red styling (default: false)
**Events:**
-
`confirm`
- Emitted when user confirms
-
`cancel`
- Emitted when user cancels
**Usage:**
```
vue
<ConfirmDialog
:show=
"showDeleteConfirm"
title=
"Delete User"
message=
"Are you sure you want to delete this user? This action cannot be undone."
confirm-text=
"Delete"
cancel-text=
"Cancel"
danger
@
confirm=
"deleteUser"
@
cancel=
"showDeleteConfirm = false"
/>
```
---
### StatCard.vue
Statistics card component for displaying metrics with optional change indicators.
**Props:**
-
`title: string`
- Card title
-
`value: number | string`
- Main value to display
-
`icon?: Component`
- Icon component
-
`change?: number`
- Percentage change value
-
`changeType?: 'up' | 'down' | 'neutral'`
- Change direction (default: 'neutral')
-
`formatValue?: (value) => string`
- Custom value formatter
**Usage:**
```
vue
<StatCard
title=
"Total Users"
:value=
"1234"
:icon=
"UserIcon"
:change=
"12.5"
change-type=
"up"
/>
```
---
### Toast.vue
Toast notification component that automatically displays toasts from the app store.
**Usage:**
```
vue
<!-- Add once in App.vue or layout -->
<Toast
/>
```
```
typescript
// Trigger toasts from anywhere using the app store
import
{
useAppStore
}
from
'
@/stores/app
'
const
appStore
=
useAppStore
()
appStore
.
addToast
({
type
:
'
success
'
,
title
:
'
Success!
'
,
message
:
'
User created successfully
'
,
duration
:
3000
})
appStore
.
addToast
({
type
:
'
error
'
,
message
:
'
Failed to delete user
'
})
```
---
### LoadingSpinner.vue
Simple animated loading spinner.
**Props:**
-
`size?: 'sm' | 'md' | 'lg' | 'xl'`
- Spinner size (default: 'md')
-
`color?: 'primary' | 'secondary' | 'white' | 'gray'`
- Spinner color (default: 'primary')
**Usage:**
```
vue
<LoadingSpinner
size=
"lg"
color=
"primary"
/>
```
---
### EmptyState.vue
Empty state placeholder with icon, message, and optional action button.
**Props:**
-
`icon?: Component`
- Icon component
-
`title: string`
- Empty state title
-
`description: string`
- Empty state description
-
`actionText?: string`
- Action button text
-
`actionTo?: string | object`
- Router link destination
-
`actionIcon?: boolean`
- Show plus icon in button (default: true)
**Slots:**
-
`icon`
- Custom icon content
-
`action`
- Custom action button/link
**Usage:**
```
vue
<EmptyState
title=
"No users found"
description=
"Get started by creating your first user account."
action-text=
"Add User"
:action-to=
"{ name: 'users-create' }"
/>
```
## Import
You can import components individually:
```
typescript
import
{
DataTable
,
Pagination
,
Modal
}
from
'
@/components/common
'
```
Or import specific components:
```
typescript
import
DataTable
from
'
@/components/common/DataTable.vue
'
```
## Features
All components include:
-
**TypeScript support**
with proper type definitions
-
**Accessibility**
with ARIA attributes and keyboard navigation
-
**Responsive design**
with mobile-friendly layouts
-
**TailwindCSS styling**
for consistent design
-
**Vue 3 Composition API**
with
`<script setup>`
-
**Slot support**
for customization
frontend/src/components/common/Select.vue
0 → 100644
View file @
642842c2
<
template
>
<div
class=
"relative"
ref=
"containerRef"
>
<button
type=
"button"
@
click=
"toggle"
:disabled=
"disabled"
:class=
"[
'select-trigger',
isOpen && 'select-trigger-open',
error && 'select-trigger-error',
disabled && 'select-trigger-disabled'
]"
>
<span
class=
"select-value"
>
<slot
name=
"selected"
:option=
"selectedOption"
>
{{
selectedLabel
}}
</slot>
</span>
<span
class=
"select-icon"
>
<svg
:class=
"['w-5 h-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>
</span>
</button>
<Transition
name=
"select-dropdown"
>
<div
v-if=
"isOpen"
class=
"select-dropdown"
>
<!-- Search input -->
<div
v-if=
"searchable"
class=
"select-search"
>
<svg
class=
"w-4 h-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>
<input
ref=
"searchInputRef"
v-model=
"searchQuery"
type=
"text"
:placeholder=
"searchPlaceholderText"
class=
"select-search-input"
@
click.stop
/>
</div>
<!-- Options list -->
<div
class=
"select-options"
>
<div
v-for=
"option in filteredOptions"
:key=
"getOptionValue(option)"
@
click=
"selectOption(option)"
:class=
"[
'select-option',
isSelected(option) && 'select-option-selected'
]"
>
<slot
name=
"option"
:option=
"option"
:selected=
"isSelected(option)"
>
<span
class=
"select-option-label"
>
{{
getOptionLabel
(
option
)
}}
</span>
<svg
v-if=
"isSelected(option)"
class=
"w-4 h-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>
</slot>
</div>
<!-- Empty state -->
<div
v-if=
"filteredOptions.length === 0"
class=
"select-empty"
>
{{
emptyTextDisplay
}}
</div>
</div>
</div>
</Transition>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
ref
,
computed
,
watch
,
onMounted
,
onUnmounted
,
nextTick
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
const
{
t
}
=
useI18n
()
export
interface
SelectOption
{
value
:
string
|
number
|
null
label
:
string
disabled
?:
boolean
[
key
:
string
]:
unknown
}
interface
Props
{
modelValue
:
string
|
number
|
null
|
undefined
options
:
SelectOption
[]
|
Array
<
Record
<
string
,
unknown
>>
placeholder
?:
string
disabled
?:
boolean
error
?:
boolean
searchable
?:
boolean
searchPlaceholder
?:
string
emptyText
?:
string
valueKey
?:
string
labelKey
?:
string
}
interface
Emits
{
(
e
:
'
update:modelValue
'
,
value
:
string
|
number
|
null
):
void
(
e
:
'
change
'
,
value
:
string
|
number
|
null
,
option
:
SelectOption
|
null
):
void
}
const
props
=
withDefaults
(
defineProps
<
Props
>
(),
{
disabled
:
false
,
error
:
false
,
searchable
:
false
,
valueKey
:
'
value
'
,
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
containerRef
=
ref
<
HTMLElement
|
null
>
(
null
)
const
searchInputRef
=
ref
<
HTMLInputElement
|
null
>
(
null
)
const
getOptionValue
=
(
option
:
SelectOption
|
Record
<
string
,
unknown
>
):
string
|
number
|
null
=>
{
if
(
typeof
option
===
'
object
'
&&
option
!==
null
)
{
return
option
[
props
.
valueKey
]
as
string
|
number
|
null
}
return
option
as
string
|
number
|
null
}
const
getOptionLabel
=
(
option
:
SelectOption
|
Record
<
string
,
unknown
>
):
string
=>
{
if
(
typeof
option
===
'
object
'
&&
option
!==
null
)
{
return
String
(
option
[
props
.
labelKey
]
??
''
)
}
return
String
(
option
??
''
)
}
const
selectedOption
=
computed
(()
=>
{
return
props
.
options
.
find
(
opt
=>
getOptionValue
(
opt
)
===
props
.
modelValue
)
||
null
})
const
selectedLabel
=
computed
(()
=>
{
if
(
selectedOption
.
value
)
{
return
getOptionLabel
(
selectedOption
.
value
)
}
return
placeholderText
.
value
})
const
filteredOptions
=
computed
(()
=>
{
if
(
!
props
.
searchable
||
!
searchQuery
.
value
)
{
return
props
.
options
}
const
query
=
searchQuery
.
value
.
toLowerCase
()
return
props
.
options
.
filter
(
opt
=>
{
const
label
=
getOptionLabel
(
opt
).
toLowerCase
()
return
label
.
includes
(
query
)
})
})
const
isSelected
=
(
option
:
SelectOption
|
Record
<
string
,
unknown
>
):
boolean
=>
{
return
getOptionValue
(
option
)
===
props
.
modelValue
}
const
toggle
=
()
=>
{
if
(
props
.
disabled
)
return
isOpen
.
value
=
!
isOpen
.
value
if
(
isOpen
.
value
&&
props
.
searchable
)
{
nextTick
(()
=>
{
searchInputRef
.
value
?.
focus
()
})
}
}
const
selectOption
=
(
option
:
SelectOption
|
Record
<
string
,
unknown
>
)
=>
{
const
value
=
getOptionValue
(
option
)
emit
(
'
update:modelValue
'
,
value
)
emit
(
'
change
'
,
value
,
option
as
SelectOption
)
isOpen
.
value
=
false
searchQuery
.
value
=
''
}
const
handleClickOutside
=
(
event
:
MouseEvent
)
=>
{
if
(
containerRef
.
value
&&
!
containerRef
.
value
.
contains
(
event
.
target
as
Node
))
{
isOpen
.
value
=
false
searchQuery
.
value
=
''
}
}
const
handleEscape
=
(
event
:
KeyboardEvent
)
=>
{
if
(
event
.
key
===
'
Escape
'
&&
isOpen
.
value
)
{
isOpen
.
value
=
false
searchQuery
.
value
=
''
}
}
watch
(
isOpen
,
(
open
)
=>
{
if
(
!
open
)
{
searchQuery
.
value
=
''
}
})
onMounted
(()
=>
{
document
.
addEventListener
(
'
click
'
,
handleClickOutside
)
document
.
addEventListener
(
'
keydown
'
,
handleEscape
)
})
onUnmounted
(()
=>
{
document
.
removeEventListener
(
'
click
'
,
handleClickOutside
)
document
.
removeEventListener
(
'
keydown
'
,
handleEscape
)
})
</
script
>
<
style
scoped
>
.select-trigger
{
@apply
w-full
flex
items-center
justify-between
gap-2;
@apply
px-4
py-2.5
rounded-xl
text-sm;
@apply
bg-white
dark
:
bg-dark-800
;
@apply
border
border-gray-200
dark
:
border-dark-600
;
@apply
text-gray-900
dark
:
text-gray-100
;
@apply
transition-all
duration-200;
@apply
focus
:
outline-none
focus
:
ring-2
focus
:
ring-primary-500
/
30
focus
:
border-primary-500
;
@apply
hover
:
border-gray-300
dark
:
hover
:
border-dark-500
;
@apply
cursor-pointer;
}
.select-trigger-open
{
@apply
ring-2
ring-primary-500/30
border-primary-500;
}
.select-trigger-error
{
@apply
border-red-500
focus
:
ring-red-500
/
30
focus
:
border-red-500
;
}
.select-trigger-disabled
{
@apply
bg-gray-100
dark
:
bg-dark-900
cursor-not-allowed
opacity-60
;
}
.select-value
{
@apply
flex-1
text-left
truncate;
}
.select-icon
{
@apply
flex-shrink-0
text-gray-400
dark
:
text-dark-400
;
}
.select-dropdown
{
@apply
absolute
z-[100]
w-full
mt-2;
@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;
}
.select-search
{
@apply
flex
items-center
gap-2
px-3
py-2;
@apply
border-b
border-gray-100
dark
:
border-dark-700
;
}
.select-search-input
{
@apply
flex-1
bg-transparent
text-sm;
@apply
text-gray-900
dark
:
text-gray-100
;
@apply
placeholder
:
text-gray-400
dark
:
placeholder
:
text-dark-400
;
@apply
focus
:
outline-none
;
}
.select-options
{
@apply
max-h-60
overflow-y-auto
py-1;
}
.select-option
{
@apply
flex
items-center
justify-between
gap-2;
@apply
px-4
py-2.5
text-sm;
@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
;
}
.select-option-selected
{
@apply
bg-primary-50
dark
:
bg-primary-900
/
20
;
@apply
text-primary-700
dark
:
text-primary-300
;
}
.select-option-label
{
@apply
truncate;
}
.select-empty
{
@apply
px-4
py-8
text-center
text-sm;
@apply
text-gray-500
dark
:
text-dark-400
;
}
/* Dropdown animation */
.select-dropdown-enter-active
,
.select-dropdown-leave-active
{
transition
:
all
0.2s
ease
;
}
.select-dropdown-enter-from
,
.select-dropdown-leave-to
{
opacity
:
0
;
transform
:
translateY
(
-8px
);
}
</
style
>
frontend/src/components/common/StatCard.vue
0 → 100644
View file @
642842c2
<
template
>
<div
class=
"stat-card"
>
<div
:class=
"['stat-icon', iconClass]"
>
<component
v-if=
"icon"
:is=
"icon"
class=
"w-6 h-6"
aria-hidden=
"true"
/>
</div>
<div
class=
"flex-1 min-w-0"
>
<p
class=
"stat-label truncate"
>
{{
title
}}
</p>
<div
class=
"flex items-baseline gap-2 mt-1"
>
<p
class=
"stat-value"
>
{{
formattedValue
}}
</p>
<span
v-if=
"change !== undefined"
:class=
"['stat-trend', trendClass]"
>
<svg
v-if=
"changeType !== 'neutral'"
:class=
"['w-3 h-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"
/>
</svg>
{{
formattedChange
}}
</span>
</div>
</div>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
computed
}
from
'
vue
'
import
type
{
Component
}
from
'
vue
'
type
ChangeType
=
'
up
'
|
'
down
'
|
'
neutral
'
type
IconVariant
=
'
primary
'
|
'
success
'
|
'
warning
'
|
'
danger
'
interface
Props
{
title
:
string
value
:
number
|
string
icon
?:
Component
iconVariant
?:
IconVariant
change
?:
number
changeType
?:
ChangeType
formatValue
?:
(
value
:
number
|
string
)
=>
string
}
const
props
=
withDefaults
(
defineProps
<
Props
>
(),
{
changeType
:
'
neutral
'
,
iconVariant
:
'
primary
'
})
const
formattedValue
=
computed
(()
=>
{
if
(
props
.
formatValue
)
{
return
props
.
formatValue
(
props
.
value
)
}
if
(
typeof
props
.
value
===
'
number
'
)
{
return
props
.
value
.
toLocaleString
()
}
return
props
.
value
})
const
formattedChange
=
computed
(()
=>
{
if
(
props
.
change
===
undefined
)
return
''
const
absChange
=
Math
.
abs
(
props
.
change
)
return
`
${
absChange
}
%`
})
const
iconClass
=
computed
(()
=>
{
const
classes
:
Record
<
IconVariant
,
string
>
=
{
primary
:
'
stat-icon-primary
'
,
success
:
'
stat-icon-success
'
,
warning
:
'
stat-icon-warning
'
,
danger
:
'
stat-icon-danger
'
}
return
classes
[
props
.
iconVariant
]
})
const
trendClass
=
computed
(()
=>
{
const
classes
:
Record
<
ChangeType
,
string
>
=
{
up
:
'
stat-trend-up
'
,
down
:
'
stat-trend-down
'
,
neutral
:
'
text-gray-500 dark:text-dark-400
'
}
return
classes
[
props
.
changeType
]
})
</
script
>
frontend/src/components/common/SubscriptionProgressMini.vue
0 → 100644
View file @
642842c2
<
template
>
<div
v-if=
"hasActiveSubscriptions"
class=
"relative"
ref=
"containerRef"
>
<!-- Mini Progress Display -->
<button
@
click=
"toggleTooltip"
class=
"flex items-center gap-2 px-3 py-1.5 rounded-xl bg-purple-50 dark:bg-purple-900/20 hover:bg-purple-100 dark:hover:bg-purple-900/30 transition-colors cursor-pointer"
:title=
"t('subscriptionProgress.viewDetails')"
>
<svg
class=
"w-4 h-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>
<div
class=
"flex items-center gap-1.5"
>
<!-- Combined progress indicator -->
<div
class=
"flex items-center gap-0.5"
>
<div
v-for=
"(sub, index) in displaySubscriptions.slice(0, 3)"
:key=
"index"
class=
"w-2 h-2 rounded-full"
:class=
"getProgressDotClass(sub)"
></div>
</div>
<span
class=
"text-xs font-medium text-purple-700 dark:text-purple-300"
>
{{
activeSubscriptions
.
length
}}
</span>
</div>
</button>
<!-- Hover/Click Tooltip -->
<transition
name=
"dropdown"
>
<div
v-if=
"tooltipOpen"
class=
"absolute right-0 mt-2 w-80 bg-white dark:bg-dark-800 rounded-xl shadow-xl border border-gray-200 dark:border-dark-700 z-50"
>
<div
class=
"p-3 border-b border-gray-100 dark:border-dark-700"
>
<h3
class=
"text-sm font-semibold text-gray-900 dark:text-white"
>
{{
t
(
'
subscriptionProgress.title
'
)
}}
</h3>
<p
class=
"text-xs text-gray-500 dark:text-dark-400 mt-0.5"
>
{{
t
(
'
subscriptionProgress.activeCount
'
,
{
count
:
activeSubscriptions
.
length
}
)
}}
<
/p
>
<
/div
>
<
div
class
=
"
max-h-64 overflow-y-auto
"
>
<
div
v
-
for
=
"
subscription in displaySubscriptions
"
:
key
=
"
subscription.id
"
class
=
"
p-3 border-b border-gray-50 dark:border-dark-700/50 last:border-b-0
"
>
<
div
class
=
"
flex items-center justify-between mb-2
"
>
<
span
class
=
"
text-sm font-medium text-gray-900 dark:text-white
"
>
{{
subscription
.
group
?.
name
||
`Group #${subscription.group_id
}
`
}}
<
/span
>
<
span
v
-
if
=
"
subscription.expires_at
"
class
=
"
text-xs
"
:
class
=
"
getDaysRemainingClass(subscription.expires_at)
"
>
{{
formatDaysRemaining
(
subscription
.
expires_at
)
}}
<
/span
>
<
/div
>
<!--
Progress
bars
-->
<
div
class
=
"
space-y-1.5
"
>
<
div
v
-
if
=
"
subscription.group?.daily_limit_usd
"
class
=
"
flex items-center gap-2
"
>
<
span
class
=
"
text-[10px] text-gray-500 w-8
"
>
{{
t
(
'
subscriptionProgress.daily
'
)
}}
<
/span
>
<
div
class
=
"
flex-1 bg-gray-200 dark:bg-dark-600 rounded-full h-1.5
"
>
<
div
class
=
"
h-1.5 rounded-full transition-all
"
:
class
=
"
getProgressBarClass(subscription.daily_usage_usd, subscription.group?.daily_limit_usd)
"
:
style
=
"
{ width: getProgressWidth(subscription.daily_usage_usd, subscription.group?.daily_limit_usd)
}
"
><
/div
>
<
/div
>
<
span
class
=
"
text-[10px] text-gray-500 w-16 text-right
"
>
{{
formatUsage
(
subscription
.
daily_usage_usd
,
subscription
.
group
?.
daily_limit_usd
)
}}
<
/span
>
<
/div
>
<
div
v
-
if
=
"
subscription.group?.weekly_limit_usd
"
class
=
"
flex items-center gap-2
"
>
<
span
class
=
"
text-[10px] text-gray-500 w-8
"
>
{{
t
(
'
subscriptionProgress.weekly
'
)
}}
<
/span
>
<
div
class
=
"
flex-1 bg-gray-200 dark:bg-dark-600 rounded-full h-1.5
"
>
<
div
class
=
"
h-1.5 rounded-full transition-all
"
:
class
=
"
getProgressBarClass(subscription.weekly_usage_usd, subscription.group?.weekly_limit_usd)
"
:
style
=
"
{ width: getProgressWidth(subscription.weekly_usage_usd, subscription.group?.weekly_limit_usd)
}
"
><
/div
>
<
/div
>
<
span
class
=
"
text-[10px] text-gray-500 w-16 text-right
"
>
{{
formatUsage
(
subscription
.
weekly_usage_usd
,
subscription
.
group
?.
weekly_limit_usd
)
}}
<
/span
>
<
/div
>
<
div
v
-
if
=
"
subscription.group?.monthly_limit_usd
"
class
=
"
flex items-center gap-2
"
>
<
span
class
=
"
text-[10px] text-gray-500 w-8
"
>
{{
t
(
'
subscriptionProgress.monthly
'
)
}}
<
/span
>
<
div
class
=
"
flex-1 bg-gray-200 dark:bg-dark-600 rounded-full h-1.5
"
>
<
div
class
=
"
h-1.5 rounded-full transition-all
"
:
class
=
"
getProgressBarClass(subscription.monthly_usage_usd, subscription.group?.monthly_limit_usd)
"
:
style
=
"
{ width: getProgressWidth(subscription.monthly_usage_usd, subscription.group?.monthly_limit_usd)
}
"
><
/div
>
<
/div
>
<
span
class
=
"
text-[10px] text-gray-500 w-16 text-right
"
>
{{
formatUsage
(
subscription
.
monthly_usage_usd
,
subscription
.
group
?.
monthly_limit_usd
)
}}
<
/span
>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<
div
class
=
"
p-2 border-t border-gray-100 dark:border-dark-700
"
>
<
router
-
link
to
=
"
/subscriptions
"
@
click
=
"
closeTooltip
"
class
=
"
block w-full text-center text-xs text-primary-600 dark:text-primary-400 hover:underline py-1
"
>
{{
t
(
'
subscriptionProgress.viewAll
'
)
}}
<
/router-link
>
<
/div
>
<
/div
>
<
/transition
>
<
/div
>
<
/template
>
<
script
setup
lang
=
"
ts
"
>
import
{
ref
,
computed
,
onMounted
,
onBeforeUnmount
,
watch
}
from
'
vue
'
;
import
{
useI18n
}
from
'
vue-i18n
'
;
import
subscriptionsAPI
from
'
@/api/subscriptions
'
;
import
type
{
UserSubscription
}
from
'
@/types
'
;
const
{
t
}
=
useI18n
();
const
containerRef
=
ref
<
HTMLElement
|
null
>
(
null
);
const
tooltipOpen
=
ref
(
false
);
const
activeSubscriptions
=
ref
<
UserSubscription
[]
>
([]);
const
loading
=
ref
(
false
);
const
hasActiveSubscriptions
=
computed
(()
=>
activeSubscriptions
.
value
.
length
>
0
);
const
displaySubscriptions
=
computed
(()
=>
{
// Sort by most usage (highest percentage first)
return
[...
activeSubscriptions
.
value
].
sort
((
a
,
b
)
=>
{
const
aMax
=
getMaxUsagePercentage
(
a
);
const
bMax
=
getMaxUsagePercentage
(
b
);
return
bMax
-
aMax
;
}
);
}
);
function
getMaxUsagePercentage
(
sub
:
UserSubscription
):
number
{
const
percentages
:
number
[]
=
[];
if
(
sub
.
group
?.
daily_limit_usd
)
{
percentages
.
push
((
sub
.
daily_usage_usd
||
0
)
/
sub
.
group
.
daily_limit_usd
*
100
);
}
if
(
sub
.
group
?.
weekly_limit_usd
)
{
percentages
.
push
((
sub
.
weekly_usage_usd
||
0
)
/
sub
.
group
.
weekly_limit_usd
*
100
);
}
if
(
sub
.
group
?.
monthly_limit_usd
)
{
percentages
.
push
((
sub
.
monthly_usage_usd
||
0
)
/
sub
.
group
.
monthly_limit_usd
*
100
);
}
return
percentages
.
length
>
0
?
Math
.
max
(...
percentages
)
:
0
;
}
function
getProgressDotClass
(
sub
:
UserSubscription
):
string
{
const
maxPercentage
=
getMaxUsagePercentage
(
sub
);
if
(
maxPercentage
>=
90
)
return
'
bg-red-500
'
;
if
(
maxPercentage
>=
70
)
return
'
bg-orange-500
'
;
return
'
bg-green-500
'
;
}
function
getProgressBarClass
(
used
:
number
|
undefined
,
limit
:
number
|
null
|
undefined
):
string
{
if
(
!
limit
||
limit
===
0
)
return
'
bg-gray-400
'
;
const
percentage
=
((
used
||
0
)
/
limit
)
*
100
;
if
(
percentage
>=
90
)
return
'
bg-red-500
'
;
if
(
percentage
>=
70
)
return
'
bg-orange-500
'
;
return
'
bg-green-500
'
;
}
function
getProgressWidth
(
used
:
number
|
undefined
,
limit
:
number
|
null
|
undefined
):
string
{
if
(
!
limit
||
limit
===
0
)
return
'
0%
'
;
const
percentage
=
Math
.
min
(((
used
||
0
)
/
limit
)
*
100
,
100
);
return
`${percentage
}
%`
;
}
function
formatUsage
(
used
:
number
|
undefined
,
limit
:
number
|
null
|
undefined
):
string
{
const
usedValue
=
(
used
||
0
).
toFixed
(
2
);
const
limitValue
=
limit
?.
toFixed
(
2
)
||
'
∞
'
;
return
`$${usedValue
}
/$${limitValue
}
`
;
}
function
formatDaysRemaining
(
expiresAt
:
string
):
string
{
const
now
=
new
Date
();
const
expires
=
new
Date
(
expiresAt
);
const
diff
=
expires
.
getTime
()
-
now
.
getTime
();
if
(
diff
<
0
)
return
t
(
'
subscriptionProgress.expired
'
);
const
days
=
Math
.
ceil
(
diff
/
(
1000
*
60
*
60
*
24
));
if
(
days
===
0
)
return
t
(
'
subscriptionProgress.expirestoday
'
);
if
(
days
===
1
)
return
t
(
'
subscriptionProgress.expiresTomorrow
'
);
return
t
(
'
subscriptionProgress.daysRemaining
'
,
{
days
}
);
}
function
getDaysRemainingClass
(
expiresAt
:
string
):
string
{
const
now
=
new
Date
();
const
expires
=
new
Date
(
expiresAt
);
const
diff
=
expires
.
getTime
()
-
now
.
getTime
();
const
days
=
Math
.
ceil
(
diff
/
(
1000
*
60
*
60
*
24
));
if
(
days
<=
3
)
return
'
text-red-600 dark:text-red-400
'
;
if
(
days
<=
7
)
return
'
text-orange-600 dark:text-orange-400
'
;
return
'
text-gray-500 dark:text-dark-400
'
;
}
function
toggleTooltip
()
{
tooltipOpen
.
value
=
!
tooltipOpen
.
value
;
}
function
closeTooltip
()
{
tooltipOpen
.
value
=
false
;
}
function
handleClickOutside
(
event
:
MouseEvent
)
{
if
(
containerRef
.
value
&&
!
containerRef
.
value
.
contains
(
event
.
target
as
Node
))
{
closeTooltip
();
}
}
async
function
loadSubscriptions
()
{
try
{
loading
.
value
=
true
;
activeSubscriptions
.
value
=
await
subscriptionsAPI
.
getActiveSubscriptions
();
}
catch
(
error
)
{
console
.
error
(
'
Failed to load subscriptions:
'
,
error
);
activeSubscriptions
.
value
=
[];
}
finally
{
loading
.
value
=
false
;
}
}
onMounted
(()
=>
{
document
.
addEventListener
(
'
click
'
,
handleClickOutside
);
loadSubscriptions
();
}
);
onBeforeUnmount
(()
=>
{
document
.
removeEventListener
(
'
click
'
,
handleClickOutside
);
}
);
// Refresh subscriptions periodically (every 5 minutes)
let
refreshInterval
:
ReturnType
<
typeof
setInterval
>
|
null
=
null
;
onMounted
(()
=>
{
refreshInterval
=
setInterval
(
loadSubscriptions
,
5
*
60
*
1000
);
}
);
onBeforeUnmount
(()
=>
{
if
(
refreshInterval
)
{
clearInterval
(
refreshInterval
);
}
}
);
<
/script
>
<
style
scoped
>
.
dropdown
-
enter
-
active
,
.
dropdown
-
leave
-
active
{
transition
:
all
0.2
s
ease
;
}
.
dropdown
-
enter
-
from
,
.
dropdown
-
leave
-
to
{
opacity
:
0
;
transform
:
scale
(
0.95
)
translateY
(
-
4
px
);
}
<
/style
>
Prev
1
…
4
5
6
7
8
9
10
11
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