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
fd29fe11
Commit
fd29fe11
authored
Jan 05, 2026
by
shaw
Browse files
Merge PR #149: Fix/multi platform - 安全稳定性修复和前端架构优化
parents
07d80f76
eef12cb9
Changes
70
Expand all
Show whitespace changes
Inline
Side-by-side
frontend/src/components/admin/account/AccountStatsModal.vue
0 → 100644
View file @
fd29fe11
This diff is collapsed.
Click to expand it.
frontend/src/components/admin/account/AccountTableActions.vue
0 → 100644
View file @
fd29fe11
<
template
>
<div
class=
"flex max-w-full flex-wrap justify-end gap-3"
>
<button
@
click=
"$emit('refresh')"
:disabled=
"loading"
class=
"btn btn-secondary flex-shrink-0"
><svg
:class=
"['h-5 w-5', loading ? 'animate-spin' : '']"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
><path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/></svg></button>
<button
@
click=
"$emit('sync')"
class=
"btn btn-secondary flex-shrink-0"
>
{{
t
(
'
admin.accounts.syncFromCrs
'
)
}}
</button>
<button
@
click=
"$emit('create')"
class=
"btn btn-primary flex-shrink-0"
>
{{
t
(
'
admin.accounts.createAccount
'
)
}}
</button>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
useI18n
}
from
'
vue-i18n
'
;
defineProps
([
'
loading
'
]);
defineEmits
([
'
refresh
'
,
'
sync
'
,
'
create
'
]);
const
{
t
}
=
useI18n
()
</
script
>
frontend/src/components/admin/account/AccountTableFilters.vue
0 → 100644
View file @
fd29fe11
<
template
>
<div
class=
"flex flex-wrap items-start gap-3"
>
<div
class=
"min-w-0 flex-1"
>
<SearchInput
:model-value=
"searchQuery"
:placeholder=
"t('admin.accounts.searchAccounts')"
@
update:model-value=
"$emit('update:searchQuery', $event)"
@
search=
"$emit('change')"
/>
</div>
<div
class=
"flex flex-wrap items-center gap-3"
>
<Select
v-model=
"filters.platform"
class=
"w-40 flex-shrink-0"
:options=
"pOpts"
@
change=
"$emit('change')"
/>
<Select
v-model=
"filters.status"
class=
"w-40 flex-shrink-0"
:options=
"sOpts"
@
change=
"$emit('change')"
/>
</div>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
computed
}
from
'
vue
'
;
import
{
useI18n
}
from
'
vue-i18n
'
;
import
Select
from
'
@/components/common/Select.vue
'
;
import
SearchInput
from
'
@/components/common/SearchInput.vue
'
defineProps
([
'
searchQuery
'
,
'
filters
'
]);
defineEmits
([
'
update:searchQuery
'
,
'
change
'
]);
const
{
t
}
=
useI18n
()
const
pOpts
=
computed
(()
=>
[{
value
:
''
,
label
:
t
(
'
admin.accounts.allPlatforms
'
)
},
{
value
:
'
openai
'
,
label
:
'
OpenAI
'
},
{
value
:
'
anthropic
'
,
label
:
'
Anthropic
'
},
{
value
:
'
gemini
'
,
label
:
'
Gemini
'
}])
const
sOpts
=
computed
(()
=>
[{
value
:
''
,
label
:
t
(
'
admin.accounts.allStatus
'
)
},
{
value
:
'
active
'
,
label
:
t
(
'
admin.accounts.status.active
'
)
},
{
value
:
'
error
'
,
label
:
t
(
'
admin.accounts.status.error
'
)
}])
</
script
>
frontend/src/components/admin/account/AccountTestModal.vue
0 → 100644
View file @
fd29fe11
<
template
>
<BaseDialog
:show=
"show"
:title=
"t('admin.accounts.testAccountConnection')"
width=
"normal"
@
close=
"handleClose"
>
<div
class=
"space-y-4"
>
<!-- Account Info Card -->
<div
v-if=
"account"
class=
"flex items-center justify-between rounded-xl border border-gray-200 bg-gradient-to-r from-gray-50 to-gray-100 p-3 dark:border-dark-500 dark:from-dark-700 dark:to-dark-600"
>
<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-primary-500 to-primary-600"
>
<svg
class=
"h-5 w-5 text-white"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M5.121 17.804A13.937 13.937 0 0112 16c2.5 0 4.847.655 6.879 1.804M15 10a3 3 0 11-6 0 3 3 0 016 0zm6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<div>
<div
class=
"font-semibold text-gray-900 dark:text-gray-100"
>
{{
account
.
name
}}
</div>
<div
class=
"flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400"
>
<span
class=
"rounded bg-gray-200 px-1.5 py-0.5 text-[10px] font-medium uppercase dark:bg-dark-500"
>
{{
account
.
type
}}
</span>
<span>
{{
t
(
'
admin.accounts.account
'
)
}}
</span>
</div>
</div>
</div>
<span
:class=
"[
'rounded-full px-2.5 py-1 text-xs font-semibold',
account.status === 'active'
? 'bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-400'
: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'
]"
>
{{
account
.
status
}}
</span>
</div>
<div
class=
"space-y-1.5"
>
<label
class=
"text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{
t
(
'
admin.accounts.selectTestModel
'
)
}}
</label>
<Select
v-model=
"selectedModelId"
:options=
"availableModels"
:disabled=
"loadingModels || status === 'connecting'"
value-key=
"id"
label-key=
"display_name"
:placeholder=
"loadingModels ? t('common.loading') + '...' : t('admin.accounts.selectTestModel')"
/>
</div>
<!-- Terminal Output -->
<div
class=
"group relative"
>
<div
ref=
"terminalRef"
class=
"max-h-[240px] min-h-[120px] overflow-y-auto rounded-xl border border-gray-700 bg-gray-900 p-4 font-mono text-sm dark:border-gray-800 dark:bg-black"
>
<!-- Status Line -->
<div
v-if=
"status === 'idle'"
class=
"flex items-center gap-2 text-gray-500"
>
<svg
class=
"h-4 w-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
<span>
{{
t
(
'
admin.accounts.readyToTest
'
)
}}
</span>
</div>
<div
v-else-if=
"status === 'connecting'"
class=
"flex items-center gap-2 text-yellow-400"
>
<svg
class=
"h-4 w-4 animate-spin"
fill=
"none"
viewBox=
"0 0 24 24"
>
<circle
class=
"opacity-25"
cx=
"12"
cy=
"12"
r=
"10"
stroke=
"currentColor"
stroke-width=
"4"
></circle>
<path
class=
"opacity-75"
fill=
"currentColor"
d=
"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<span>
{{
t
(
'
admin.accounts.connectingToApi
'
)
}}
</span>
</div>
<!-- Output Lines -->
<div
v-for=
"(line, index) in outputLines"
:key=
"index"
:class=
"line.class"
>
{{
line
.
text
}}
</div>
<!-- Streaming Content -->
<div
v-if=
"streamingContent"
class=
"text-green-400"
>
{{
streamingContent
}}
<span
class=
"animate-pulse"
>
_
</span>
</div>
<!-- Result Status -->
<div
v-if=
"status === 'success'"
class=
"mt-3 flex items-center gap-2 border-t border-gray-700 pt-3 text-green-400"
>
<svg
class=
"h-4 w-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>
{{
t
(
'
admin.accounts.testCompleted
'
)
}}
</span>
</div>
<div
v-else-if=
"status === 'error'"
class=
"mt-3 flex items-center gap-2 border-t border-gray-700 pt-3 text-red-400"
>
<svg
class=
"h-4 w-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>
{{
errorMessage
}}
</span>
</div>
</div>
<!-- Copy Button -->
<button
v-if=
"outputLines.length > 0"
@
click=
"copyOutput"
class=
"absolute right-2 top-2 rounded-lg bg-gray-800/80 p-1.5 text-gray-400 opacity-0 transition-all hover:bg-gray-700 hover:text-white group-hover:opacity-100"
:title=
"t('admin.accounts.copyOutput')"
>
<svg
class=
"h-4 w-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg>
</button>
</div>
<!-- Test Info -->
<div
class=
"flex items-center justify-between px-1 text-xs text-gray-500 dark:text-gray-400"
>
<div
class=
"flex items-center gap-3"
>
<span
class=
"flex items-center gap-1"
>
<svg
class=
"h-3.5 w-3.5"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"
/>
</svg>
{{
t
(
'
admin.accounts.testModel
'
)
}}
</span>
</div>
<span
class=
"flex items-center gap-1"
>
<svg
class=
"h-3.5 w-3.5"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"
/>
</svg>
{{
t
(
'
admin.accounts.testPrompt
'
)
}}
</span>
</div>
</div>
<template
#footer
>
<div
class=
"flex justify-end gap-3"
>
<button
@
click=
"handleClose"
class=
"rounded-lg bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-300 dark:hover:bg-dark-500"
:disabled=
"status === 'connecting'"
>
{{
t
(
'
common.close
'
)
}}
</button>
<button
@
click=
"startTest"
:disabled=
"status === 'connecting' || !selectedModelId"
:class=
"[
'flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-all',
status === 'connecting' || !selectedModelId
? 'cursor-not-allowed bg-primary-400 text-white'
: status === 'success'
? 'bg-green-500 text-white hover:bg-green-600'
: status === 'error'
? 'bg-orange-500 text-white hover:bg-orange-600'
: 'bg-primary-500 text-white hover:bg-primary-600'
]"
>
<svg
v-if=
"status === 'connecting'"
class=
"h-4 w-4 animate-spin"
fill=
"none"
viewBox=
"0 0 24 24"
>
<circle
class=
"opacity-25"
cx=
"12"
cy=
"12"
r=
"10"
stroke=
"currentColor"
stroke-width=
"4"
></circle>
<path
class=
"opacity-75"
fill=
"currentColor"
d=
"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<svg
v-else-if=
"status === 'idle'"
class=
"h-4 w-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"
/>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<svg
v-else
class=
"h-4 w-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
<span>
{{
status
===
'
connecting
'
?
t
(
'
admin.accounts.testing
'
)
:
status
===
'
idle
'
?
t
(
'
admin.accounts.startTest
'
)
:
t
(
'
admin.accounts.retry
'
)
}}
</span>
</button>
</div>
</
template
>
</BaseDialog>
</template>
<
script
setup
lang=
"ts"
>
import
{
ref
,
watch
,
nextTick
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
import
Select
from
'
@/components/common/Select.vue
'
import
{
useClipboard
}
from
'
@/composables/useClipboard
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
type
{
Account
,
ClaudeModel
}
from
'
@/types
'
const
{
t
}
=
useI18n
()
const
{
copyToClipboard
}
=
useClipboard
()
interface
OutputLine
{
text
:
string
class
:
string
}
const
props
=
defineProps
<
{
show
:
boolean
account
:
Account
|
null
}
>
()
const
emit
=
defineEmits
<
{
(
e
:
'
close
'
):
void
}
>
()
const
terminalRef
=
ref
<
HTMLElement
|
null
>
(
null
)
const
status
=
ref
<
'
idle
'
|
'
connecting
'
|
'
success
'
|
'
error
'
>
(
'
idle
'
)
const
outputLines
=
ref
<
OutputLine
[]
>
([])
const
streamingContent
=
ref
(
''
)
const
errorMessage
=
ref
(
''
)
const
availableModels
=
ref
<
ClaudeModel
[]
>
([])
const
selectedModelId
=
ref
(
''
)
const
loadingModels
=
ref
(
false
)
let
eventSource
:
EventSource
|
null
=
null
// Load available models when modal opens
watch
(
()
=>
props
.
show
,
async
(
newVal
)
=>
{
if
(
newVal
&&
props
.
account
)
{
resetState
()
await
loadAvailableModels
()
}
else
{
closeEventSource
()
}
}
)
const
loadAvailableModels
=
async
()
=>
{
if
(
!
props
.
account
)
return
loadingModels
.
value
=
true
selectedModelId
.
value
=
''
// Reset selection before loading
try
{
availableModels
.
value
=
await
adminAPI
.
accounts
.
getAvailableModels
(
props
.
account
.
id
)
// Default selection by platform
if
(
availableModels
.
value
.
length
>
0
)
{
if
(
props
.
account
.
platform
===
'
gemini
'
)
{
const
preferred
=
availableModels
.
value
.
find
((
m
)
=>
m
.
id
===
'
gemini-2.5-pro
'
)
||
availableModels
.
value
.
find
((
m
)
=>
m
.
id
===
'
gemini-3-pro
'
)
selectedModelId
.
value
=
preferred
?.
id
||
availableModels
.
value
[
0
].
id
}
else
{
// Try to select Sonnet as default, otherwise use first model
const
sonnetModel
=
availableModels
.
value
.
find
((
m
)
=>
m
.
id
.
includes
(
'
sonnet
'
))
selectedModelId
.
value
=
sonnetModel
?.
id
||
availableModels
.
value
[
0
].
id
}
}
}
catch
(
error
)
{
console
.
error
(
'
Failed to load available models:
'
,
error
)
// Fallback to empty list
availableModels
.
value
=
[]
selectedModelId
.
value
=
''
}
finally
{
loadingModels
.
value
=
false
}
}
const
resetState
=
()
=>
{
status
.
value
=
'
idle
'
outputLines
.
value
=
[]
streamingContent
.
value
=
''
errorMessage
.
value
=
''
}
const
handleClose
=
()
=>
{
// 防止在连接测试进行中关闭对话框
if
(
status
.
value
===
'
connecting
'
)
{
return
}
closeEventSource
()
emit
(
'
close
'
)
}
const
closeEventSource
=
()
=>
{
if
(
eventSource
)
{
eventSource
.
close
()
eventSource
=
null
}
}
const
addLine
=
(
text
:
string
,
className
:
string
=
'
text-gray-300
'
)
=>
{
outputLines
.
value
.
push
({
text
,
class
:
className
})
scrollToBottom
()
}
const
scrollToBottom
=
async
()
=>
{
await
nextTick
()
if
(
terminalRef
.
value
)
{
terminalRef
.
value
.
scrollTop
=
terminalRef
.
value
.
scrollHeight
}
}
const
startTest
=
async
()
=>
{
if
(
!
props
.
account
||
!
selectedModelId
.
value
)
return
resetState
()
status
.
value
=
'
connecting
'
addLine
(
t
(
'
admin.accounts.startingTestForAccount
'
,
{
name
:
props
.
account
.
name
}),
'
text-blue-400
'
)
addLine
(
t
(
'
admin.accounts.testAccountTypeLabel
'
,
{
type
:
props
.
account
.
type
}),
'
text-gray-400
'
)
addLine
(
''
,
'
text-gray-300
'
)
closeEventSource
()
try
{
// Create EventSource for SSE
const
url
=
`/api/v1/admin/accounts/
${
props
.
account
.
id
}
/test`
// Use fetch with streaming for SSE since EventSource doesn't support POST
const
response
=
await
fetch
(
url
,
{
method
:
'
POST
'
,
headers
:
{
Authorization
:
`Bearer
${
localStorage
.
getItem
(
'
auth_token
'
)}
`
,
'
Content-Type
'
:
'
application/json
'
},
body
:
JSON
.
stringify
({
model_id
:
selectedModelId
.
value
})
})
if
(
!
response
.
ok
)
{
throw
new
Error
(
`HTTP error! status:
${
response
.
status
}
`
)
}
const
reader
=
response
.
body
?.
getReader
()
if
(
!
reader
)
{
throw
new
Error
(
'
No response body
'
)
}
const
decoder
=
new
TextDecoder
()
let
buffer
=
''
while
(
true
)
{
const
{
done
,
value
}
=
await
reader
.
read
()
if
(
done
)
break
buffer
+=
decoder
.
decode
(
value
,
{
stream
:
true
})
const
lines
=
buffer
.
split
(
'
\n
'
)
buffer
=
lines
.
pop
()
||
''
for
(
const
line
of
lines
)
{
if
(
line
.
startsWith
(
'
data:
'
))
{
const
jsonStr
=
line
.
slice
(
6
).
trim
()
if
(
jsonStr
)
{
try
{
const
event
=
JSON
.
parse
(
jsonStr
)
handleEvent
(
event
)
}
catch
(
e
)
{
console
.
error
(
'
Failed to parse SSE event:
'
,
e
)
}
}
}
}
}
}
catch
(
error
:
any
)
{
status
.
value
=
'
error
'
errorMessage
.
value
=
error
.
message
||
'
Unknown error
'
addLine
(
`Error:
${
errorMessage
.
value
}
`
,
'
text-red-400
'
)
}
}
const
handleEvent
=
(
event
:
{
type
:
string
text
?:
string
model
?:
string
success
?:
boolean
error
?:
string
})
=>
{
switch
(
event
.
type
)
{
case
'
test_start
'
:
addLine
(
t
(
'
admin.accounts.connectedToApi
'
),
'
text-green-400
'
)
if
(
event
.
model
)
{
addLine
(
t
(
'
admin.accounts.usingModel
'
,
{
model
:
event
.
model
}),
'
text-cyan-400
'
)
}
addLine
(
t
(
'
admin.accounts.sendingTestMessage
'
),
'
text-gray-400
'
)
addLine
(
''
,
'
text-gray-300
'
)
addLine
(
t
(
'
admin.accounts.response
'
),
'
text-yellow-400
'
)
break
case
'
content
'
:
if
(
event
.
text
)
{
streamingContent
.
value
+=
event
.
text
scrollToBottom
()
}
break
case
'
test_complete
'
:
// Move streaming content to output lines
if
(
streamingContent
.
value
)
{
addLine
(
streamingContent
.
value
,
'
text-green-300
'
)
streamingContent
.
value
=
''
}
if
(
event
.
success
)
{
status
.
value
=
'
success
'
}
else
{
status
.
value
=
'
error
'
errorMessage
.
value
=
event
.
error
||
'
Test failed
'
}
break
case
'
error
'
:
status
.
value
=
'
error
'
errorMessage
.
value
=
event
.
error
||
'
Unknown error
'
if
(
streamingContent
.
value
)
{
addLine
(
streamingContent
.
value
,
'
text-green-300
'
)
streamingContent
.
value
=
''
}
break
}
}
const
copyOutput
=
()
=>
{
const
text
=
outputLines
.
value
.
map
((
l
)
=>
l
.
text
).
join
(
'
\n
'
)
copyToClipboard
(
text
,
t
(
'
admin.accounts.outputCopied
'
))
}
</
script
>
frontend/src/components/admin/account/ReAuthAccountModal.vue
0 → 100644
View file @
fd29fe11
This diff is collapsed.
Click to expand it.
frontend/src/components/admin/usage/UsageExportProgress.vue
0 → 100644
View file @
fd29fe11
<
template
>
<ExportProgressDialog
:show=
"show"
:progress=
"progress"
:current=
"current"
:total=
"total"
:estimated-time=
"estimatedTime"
@
cancel=
"$emit('cancel')"
/>
</
template
>
<
script
setup
lang=
"ts"
>
import
ExportProgressDialog
from
'
@/components/common/ExportProgressDialog.vue
'
defineProps
<
{
show
:
boolean
,
progress
:
number
,
current
:
number
,
total
:
number
,
estimatedTime
:
string
}
>
()
defineEmits
([
'
cancel
'
])
</
script
>
\ No newline at end of file
frontend/src/components/admin/usage/UsageFilters.vue
0 → 100644
View file @
fd29fe11
<
template
>
<div
class=
"card p-6"
>
<!-- Toolbar: left filters (multi-line) + right actions -->
<div
class=
"flex flex-wrap items-end justify-between gap-4"
>
<!-- Left: filters (allowed to wrap to multiple rows) -->
<div
class=
"flex flex-1 flex-wrap items-end gap-4"
>
<!-- User Search -->
<div
ref=
"userSearchRef"
class=
"usage-filter-dropdown relative w-full sm:w-auto sm:min-w-[240px]"
>
<label
class=
"input-label"
>
{{
t
(
'
admin.usage.userFilter
'
)
}}
</label>
<input
v-model=
"userKeyword"
type=
"text"
class=
"input pr-8"
:placeholder=
"t('admin.usage.searchUserPlaceholder')"
@
input=
"debounceUserSearch"
@
focus=
"showUserDropdown = true"
/>
<button
v-if=
"filters.user_id"
type=
"button"
@
click=
"clearUser"
class=
"absolute right-2 top-9 text-gray-400"
aria-label=
"Clear user filter"
>
✕
</button>
<div
v-if=
"showUserDropdown && (userResults.length > 0 || userKeyword)"
class=
"absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-lg border bg-white shadow-lg dark:bg-gray-800"
>
<button
v-for=
"u in userResults"
:key=
"u.id"
type=
"button"
@
click=
"selectUser(u)"
class=
"w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700"
>
<span>
{{
u
.
email
}}
</span>
<span
class=
"ml-2 text-xs text-gray-400"
>
#
{{
u
.
id
}}
</span>
</button>
</div>
</div>
<!-- API Key Search -->
<div
ref=
"apiKeySearchRef"
class=
"usage-filter-dropdown relative w-full sm:w-auto sm:min-w-[240px]"
>
<label
class=
"input-label"
>
{{
t
(
'
usage.apiKeyFilter
'
)
}}
</label>
<input
v-model=
"apiKeyKeyword"
type=
"text"
class=
"input pr-8"
:placeholder=
"t('admin.usage.searchApiKeyPlaceholder')"
@
input=
"debounceApiKeySearch"
@
focus=
"showApiKeyDropdown = true"
/>
<button
v-if=
"filters.api_key_id"
type=
"button"
@
click=
"onClearApiKey"
class=
"absolute right-2 top-9 text-gray-400"
aria-label=
"Clear API key filter"
>
✕
</button>
<div
v-if=
"showApiKeyDropdown && (apiKeyResults.length > 0 || apiKeyKeyword)"
class=
"absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-lg border bg-white shadow-lg dark:bg-gray-800"
>
<button
v-for=
"k in apiKeyResults"
:key=
"k.id"
type=
"button"
@
click=
"selectApiKey(k)"
class=
"w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700"
>
<span
class=
"truncate"
>
{{
k
.
name
||
`#${k.id
}
`
}}
<
/span
>
<
span
class
=
"
ml-2 text-xs text-gray-400
"
>
#
{{
k
.
id
}}
<
/span
>
<
/button
>
<
/div
>
<
/div
>
<!--
Model
Filter
-->
<
div
class
=
"
w-full sm:w-auto sm:min-w-[220px]
"
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
usage.model
'
)
}}
<
/label
>
<
Select
v
-
model
=
"
filters.model
"
:
options
=
"
modelOptions
"
searchable
@
change
=
"
emitChange
"
/>
<
/div
>
<!--
Account
Filter
-->
<
div
class
=
"
w-full sm:w-auto sm:min-w-[220px]
"
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.usage.account
'
)
}}
<
/label
>
<
Select
v
-
model
=
"
filters.account_id
"
:
options
=
"
accountOptions
"
searchable
@
change
=
"
emitChange
"
/>
<
/div
>
<!--
Stream
Type
Filter
-->
<
div
class
=
"
w-full sm:w-auto sm:min-w-[180px]
"
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
usage.type
'
)
}}
<
/label
>
<
Select
v
-
model
=
"
filters.stream
"
:
options
=
"
streamTypeOptions
"
@
change
=
"
emitChange
"
/>
<
/div
>
<!--
Billing
Type
Filter
-->
<
div
class
=
"
w-full sm:w-auto sm:min-w-[180px]
"
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
usage.billingType
'
)
}}
<
/label
>
<
Select
v
-
model
=
"
filters.billing_type
"
:
options
=
"
billingTypeOptions
"
@
change
=
"
emitChange
"
/>
<
/div
>
<!--
Group
Filter
-->
<
div
class
=
"
w-full sm:w-auto sm:min-w-[200px]
"
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.usage.group
'
)
}}
<
/label
>
<
Select
v
-
model
=
"
filters.group_id
"
:
options
=
"
groupOptions
"
searchable
@
change
=
"
emitChange
"
/>
<
/div
>
<!--
Date
Range
Filter
-->
<
div
class
=
"
w-full sm:w-auto [&_.date-picker-trigger]:w-full
"
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
usage.timeRange
'
)
}}
<
/label
>
<
DateRangePicker
:
start
-
date
=
"
startDate
"
:
end
-
date
=
"
endDate
"
@
update
:
startDate
=
"
updateStartDate
"
@
update
:
endDate
=
"
updateEndDate
"
@
change
=
"
emitChange
"
/>
<
/div
>
<
/div
>
<!--
Right
:
actions
-->
<
div
class
=
"
flex w-full flex-wrap items-center justify-end gap-3 sm:w-auto
"
>
<
button
type
=
"
button
"
@
click
=
"
$emit('reset')
"
class
=
"
btn btn-secondary
"
>
{{
t
(
'
common.reset
'
)
}}
<
/button
>
<
button
type
=
"
button
"
@
click
=
"
$emit('export')
"
:
disabled
=
"
exporting
"
class
=
"
btn btn-primary
"
>
{{
t
(
'
usage.exportExcel
'
)
}}
<
/button
>
<
/div
>
<
/div
>
<
/div
>
<
/template
>
<
script
setup
lang
=
"
ts
"
>
import
{
ref
,
onMounted
,
onUnmounted
,
toRef
,
watch
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
Select
,
{
type
SelectOption
}
from
'
@/components/common/Select.vue
'
import
DateRangePicker
from
'
@/components/common/DateRangePicker.vue
'
import
type
{
SimpleApiKey
,
SimpleUser
}
from
'
@/api/admin/usage
'
type
ModelValue
=
Record
<
string
,
any
>
interface
Props
{
modelValue
:
ModelValue
exporting
:
boolean
startDate
:
string
endDate
:
string
}
const
props
=
defineProps
<
Props
>
()
const
emit
=
defineEmits
([
'
update:modelValue
'
,
'
update:startDate
'
,
'
update:endDate
'
,
'
change
'
,
'
reset
'
,
'
export
'
])
const
{
t
}
=
useI18n
()
const
filters
=
toRef
(
props
,
'
modelValue
'
)
const
userSearchRef
=
ref
<
HTMLElement
|
null
>
(
null
)
const
apiKeySearchRef
=
ref
<
HTMLElement
|
null
>
(
null
)
const
userKeyword
=
ref
(
''
)
const
userResults
=
ref
<
SimpleUser
[]
>
([])
const
showUserDropdown
=
ref
(
false
)
let
userSearchTimeout
:
ReturnType
<
typeof
setTimeout
>
|
null
=
null
const
apiKeyKeyword
=
ref
(
''
)
const
apiKeyResults
=
ref
<
SimpleApiKey
[]
>
([])
const
showApiKeyDropdown
=
ref
(
false
)
let
apiKeySearchTimeout
:
ReturnType
<
typeof
setTimeout
>
|
null
=
null
const
modelOptions
=
ref
<
SelectOption
[]
>
([{
value
:
null
,
label
:
t
(
'
admin.usage.allModels
'
)
}
])
const
groupOptions
=
ref
<
SelectOption
[]
>
([{
value
:
null
,
label
:
t
(
'
admin.usage.allGroups
'
)
}
])
const
accountOptions
=
ref
<
SelectOption
[]
>
([{
value
:
null
,
label
:
t
(
'
admin.usage.allAccounts
'
)
}
])
const
streamTypeOptions
=
ref
<
SelectOption
[]
>
([
{
value
:
null
,
label
:
t
(
'
admin.usage.allTypes
'
)
}
,
{
value
:
true
,
label
:
t
(
'
usage.stream
'
)
}
,
{
value
:
false
,
label
:
t
(
'
usage.sync
'
)
}
])
const
billingTypeOptions
=
ref
<
SelectOption
[]
>
([
{
value
:
null
,
label
:
t
(
'
admin.usage.allBillingTypes
'
)
}
,
{
value
:
1
,
label
:
t
(
'
usage.subscription
'
)
}
,
{
value
:
0
,
label
:
t
(
'
usage.balance
'
)
}
])
const
emitChange
=
()
=>
emit
(
'
change
'
)
const
updateStartDate
=
(
value
:
string
)
=>
{
emit
(
'
update:startDate
'
,
value
)
filters
.
value
.
start_date
=
value
}
const
updateEndDate
=
(
value
:
string
)
=>
{
emit
(
'
update:endDate
'
,
value
)
filters
.
value
.
end_date
=
value
}
const
debounceUserSearch
=
()
=>
{
if
(
userSearchTimeout
)
clearTimeout
(
userSearchTimeout
)
userSearchTimeout
=
setTimeout
(
async
()
=>
{
if
(
!
userKeyword
.
value
)
{
userResults
.
value
=
[]
return
}
try
{
userResults
.
value
=
await
adminAPI
.
usage
.
searchUsers
(
userKeyword
.
value
)
}
catch
{
userResults
.
value
=
[]
}
}
,
300
)
}
const
debounceApiKeySearch
=
()
=>
{
if
(
apiKeySearchTimeout
)
clearTimeout
(
apiKeySearchTimeout
)
apiKeySearchTimeout
=
setTimeout
(
async
()
=>
{
if
(
!
apiKeyKeyword
.
value
)
{
apiKeyResults
.
value
=
[]
return
}
try
{
apiKeyResults
.
value
=
await
adminAPI
.
usage
.
searchApiKeys
(
filters
.
value
.
user_id
,
apiKeyKeyword
.
value
)
}
catch
{
apiKeyResults
.
value
=
[]
}
}
,
300
)
}
const
selectUser
=
(
u
:
SimpleUser
)
=>
{
userKeyword
.
value
=
u
.
email
showUserDropdown
.
value
=
false
filters
.
value
.
user_id
=
u
.
id
clearApiKey
()
emitChange
()
}
const
clearUser
=
()
=>
{
userKeyword
.
value
=
''
userResults
.
value
=
[]
showUserDropdown
.
value
=
false
filters
.
value
.
user_id
=
undefined
clearApiKey
()
emitChange
()
}
const
selectApiKey
=
(
k
:
SimpleApiKey
)
=>
{
apiKeyKeyword
.
value
=
k
.
name
||
String
(
k
.
id
)
showApiKeyDropdown
.
value
=
false
filters
.
value
.
api_key_id
=
k
.
id
emitChange
()
}
const
clearApiKey
=
()
=>
{
apiKeyKeyword
.
value
=
''
apiKeyResults
.
value
=
[]
showApiKeyDropdown
.
value
=
false
filters
.
value
.
api_key_id
=
undefined
}
const
onClearApiKey
=
()
=>
{
clearApiKey
()
emitChange
()
}
const
onDocumentClick
=
(
e
:
MouseEvent
)
=>
{
const
target
=
e
.
target
as
Node
|
null
if
(
!
target
)
return
const
clickedInsideUser
=
userSearchRef
.
value
?.
contains
(
target
)
??
false
const
clickedInsideApiKey
=
apiKeySearchRef
.
value
?.
contains
(
target
)
??
false
if
(
!
clickedInsideUser
)
showUserDropdown
.
value
=
false
if
(
!
clickedInsideApiKey
)
showApiKeyDropdown
.
value
=
false
}
watch
(
()
=>
props
.
startDate
,
(
value
)
=>
{
filters
.
value
.
start_date
=
value
}
,
{
immediate
:
true
}
)
watch
(
()
=>
props
.
endDate
,
(
value
)
=>
{
filters
.
value
.
end_date
=
value
}
,
{
immediate
:
true
}
)
watch
(
()
=>
filters
.
value
.
user_id
,
(
userId
)
=>
{
if
(
!
userId
)
{
userKeyword
.
value
=
''
userResults
.
value
=
[]
}
}
)
watch
(
()
=>
filters
.
value
.
api_key_id
,
(
apiKeyId
)
=>
{
if
(
!
apiKeyId
)
{
apiKeyKeyword
.
value
=
''
apiKeyResults
.
value
=
[]
}
}
)
onMounted
(
async
()
=>
{
document
.
addEventListener
(
'
click
'
,
onDocumentClick
)
try
{
const
[
gs
,
ms
,
as
]
=
await
Promise
.
all
([
adminAPI
.
groups
.
list
(
1
,
1000
),
adminAPI
.
dashboard
.
getModelStats
({
start_date
:
props
.
startDate
,
end_date
:
props
.
endDate
}
),
adminAPI
.
accounts
.
list
(
1
,
1000
)
])
groupOptions
.
value
.
push
(...
gs
.
items
.
map
((
g
:
any
)
=>
({
value
:
g
.
id
,
label
:
g
.
name
}
)))
accountOptions
.
value
.
push
(...
as
.
items
.
map
((
a
:
any
)
=>
({
value
:
a
.
id
,
label
:
a
.
name
}
)))
const
uniqueModels
=
new
Set
<
string
>
()
ms
.
models
?.
forEach
((
s
:
any
)
=>
s
.
model
&&
uniqueModels
.
add
(
s
.
model
))
modelOptions
.
value
.
push
(
...
Array
.
from
(
uniqueModels
)
.
sort
()
.
map
((
m
)
=>
({
value
:
m
,
label
:
m
}
))
)
}
catch
{
// Ignore filter option loading errors (page still usable)
}
}
)
onUnmounted
(()
=>
{
document
.
removeEventListener
(
'
click
'
,
onDocumentClick
)
}
)
<
/script
>
frontend/src/components/admin/usage/UsageStatsCards.vue
0 → 100644
View file @
fd29fe11
<
template
>
<div
class=
"grid grid-cols-2 gap-4 lg:grid-cols-4"
>
<div
class=
"card p-4 flex items-center gap-3"
>
<div
class=
"rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30 text-blue-600"
><svg
class=
"h-5 w-5"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
><path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/></svg></div>
<div><p
class=
"text-xs font-medium text-gray-500"
>
{{
t
(
'
usage.totalRequests
'
)
}}
</p><p
class=
"text-xl font-bold"
>
{{
stats
?.
total_requests
?.
toLocaleString
()
||
'
0
'
}}
</p></div>
</div>
<div
class=
"card p-4 flex items-center gap-3"
>
<div
class=
"rounded-lg bg-amber-100 p-2 dark:bg-amber-900/30 text-amber-600"
><svg
class=
"h-5 w-5"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
><path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"m21 7.5-9-5.25L3 7.5m18 0-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9"
/></svg></div>
<div><p
class=
"text-xs font-medium text-gray-500"
>
{{
t
(
'
usage.totalTokens
'
)
}}
</p><p
class=
"text-xl font-bold"
>
{{
formatTokens
(
stats
?.
total_tokens
||
0
)
}}
</p></div>
</div>
<div
class=
"card p-4 flex items-center gap-3"
>
<div
class=
"rounded-lg bg-green-100 p-2 dark:bg-green-900/30 text-green-600"
><svg
class=
"h-5 w-5"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
><path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/></svg></div>
<div><p
class=
"text-xs font-medium text-gray-500"
>
{{
t
(
'
usage.totalCost
'
)
}}
</p><p
class=
"text-xl font-bold text-green-600"
>
$
{{
(
stats
?.
total_actual_cost
||
0
).
toFixed
(
4
)
}}
</p></div>
</div>
<div
class=
"card p-4 flex items-center gap-3"
>
<div
class=
"rounded-lg bg-purple-100 p-2 dark:bg-purple-900/30 text-purple-600"
><svg
class=
"h-5 w-5"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
><path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
/></svg></div>
<div><p
class=
"text-xs font-medium text-gray-500"
>
{{
t
(
'
usage.avgDuration
'
)
}}
</p><p
class=
"text-xl font-bold"
>
{{
formatDuration
(
stats
?.
average_duration_ms
||
0
)
}}
</p></div>
</div>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
useI18n
}
from
'
vue-i18n
'
;
import
type
{
AdminUsageStatsResponse
}
from
'
@/api/admin/usage
'
defineProps
<
{
stats
:
AdminUsageStatsResponse
|
null
}
>
();
const
{
t
}
=
useI18n
()
const
formatDuration
=
(
ms
:
number
)
=>
ms
<
1000
?
`
${
ms
.
toFixed
(
0
)}
ms`
:
`
${(
ms
/
1000
).
toFixed
(
2
)}
s`
const
formatTokens
=
(
v
:
number
)
=>
{
if
(
v
>=
1
e9
)
return
(
v
/
1
e9
).
toFixed
(
2
)
+
'
B
'
;
if
(
v
>=
1
e6
)
return
(
v
/
1
e6
).
toFixed
(
2
)
+
'
M
'
;
if
(
v
>=
1
e3
)
return
(
v
/
1
e3
).
toFixed
(
2
)
+
'
K
'
;
return
v
.
toLocaleString
()
}
</
script
>
\ No newline at end of file
frontend/src/components/admin/usage/UsageTable.vue
0 → 100644
View file @
fd29fe11
<
template
>
<div
class=
"card overflow-hidden"
><div
class=
"overflow-auto"
>
<DataTable
:columns=
"cols"
:data=
"data"
:loading=
"loading"
>
<template
#cell-user
="
{ row }">
<div
class=
"text-sm"
><span
class=
"font-medium text-gray-900 dark:text-white"
>
{{
row
.
user
?.
email
||
'
-
'
}}
</span><span
class=
"ml-1 text-xs text-gray-400"
>
#
{{
row
.
user_id
}}
</span></div></
template
>
<
template
#cell-model=
"{ value }"
><span
class=
"font-medium"
>
{{
value
}}
</span></
template
>
<
template
#cell-tokens=
"{ row }"
><div
class=
"text-sm"
>
In:
{{
row
.
input_tokens
.
toLocaleString
()
}}
/ Out:
{{
row
.
output_tokens
.
toLocaleString
()
}}
</div></
template
>
<
template
#cell-cost=
"{ row }"
><span
class=
"font-medium text-green-600"
>
$
{{
row
.
actual_cost
.
toFixed
(
6
)
}}
</span></
template
>
<
template
#cell-created_at=
"{ value }"
><span
class=
"text-sm text-gray-500"
>
{{
formatDateTime
(
value
)
}}
</span></
template
>
<
template
#empty
><EmptyState
:message=
"t('usage.noRecords')"
/></
template
>
</DataTable>
</div></div>
</template>
<
script
setup
lang=
"ts"
>
import
{
computed
}
from
'
vue
'
;
import
{
useI18n
}
from
'
vue-i18n
'
;
import
{
formatDateTime
}
from
'
@/utils/format
'
;
import
DataTable
from
'
@/components/common/DataTable.vue
'
;
import
EmptyState
from
'
@/components/common/EmptyState.vue
'
defineProps
([
'
data
'
,
'
loading
'
]);
const
{
t
}
=
useI18n
()
const
cols
=
computed
(()
=>
[
{
key
:
'
user
'
,
label
:
t
(
'
admin.usage.user
'
)
},
{
key
:
'
model
'
,
label
:
t
(
'
usage.model
'
),
sortable
:
true
},
{
key
:
'
tokens
'
,
label
:
t
(
'
usage.tokens
'
)
},
{
key
:
'
cost
'
,
label
:
t
(
'
usage.cost
'
)
},
{
key
:
'
created_at
'
,
label
:
t
(
'
usage.time
'
),
sortable
:
true
}
])
</
script
>
\ No newline at end of file
frontend/src/components/admin/user/UserAllowedGroupsModal.vue
0 → 100644
View file @
fd29fe11
<
template
>
<BaseDialog
:show=
"show"
:title=
"t('admin.users.setAllowedGroups')"
width=
"normal"
@
close=
"$emit('close')"
>
<div
v-if=
"user"
class=
"space-y-4"
>
<div
class=
"flex items-center gap-3 rounded-xl bg-gray-50 p-4 dark:bg-dark-700"
>
<div
class=
"flex h-10 w-10 items-center justify-center rounded-full bg-primary-100"
>
<span
class=
"text-lg font-medium text-primary-700"
>
{{
user
.
email
.
charAt
(
0
).
toUpperCase
()
}}
</span>
</div>
<p
class=
"font-medium text-gray-900 dark:text-white"
>
{{
user
.
email
}}
</p>
</div>
<div
v-if=
"loading"
class=
"flex justify-center py-8"
><svg
class=
"h-8 w-8 animate-spin text-primary-500"
fill=
"none"
viewBox=
"0 0 24 24"
><circle
class=
"opacity-25"
cx=
"12"
cy=
"12"
r=
"10"
stroke=
"currentColor"
stroke-width=
"4"
></circle><path
class=
"opacity-75"
fill=
"currentColor"
d=
"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path></svg></div>
<div
v-else
>
<p
class=
"mb-3 text-sm text-gray-600"
>
{{
t
(
'
admin.users.allowedGroupsHint
'
)
}}
</p>
<div
class=
"max-h-64 space-y-2 overflow-y-auto"
>
<label
v-for=
"group in groups"
:key=
"group.id"
class=
"flex cursor-pointer items-center gap-3 rounded-lg border border-gray-200 p-3 hover:bg-gray-50"
:class=
"
{'border-primary-300 bg-primary-50': selectedIds.includes(group.id)}">
<input
type=
"checkbox"
:value=
"group.id"
v-model=
"selectedIds"
class=
"h-4 w-4 rounded border-gray-300 text-primary-600"
/>
<div
class=
"flex-1"
><p
class=
"font-medium text-gray-900"
>
{{
group
.
name
}}
</p><p
v-if=
"group.description"
class=
"truncate text-sm text-gray-500"
>
{{
group
.
description
}}
</p></div>
<div
class=
"flex items-center gap-2"
><span
class=
"badge badge-gray text-xs"
>
{{
group
.
platform
}}
</span><span
v-if=
"group.is_exclusive"
class=
"badge badge-purple text-xs"
>
{{
t
(
'
admin.groups.exclusive
'
)
}}
</span></div>
</label>
</div>
<div
class=
"mt-4 border-t border-gray-200 pt-4"
>
<label
class=
"flex cursor-pointer items-center gap-3 rounded-lg border border-gray-200 p-3 hover:bg-gray-50"
:class=
"
{'border-green-300 bg-green-50': selectedIds.length === 0}">
<input
type=
"radio"
:checked=
"selectedIds.length === 0"
@
change=
"selectedIds = []"
class=
"h-4 w-4 border-gray-300 text-green-600"
/>
<div
class=
"flex-1"
><p
class=
"font-medium text-gray-900"
>
{{
t
(
'
admin.users.allowAllGroups
'
)
}}
</p><p
class=
"text-sm text-gray-500"
>
{{
t
(
'
admin.users.allowAllGroupsHint
'
)
}}
</p></div>
</label>
</div>
</div>
</div>
<template
#footer
>
<div
class=
"flex justify-end gap-3"
>
<button
@
click=
"$emit('close')"
class=
"btn btn-secondary"
>
{{
t
(
'
common.cancel
'
)
}}
</button>
<button
@
click=
"handleSave"
:disabled=
"submitting"
class=
"btn btn-primary"
>
{{
submitting
?
t
(
'
common.saving
'
)
:
t
(
'
common.save
'
)
}}
</button>
</div>
</
template
>
</BaseDialog>
</template>
<
script
setup
lang=
"ts"
>
import
{
ref
,
watch
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
type
{
User
,
Group
}
from
'
@/types
'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
const
props
=
defineProps
<
{
show
:
boolean
,
user
:
User
|
null
}
>
()
const
emit
=
defineEmits
([
'
close
'
,
'
success
'
]);
const
{
t
}
=
useI18n
();
const
appStore
=
useAppStore
()
const
groups
=
ref
<
Group
[]
>
([]);
const
selectedIds
=
ref
<
number
[]
>
([]);
const
loading
=
ref
(
false
);
const
submitting
=
ref
(
false
)
watch
(()
=>
props
.
show
,
(
v
)
=>
{
if
(
v
&&
props
.
user
)
{
selectedIds
.
value
=
props
.
user
.
allowed_groups
||
[];
load
()
}
})
const
load
=
async
()
=>
{
loading
.
value
=
true
;
try
{
const
res
=
await
adminAPI
.
groups
.
list
(
1
,
1000
);
groups
.
value
=
res
.
items
.
filter
(
g
=>
g
.
subscription_type
===
'
standard
'
&&
g
.
status
===
'
active
'
)
}
catch
{}
finally
{
loading
.
value
=
false
}
}
const
handleSave
=
async
()
=>
{
if
(
!
props
.
user
)
return
;
submitting
.
value
=
true
try
{
await
adminAPI
.
users
.
update
(
props
.
user
.
id
,
{
allowed_groups
:
selectedIds
.
value
.
length
>
0
?
selectedIds
.
value
:
null
})
appStore
.
showSuccess
(
t
(
'
admin.users.allowedGroupsUpdated
'
));
emit
(
'
success
'
);
emit
(
'
close
'
)
}
catch
{}
finally
{
submitting
.
value
=
false
}
}
</
script
>
\ No newline at end of file
frontend/src/components/admin/user/UserApiKeysModal.vue
0 → 100644
View file @
fd29fe11
<
template
>
<BaseDialog
:show=
"show"
:title=
"t('admin.users.userApiKeys')"
width=
"wide"
@
close=
"$emit('close')"
>
<div
v-if=
"user"
class=
"space-y-4"
>
<div
class=
"flex items-center gap-3 rounded-xl bg-gray-50 p-4 dark:bg-dark-700"
>
<div
class=
"flex h-10 w-10 items-center justify-center rounded-full bg-primary-100 dark:bg-primary-900/30"
>
<span
class=
"text-lg font-medium text-primary-700 dark:text-primary-300"
>
{{
user
.
email
.
charAt
(
0
).
toUpperCase
()
}}
</span>
</div>
<div><p
class=
"font-medium text-gray-900 dark:text-white"
>
{{
user
.
email
}}
</p><p
class=
"text-sm text-gray-500 dark:text-dark-400"
>
{{
user
.
username
}}
</p></div>
</div>
<div
v-if=
"loading"
class=
"flex justify-center py-8"
><svg
class=
"h-8 w-8 animate-spin text-primary-500"
fill=
"none"
viewBox=
"0 0 24 24"
><circle
class=
"opacity-25"
cx=
"12"
cy=
"12"
r=
"10"
stroke=
"currentColor"
stroke-width=
"4"
></circle><path
class=
"opacity-75"
fill=
"currentColor"
d=
"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path></svg></div>
<div
v-else-if=
"apiKeys.length === 0"
class=
"py-8 text-center"
><p
class=
"text-sm text-gray-500"
>
{{
t
(
'
admin.users.noApiKeys
'
)
}}
</p></div>
<div
v-else
class=
"max-h-96 space-y-3 overflow-y-auto"
>
<div
v-for=
"key in apiKeys"
:key=
"key.id"
class=
"rounded-xl border border-gray-200 bg-white p-4 dark:border-dark-600 dark:bg-dark-800"
>
<div
class=
"flex items-start justify-between"
>
<div
class=
"min-w-0 flex-1"
>
<div
class=
"mb-1 flex items-center gap-2"
><span
class=
"font-medium text-gray-900 dark:text-white"
>
{{
key
.
name
}}
</span><span
:class=
"['badge text-xs', key.status === 'active' ? 'badge-success' : 'badge-danger']"
>
{{
key
.
status
}}
</span></div>
<p
class=
"truncate font-mono text-sm text-gray-500"
>
{{
key
.
key
.
substring
(
0
,
20
)
}}
...
{{
key
.
key
.
substring
(
key
.
key
.
length
-
8
)
}}
</p>
</div>
</div>
<div
class=
"mt-3 flex flex-wrap gap-4 text-xs text-gray-500"
>
<div
class=
"flex items-center gap-1"
><span>
{{
t
(
'
admin.users.group
'
)
}}
:
{{
key
.
group
?.
name
||
t
(
'
admin.users.none
'
)
}}
</span></div>
<div
class=
"flex items-center gap-1"
><span>
{{
t
(
'
admin.users.columns.created
'
)
}}
:
{{
formatDateTime
(
key
.
created_at
)
}}
</span></div>
</div>
</div>
</div>
</div>
</BaseDialog>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
ref
,
watch
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
{
formatDateTime
}
from
'
@/utils/format
'
import
type
{
User
,
ApiKey
}
from
'
@/types
'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
const
props
=
defineProps
<
{
show
:
boolean
,
user
:
User
|
null
}
>
()
defineEmits
([
'
close
'
]);
const
{
t
}
=
useI18n
()
const
apiKeys
=
ref
<
ApiKey
[]
>
([]);
const
loading
=
ref
(
false
)
watch
(()
=>
props
.
show
,
(
v
)
=>
{
if
(
v
&&
props
.
user
)
load
()
})
const
load
=
async
()
=>
{
if
(
!
props
.
user
)
return
;
loading
.
value
=
true
try
{
const
res
=
await
adminAPI
.
users
.
getUserApiKeys
(
props
.
user
.
id
);
apiKeys
.
value
=
res
.
items
||
[]
}
catch
{}
finally
{
loading
.
value
=
false
}
}
</
script
>
\ No newline at end of file
frontend/src/components/admin/user/UserBalanceModal.vue
0 → 100644
View file @
fd29fe11
<
template
>
<BaseDialog
:show=
"show"
:title=
"operation === 'add' ? t('admin.users.deposit') : t('admin.users.withdraw')"
width=
"narrow"
@
close=
"$emit('close')"
>
<form
v-if=
"user"
id=
"balance-form"
@
submit.prevent=
"handleBalanceSubmit"
class=
"space-y-5"
>
<div
class=
"flex items-center gap-3 rounded-xl bg-gray-50 p-4 dark:bg-dark-700"
>
<div
class=
"flex h-10 w-10 items-center justify-center rounded-full bg-primary-100"
><span
class=
"text-lg font-medium text-primary-700"
>
{{
user
.
email
.
charAt
(
0
).
toUpperCase
()
}}
</span></div>
<div
class=
"flex-1"
><p
class=
"font-medium text-gray-900"
>
{{
user
.
email
}}
</p><p
class=
"text-sm text-gray-500"
>
{{
t
(
'
admin.users.currentBalance
'
)
}}
: $
{{
user
.
balance
.
toFixed
(
2
)
}}
</p></div>
</div>
<div>
<label
class=
"input-label"
>
{{
operation
===
'
add
'
?
t
(
'
admin.users.depositAmount
'
)
:
t
(
'
admin.users.withdrawAmount
'
)
}}
</label>
<div
class=
"relative"
><div
class=
"absolute left-3 top-1/2 -translate-y-1/2 font-medium text-gray-500"
>
$
</div><input
v-model.number=
"form.amount"
type=
"number"
step=
"0.01"
min=
"0.01"
required
class=
"input pl-8"
/></div>
</div>
<div><label
class=
"input-label"
>
{{
t
(
'
admin.users.notes
'
)
}}
</label><textarea
v-model=
"form.notes"
rows=
"3"
class=
"input"
></textarea></div>
<div
v-if=
"form.amount > 0"
class=
"rounded-xl border border-blue-200 bg-blue-50 p-4"
><div
class=
"flex items-center justify-between text-sm"
><span>
{{
t
(
'
admin.users.newBalance
'
)
}}
:
</span><span
class=
"font-bold"
>
$
{{
calculateNewBalance
().
toFixed
(
2
)
}}
</span></div></div>
</form>
<template
#footer
>
<div
class=
"flex justify-end gap-3"
>
<button
@
click=
"$emit('close')"
class=
"btn btn-secondary"
>
{{
t
(
'
common.cancel
'
)
}}
</button>
<button
type=
"submit"
form=
"balance-form"
:disabled=
"submitting || !form.amount"
class=
"btn"
:class=
"operation === 'add' ? 'bg-emerald-600 text-white' : 'btn-danger'"
>
{{
submitting
?
t
(
'
common.saving
'
)
:
t
(
'
common.confirm
'
)
}}
</button>
</div>
</
template
>
</BaseDialog>
</template>
<
script
setup
lang=
"ts"
>
import
{
reactive
,
ref
,
watch
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
type
{
User
}
from
'
@/types
'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
const
props
=
defineProps
<
{
show
:
boolean
,
user
:
User
|
null
,
operation
:
'
add
'
|
'
subtract
'
}
>
()
const
emit
=
defineEmits
([
'
close
'
,
'
success
'
]);
const
{
t
}
=
useI18n
();
const
appStore
=
useAppStore
()
const
submitting
=
ref
(
false
);
const
form
=
reactive
({
amount
:
0
,
notes
:
''
})
watch
(()
=>
props
.
show
,
(
v
)
=>
{
if
(
v
)
{
form
.
amount
=
0
;
form
.
notes
=
''
}
})
const
calculateNewBalance
=
()
=>
(
props
.
user
?
(
props
.
operation
===
'
add
'
?
props
.
user
.
balance
+
form
.
amount
:
props
.
user
.
balance
-
form
.
amount
)
:
0
)
const
handleBalanceSubmit
=
async
()
=>
{
if
(
!
props
.
user
)
return
;
submitting
.
value
=
true
try
{
await
adminAPI
.
users
.
updateBalance
(
props
.
user
.
id
,
form
.
amount
,
props
.
operation
,
form
.
notes
)
appStore
.
showSuccess
(
t
(
'
common.success
'
));
emit
(
'
success
'
);
emit
(
'
close
'
)
}
catch
{}
finally
{
submitting
.
value
=
false
}
}
</
script
>
\ No newline at end of file
frontend/src/components/admin/user/UserCreateModal.vue
0 → 100644
View file @
fd29fe11
<
template
>
<BaseDialog
:show=
"show"
:title=
"t('admin.users.createUser')"
width=
"normal"
@
close=
"$emit('close')"
>
<form
id=
"create-user-form"
@
submit.prevent=
"submit"
class=
"space-y-5"
>
<div>
<label
class=
"input-label"
>
{{
t
(
'
admin.users.email
'
)
}}
</label>
<input
v-model=
"form.email"
type=
"email"
required
class=
"input"
:placeholder=
"t('admin.users.enterEmail')"
/>
</div>
<div>
<label
class=
"input-label"
>
{{
t
(
'
admin.users.password
'
)
}}
</label>
<div
class=
"flex gap-2"
>
<div
class=
"relative flex-1"
>
<input
v-model=
"form.password"
type=
"text"
required
class=
"input pr-10"
:placeholder=
"t('admin.users.enterPassword')"
/>
</div>
<button
type=
"button"
@
click=
"generateRandomPassword"
class=
"btn btn-secondary px-3"
>
<svg
class=
"h-5 w-5"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
><path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"1.5"
d=
"M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/></svg>
</button>
</div>
</div>
<div>
<label
class=
"input-label"
>
{{
t
(
'
admin.users.username
'
)
}}
</label>
<input
v-model=
"form.username"
type=
"text"
class=
"input"
:placeholder=
"t('admin.users.enterUsername')"
/>
</div>
<div
class=
"grid grid-cols-2 gap-4"
>
<div>
<label
class=
"input-label"
>
{{
t
(
'
admin.users.columns.balance
'
)
}}
</label>
<input
v-model.number=
"form.balance"
type=
"number"
step=
"any"
class=
"input"
/>
</div>
<div>
<label
class=
"input-label"
>
{{
t
(
'
admin.users.columns.concurrency
'
)
}}
</label>
<input
v-model.number=
"form.concurrency"
type=
"number"
class=
"input"
/>
</div>
</div>
</form>
<template
#footer
>
<div
class=
"flex justify-end gap-3"
>
<button
@
click=
"$emit('close')"
type=
"button"
class=
"btn btn-secondary"
>
{{
t
(
'
common.cancel
'
)
}}
</button>
<button
type=
"submit"
form=
"create-user-form"
:disabled=
"loading"
class=
"btn btn-primary"
>
{{
loading
?
t
(
'
admin.users.creating
'
)
:
t
(
'
common.create
'
)
}}
</button>
</div>
</
template
>
</BaseDialog>
</template>
<
script
setup
lang=
"ts"
>
import
{
reactive
,
watch
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
;
import
{
adminAPI
}
from
'
@/api/admin
'
import
{
useForm
}
from
'
@/composables/useForm
'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
const
props
=
defineProps
<
{
show
:
boolean
}
>
()
const
emit
=
defineEmits
([
'
close
'
,
'
success
'
]);
const
{
t
}
=
useI18n
()
const
form
=
reactive
({
email
:
''
,
password
:
''
,
username
:
''
,
notes
:
''
,
balance
:
0
,
concurrency
:
1
})
const
{
loading
,
submit
}
=
useForm
({
form
,
submitFn
:
async
(
data
)
=>
{
await
adminAPI
.
users
.
create
(
data
)
emit
(
'
success
'
);
emit
(
'
close
'
)
},
successMsg
:
t
(
'
admin.users.userCreated
'
)
})
watch
(()
=>
props
.
show
,
(
v
)
=>
{
if
(
v
)
Object
.
assign
(
form
,
{
email
:
''
,
password
:
''
,
username
:
''
,
notes
:
''
,
balance
:
0
,
concurrency
:
1
})
})
const
generateRandomPassword
=
()
=>
{
const
chars
=
'
ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%^&*
'
let
p
=
''
;
for
(
let
i
=
0
;
i
<
16
;
i
++
)
p
+=
chars
.
charAt
(
Math
.
floor
(
Math
.
random
()
*
chars
.
length
))
form
.
password
=
p
}
</
script
>
frontend/src/components/admin/user/UserEditModal.vue
0 → 100644
View file @
fd29fe11
<
template
>
<BaseDialog
:show=
"show"
:title=
"t('admin.users.editUser')"
width=
"normal"
@
close=
"$emit('close')"
>
<form
v-if=
"user"
id=
"edit-user-form"
@
submit.prevent=
"handleUpdateUser"
class=
"space-y-5"
>
<div>
<label
class=
"input-label"
>
{{
t
(
'
admin.users.email
'
)
}}
</label>
<input
v-model=
"form.email"
type=
"email"
class=
"input"
/>
</div>
<div>
<label
class=
"input-label"
>
{{
t
(
'
admin.users.password
'
)
}}
</label>
<div
class=
"flex gap-2"
>
<div
class=
"relative flex-1"
>
<input
v-model=
"form.password"
type=
"text"
class=
"input pr-10"
:placeholder=
"t('admin.users.enterNewPassword')"
/>
<button
v-if=
"form.password"
type=
"button"
@
click=
"copyPassword"
class=
"absolute right-2 top-1/2 -translate-y-1/2 rounded-lg p-1 transition-colors hover:bg-gray-100 dark:hover:bg-dark-700"
:class=
"passwordCopied ? 'text-green-500' : 'text-gray-400'"
>
<svg
v-if=
"passwordCopied"
class=
"h-4 w-4"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"2"
><path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M5 13l4 4L19 7"
/></svg>
<svg
v-else
class=
"h-4 w-4"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
><path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184"
/></svg>
</button>
</div>
<button
type=
"button"
@
click=
"generatePassword"
class=
"btn btn-secondary px-3"
>
<svg
class=
"h-5 w-5"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
><path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/></svg>
</button>
</div>
</div>
<div>
<label
class=
"input-label"
>
{{
t
(
'
admin.users.username
'
)
}}
</label>
<input
v-model=
"form.username"
type=
"text"
class=
"input"
/>
</div>
<div>
<label
class=
"input-label"
>
{{
t
(
'
admin.users.notes
'
)
}}
</label>
<textarea
v-model=
"form.notes"
rows=
"3"
class=
"input"
></textarea>
</div>
<div>
<label
class=
"input-label"
>
{{
t
(
'
admin.users.columns.concurrency
'
)
}}
</label>
<input
v-model.number=
"form.concurrency"
type=
"number"
class=
"input"
/>
</div>
<UserAttributeForm
v-model=
"form.customAttributes"
:user-id=
"user?.id"
/>
</form>
<template
#footer
>
<div
class=
"flex justify-end gap-3"
>
<button
@
click=
"$emit('close')"
type=
"button"
class=
"btn btn-secondary"
>
{{
t
(
'
common.cancel
'
)
}}
</button>
<button
type=
"submit"
form=
"edit-user-form"
:disabled=
"submitting"
class=
"btn btn-primary"
>
{{
submitting
?
t
(
'
admin.users.updating
'
)
:
t
(
'
common.update
'
)
}}
</button>
</div>
</
template
>
</BaseDialog>
</template>
<
script
setup
lang=
"ts"
>
import
{
ref
,
reactive
,
watch
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
useClipboard
}
from
'
@/composables/useClipboard
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
type
{
User
,
UserAttributeValuesMap
}
from
'
@/types
'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
import
UserAttributeForm
from
'
@/components/user/UserAttributeForm.vue
'
const
props
=
defineProps
<
{
show
:
boolean
,
user
:
User
|
null
}
>
()
const
emit
=
defineEmits
([
'
close
'
,
'
success
'
])
const
{
t
}
=
useI18n
();
const
appStore
=
useAppStore
();
const
{
copyToClipboard
}
=
useClipboard
()
const
submitting
=
ref
(
false
);
const
passwordCopied
=
ref
(
false
)
const
form
=
reactive
({
email
:
''
,
password
:
''
,
username
:
''
,
notes
:
''
,
concurrency
:
1
,
customAttributes
:
{}
as
UserAttributeValuesMap
})
watch
(()
=>
props
.
user
,
(
u
)
=>
{
if
(
u
)
{
Object
.
assign
(
form
,
{
email
:
u
.
email
,
password
:
''
,
username
:
u
.
username
||
''
,
notes
:
u
.
notes
||
''
,
concurrency
:
u
.
concurrency
,
customAttributes
:
{}
})
passwordCopied
.
value
=
false
}
},
{
immediate
:
true
})
const
generatePassword
=
()
=>
{
const
chars
=
'
ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%^&*
'
let
p
=
''
;
for
(
let
i
=
0
;
i
<
16
;
i
++
)
p
+=
chars
.
charAt
(
Math
.
floor
(
Math
.
random
()
*
chars
.
length
))
form
.
password
=
p
}
const
copyPassword
=
async
()
=>
{
if
(
form
.
password
&&
await
copyToClipboard
(
form
.
password
,
t
(
'
admin.users.passwordCopied
'
)))
{
passwordCopied
.
value
=
true
;
setTimeout
(()
=>
passwordCopied
.
value
=
false
,
2000
)
}
}
const
handleUpdateUser
=
async
()
=>
{
if
(
!
props
.
user
)
return
submitting
.
value
=
true
try
{
const
data
:
any
=
{
email
:
form
.
email
,
username
:
form
.
username
,
notes
:
form
.
notes
,
concurrency
:
form
.
concurrency
}
if
(
form
.
password
.
trim
())
data
.
password
=
form
.
password
.
trim
()
await
adminAPI
.
users
.
update
(
props
.
user
.
id
,
data
)
if
(
Object
.
keys
(
form
.
customAttributes
).
length
>
0
)
await
adminAPI
.
userAttributes
.
updateUserAttributeValues
(
props
.
user
.
id
,
form
.
customAttributes
)
appStore
.
showSuccess
(
t
(
'
admin.users.userUpdated
'
))
emit
(
'
success
'
);
emit
(
'
close
'
)
}
catch
(
e
:
any
)
{
appStore
.
showError
(
e
.
response
?.
data
?.
detail
||
t
(
'
admin.users.failedToUpdate
'
))
}
finally
{
submitting
.
value
=
false
}
}
</
script
>
\ No newline at end of file
frontend/src/components/common/GroupSelector.vue
View file @
fd29fe11
<
template
>
<div>
<label
class=
"input-label"
>
Groups
<span
class=
"font-normal text-gray-400"
>
(
{{
modelValue
.
length
}
}
selected)
</span>
{{
t
(
'
admin.users.groups
'
)
}}
<span
class=
"font-normal text-gray-400"
>
{{
t
(
'
common.selectedCount
'
,
{
count
:
modelValue
.
length
}
)
}}
<
/span
>
<
/label
>
<
div
class
=
"
grid max-h-32 grid-cols-2 gap-1 overflow-y-auto rounded-lg border border-gray-200 bg-gray-50 p-2 dark:border-dark-600 dark:bg-dark-800
"
...
...
@@ -32,7 +32,7 @@
v
-
if
=
"
filteredGroups.length === 0
"
class
=
"
col-span-2 py-2 text-center text-sm text-gray-500 dark:text-gray-400
"
>
No g
roups
a
vailable
{{
t
(
'
common.noG
roups
A
vailable
'
)
}}
<
/div
>
<
/div
>
<
/div
>
...
...
frontend/src/components/common/Input.vue
0 → 100644
View file @
fd29fe11
<
template
>
<div
class=
"w-full"
>
<label
v-if=
"label"
:for=
"id"
class=
"input-label mb-1.5 block"
>
{{
label
}}
<span
v-if=
"required"
class=
"text-red-500"
>
*
</span>
</label>
<div
class=
"relative"
>
<!-- Prefix Icon Slot -->
<div
v-if=
"$slots.prefix"
class=
"pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3.5 text-gray-400 dark:text-dark-400"
>
<slot
name=
"prefix"
></slot>
</div>
<input
:id=
"id"
ref=
"inputRef"
:type=
"type"
:value=
"modelValue"
:disabled=
"disabled"
:required=
"required"
:placeholder=
"placeholderText"
:autocomplete=
"autocomplete"
:readonly=
"readonly"
:class=
"[
'input w-full transition-all duration-200',
$slots.prefix ? 'pl-11' : '',
$slots.suffix ? 'pr-11' : '',
error ? 'input-error ring-2 ring-red-500/20' : '',
disabled ? 'cursor-not-allowed bg-gray-100 opacity-60 dark:bg-dark-900' : ''
]"
@
input=
"onInput"
@
change=
"$emit('change', ($event.target as HTMLInputElement).value)"
@
blur=
"$emit('blur', $event)"
@
focus=
"$emit('focus', $event)"
@
keyup.enter=
"$emit('enter', $event)"
/>
<!-- Suffix Slot (e.g. Password Toggle or Clear Button) -->
<div
v-if=
"$slots.suffix"
class=
"absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 dark:text-dark-400"
>
<slot
name=
"suffix"
></slot>
</div>
</div>
<!-- Hint / Error Text -->
<p
v-if=
"error"
class=
"input-error-text mt-1.5"
>
{{
error
}}
</p>
<p
v-else-if=
"hint"
class=
"input-hint mt-1.5"
>
{{
hint
}}
</p>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
computed
,
ref
}
from
'
vue
'
interface
Props
{
modelValue
:
string
|
number
|
null
|
undefined
type
?:
string
label
?:
string
placeholder
?:
string
disabled
?:
boolean
required
?:
boolean
readonly
?:
boolean
error
?:
string
hint
?:
string
id
?:
string
autocomplete
?:
string
}
const
props
=
withDefaults
(
defineProps
<
Props
>
(),
{
type
:
'
text
'
,
disabled
:
false
,
required
:
false
,
readonly
:
false
})
const
emit
=
defineEmits
<
{
(
e
:
'
update:modelValue
'
,
value
:
string
):
void
(
e
:
'
change
'
,
value
:
string
):
void
(
e
:
'
blur
'
,
event
:
FocusEvent
):
void
(
e
:
'
focus
'
,
event
:
FocusEvent
):
void
(
e
:
'
enter
'
,
event
:
KeyboardEvent
):
void
}
>
()
const
inputRef
=
ref
<
HTMLInputElement
|
null
>
(
null
)
const
placeholderText
=
computed
(()
=>
props
.
placeholder
||
''
)
const
onInput
=
(
event
:
Event
)
=>
{
const
value
=
(
event
.
target
as
HTMLInputElement
).
value
emit
(
'
update:modelValue
'
,
value
)
}
// Expose focus method
defineExpose
({
focus
:
()
=>
inputRef
.
value
?.
focus
(),
select
:
()
=>
inputRef
.
value
?.
select
()
})
</
script
>
frontend/src/components/common/SearchInput.vue
0 → 100644
View file @
fd29fe11
<
template
>
<div
class=
"relative w-full"
>
<div
class=
"pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3"
>
<svg
class=
"h-5 w-5 text-gray-400"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
/>
</svg>
</div>
<input
:value=
"modelValue"
type=
"text"
class=
"input pl-10"
:placeholder=
"placeholder"
@
input=
"handleInput"
/>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
useDebounceFn
}
from
'
@vueuse/core
'
const
props
=
withDefaults
(
defineProps
<
{
modelValue
:
string
placeholder
?:
string
debounceMs
?:
number
}
>
(),
{
placeholder
:
'
Search...
'
,
debounceMs
:
300
})
const
emit
=
defineEmits
<
{
(
e
:
'
update:modelValue
'
,
value
:
string
):
void
(
e
:
'
search
'
,
value
:
string
):
void
}
>
()
const
debouncedEmitSearch
=
useDebounceFn
((
value
:
string
)
=>
{
emit
(
'
search
'
,
value
)
},
props
.
debounceMs
)
const
handleInput
=
(
event
:
Event
)
=>
{
const
value
=
(
event
.
target
as
HTMLInputElement
).
value
emit
(
'
update:modelValue
'
,
value
)
debouncedEmitSearch
(
value
)
}
</
script
>
frontend/src/components/common/Select.vue
View file @
fd29fe11
This diff is collapsed.
Click to expand it.
frontend/src/components/common/Skeleton.vue
0 → 100644
View file @
fd29fe11
<
template
>
<div
:class=
"[
'animate-pulse bg-gray-200 dark:bg-dark-700',
variant === 'circle' ? 'rounded-full' : 'rounded-lg',
customClass
]"
:style=
"style"
></div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
computed
}
from
'
vue
'
interface
Props
{
variant
?:
'
rect
'
|
'
circle
'
|
'
text
'
width
?:
string
|
number
height
?:
string
|
number
class
?:
string
}
const
props
=
withDefaults
(
defineProps
<
Props
>
(),
{
variant
:
'
rect
'
,
width
:
'
100%
'
})
const
customClass
=
computed
(()
=>
props
.
class
||
''
)
const
style
=
computed
(()
=>
{
const
s
:
Record
<
string
,
string
>
=
{}
if
(
props
.
width
)
{
s
.
width
=
typeof
props
.
width
===
'
number
'
?
`
${
props
.
width
}
px`
:
props
.
width
}
if
(
props
.
height
)
{
s
.
height
=
typeof
props
.
height
===
'
number
'
?
`
${
props
.
height
}
px`
:
props
.
height
}
else
if
(
props
.
variant
===
'
text
'
)
{
s
.
height
=
'
1em
'
s
.
marginTop
=
'
0.25em
'
s
.
marginBottom
=
'
0.25em
'
}
return
s
})
</
script
>
frontend/src/components/common/StatusBadge.vue
0 → 100644
View file @
fd29fe11
<
template
>
<div
class=
"flex items-center gap-1.5"
>
<span
:class=
"[
'inline-block h-2 w-2 rounded-full',
variantClass
]"
></span>
<span
class=
"text-sm text-gray-700 dark:text-gray-300"
>
{{
label
}}
</span>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
computed
}
from
'
vue
'
const
props
=
defineProps
<
{
status
:
string
label
:
string
}
>
()
const
variantClass
=
computed
(()
=>
{
switch
(
props
.
status
)
{
case
'
active
'
:
case
'
success
'
:
return
'
bg-green-500
'
case
'
disabled
'
:
case
'
inactive
'
:
case
'
warning
'
:
return
'
bg-yellow-500
'
case
'
error
'
:
case
'
danger
'
:
return
'
bg-red-500
'
default
:
return
'
bg-gray-400
'
}
})
</
script
>
Prev
1
2
3
4
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