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
Show whitespace changes
Inline
Side-by-side
frontend/src/components/common/TextArea.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"
>
<textarea
:id=
"id"
ref=
"textAreaRef"
:value=
"modelValue"
:disabled=
"disabled"
:required=
"required"
:placeholder=
"placeholderText"
:readonly=
"readonly"
:rows=
"rows"
:class=
"[
'input w-full min-h-[80px] transition-all duration-200 resize-y',
error ? 'input-error ring-2 ring-red-500/20' : '',
disabled ? 'cursor-not-allowed bg-gray-100 opacity-60 dark:bg-dark-900' : ''
]"
@
input=
"onInput"
@
change=
"$emit('change', ($event.target as HTMLTextAreaElement).value)"
@
blur=
"$emit('blur', $event)"
@
focus=
"$emit('focus', $event)"
></textarea>
</div>
<!-- Hint / Error Text -->
<p
v-if=
"error"
class=
"input-error-text mt-1.5"
>
{{
error
}}
</p>
<p
v-else-if=
"hint"
class=
"input-hint mt-1.5"
>
{{
hint
}}
</p>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
computed
,
ref
}
from
'
vue
'
interface
Props
{
modelValue
:
string
|
null
|
undefined
label
?:
string
placeholder
?:
string
disabled
?:
boolean
required
?:
boolean
readonly
?:
boolean
error
?:
string
hint
?:
string
id
?:
string
rows
?:
number
|
string
}
const
props
=
withDefaults
(
defineProps
<
Props
>
(),
{
disabled
:
false
,
required
:
false
,
readonly
:
false
,
rows
:
3
})
const
emit
=
defineEmits
<
{
(
e
:
'
update:modelValue
'
,
value
:
string
):
void
(
e
:
'
change
'
,
value
:
string
):
void
(
e
:
'
blur
'
,
event
:
FocusEvent
):
void
(
e
:
'
focus
'
,
event
:
FocusEvent
):
void
}
>
()
const
textAreaRef
=
ref
<
HTMLTextAreaElement
|
null
>
(
null
)
const
placeholderText
=
computed
(()
=>
props
.
placeholder
||
''
)
const
onInput
=
(
event
:
Event
)
=>
{
const
value
=
(
event
.
target
as
HTMLTextAreaElement
).
value
emit
(
'
update:modelValue
'
,
value
)
}
// Expose focus method
defineExpose
({
focus
:
()
=>
textAreaRef
.
value
?.
focus
(),
select
:
()
=>
textAreaRef
.
value
?.
select
()
})
</
script
>
frontend/src/components/user/UserAttributeForm.vue
View file @
fd29fe11
...
...
@@ -52,18 +52,12 @@
/>
<!-- Select -->
<
s
elect
<
S
elect
v-else-if=
"attr.type === 'select'"
v-model=
"localValues[attr.id]"
:required=
"attr.required"
class=
"input"
:options=
"attr.options || []"
@
change=
"emitChange"
>
<option
value=
""
>
{{
t
(
'
common.selectOption
'
)
}}
</option>
<option
v-for=
"opt in attr.options"
:key=
"opt.value"
:value=
"opt.value"
>
{{
opt
.
label
}}
</option>
</select>
/>
<!-- Multi-Select (Checkboxes) -->
<div
v-else-if=
"attr.type === 'multi_select'"
class=
"space-y-2"
>
...
...
@@ -99,11 +93,9 @@
<
script
setup
lang=
"ts"
>
import
{
ref
,
watch
,
onMounted
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
type
{
UserAttributeDefinition
,
UserAttributeValuesMap
}
from
'
@/types
'
const
{
t
}
=
useI18n
()
import
Select
from
'
@/components/common/Select.vue
'
interface
Props
{
userId
?:
number
...
...
frontend/src/components/user/UserAttributesConfigModal.vue
View file @
fd29fe11
...
...
@@ -142,11 +142,10 @@
<!--
Type
-->
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.users.attributes.type
'
)
}}
<
/label
>
<
select
v
-
model
=
"
form.type
"
class
=
"
input
"
required
>
<
option
v
-
for
=
"
type in attributeTypes
"
:
key
=
"
type
"
:
value
=
"
type
"
>
{{
t
(
`admin.users.attributes.types.${type
}
`
)
}}
<
/option
>
<
/select
>
<
Select
v
-
model
=
"
form.type
"
:
options
=
"
attributeTypes.map(type => ({ value: type, label: t(`admin.users.attributes.types.${type
}
`)
}
))
"
/>
<
/div
>
<!--
Options
(
for
select
/
multi_select
)
-->
...
...
@@ -257,6 +256,7 @@ import { adminAPI } from '@/api/admin'
import
type
{
UserAttributeDefinition
,
UserAttributeType
,
UserAttributeOption
}
from
'
@/types
'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
import
ConfirmDialog
from
'
@/components/common/ConfirmDialog.vue
'
import
Select
from
'
@/components/common/Select.vue
'
const
{
t
}
=
useI18n
()
const
appStore
=
useAppStore
()
...
...
frontend/src/components/user/dashboard/UserDashboardCharts.vue
0 → 100644
View file @
fd29fe11
<
template
>
<div
class=
"space-y-6"
>
<!-- Date Range Filter -->
<div
class=
"card p-4"
>
<div
class=
"flex flex-wrap items-center gap-4"
>
<div
class=
"flex items-center gap-2"
>
<span
class=
"text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{
t
(
'
dashboard.timeRange
'
)
}}
:
</span>
<DateRangePicker
:start-date=
"startDate"
:end-date=
"endDate"
@
update:startDate=
"$emit('update:startDate', $event)"
@
update:endDate=
"$emit('update:endDate', $event)"
@
change=
"$emit('dateRangeChange', $event)"
/>
</div>
<div
class=
"ml-auto flex items-center gap-2"
>
<span
class=
"text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{
t
(
'
dashboard.granularity
'
)
}}
:
</span>
<div
class=
"w-28"
>
<Select
:model-value=
"granularity"
:options=
"[
{value:'day', label:t('dashboard.day')}, {value:'hour', label:t('dashboard.hour')}]" @update:model-value="$emit('update:granularity', $event)" @change="$emit('granularityChange')" />
</div>
</div>
</div>
</div>
<!-- Charts Grid -->
<div
class=
"grid grid-cols-1 gap-6 lg:grid-cols-2"
>
<!-- Model Distribution Chart -->
<div
class=
"card relative overflow-hidden p-4"
>
<div
v-if=
"loading"
class=
"absolute inset-0 z-10 flex items-center justify-center bg-white/50 backdrop-blur-sm dark:bg-dark-800/50"
>
<LoadingSpinner
size=
"md"
/>
</div>
<h3
class=
"mb-4 text-sm font-semibold text-gray-900 dark:text-white"
>
{{
t
(
'
dashboard.modelDistribution
'
)
}}
</h3>
<div
class=
"flex items-center gap-6"
>
<div
class=
"h-48 w-48"
>
<Doughnut
v-if=
"modelData"
:data=
"modelData"
:options=
"doughnutOptions"
/>
<div
v-else
class=
"flex h-full items-center justify-center text-sm text-gray-500 dark:text-gray-400"
>
{{
t
(
'
dashboard.noDataAvailable
'
)
}}
</div>
</div>
<div
class=
"max-h-48 flex-1 overflow-y-auto"
>
<table
class=
"w-full text-xs"
>
<thead>
<tr
class=
"text-gray-500 dark:text-gray-400"
>
<th
class=
"pb-2 text-left"
>
{{
t
(
'
dashboard.model
'
)
}}
</th>
<th
class=
"pb-2 text-right"
>
{{
t
(
'
dashboard.requests
'
)
}}
</th>
<th
class=
"pb-2 text-right"
>
{{
t
(
'
dashboard.tokens
'
)
}}
</th>
<th
class=
"pb-2 text-right"
>
{{
t
(
'
dashboard.actual
'
)
}}
</th>
<th
class=
"pb-2 text-right"
>
{{
t
(
'
dashboard.standard
'
)
}}
</th>
</tr>
</thead>
<tbody>
<tr
v-for=
"model in models"
:key=
"model.model"
class=
"border-t border-gray-100 dark:border-gray-700"
>
<td
class=
"max-w-[100px] truncate py-1.5 font-medium text-gray-900 dark:text-white"
:title=
"model.model"
>
{{
model
.
model
}}
</td>
<td
class=
"py-1.5 text-right text-gray-600 dark:text-gray-400"
>
{{
formatNumber
(
model
.
requests
)
}}
</td>
<td
class=
"py-1.5 text-right text-gray-600 dark:text-gray-400"
>
{{
formatTokens
(
model
.
total_tokens
)
}}
</td>
<td
class=
"py-1.5 text-right text-green-600 dark:text-green-400"
>
$
{{
formatCost
(
model
.
actual_cost
)
}}
</td>
<td
class=
"py-1.5 text-right text-gray-400 dark:text-gray-500"
>
$
{{
formatCost
(
model
.
cost
)
}}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Token Usage Trend Chart -->
<div
class=
"card relative overflow-hidden p-4"
>
<div
v-if=
"loading"
class=
"absolute inset-0 z-10 flex items-center justify-center bg-white/50 backdrop-blur-sm dark:bg-dark-800/50"
>
<LoadingSpinner
size=
"md"
/>
</div>
<h3
class=
"mb-4 text-sm font-semibold text-gray-900 dark:text-white"
>
{{
t
(
'
dashboard.tokenUsageTrend
'
)
}}
</h3>
<div
class=
"h-48"
>
<Line
v-if=
"trendData"
:data=
"trendData"
:options=
"lineOptions"
/>
<div
v-else
class=
"flex h-full items-center justify-center text-sm text-gray-500 dark:text-gray-400"
>
{{
t
(
'
dashboard.noDataAvailable
'
)
}}
</div>
</div>
</div>
</div>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
computed
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
LoadingSpinner
from
'
@/components/common/LoadingSpinner.vue
'
import
DateRangePicker
from
'
@/components/common/DateRangePicker.vue
'
import
Select
from
'
@/components/common/Select.vue
'
import
{
Line
,
Doughnut
}
from
'
vue-chartjs
'
import
type
{
TrendDataPoint
,
ModelStat
}
from
'
@/types
'
import
{
formatCostFixed
as
formatCost
,
formatNumberLocaleString
as
formatNumber
,
formatTokensK
as
formatTokens
}
from
'
@/utils/format
'
import
{
Chart
as
ChartJS
,
CategoryScale
,
LinearScale
,
PointElement
,
LineElement
,
ArcElement
,
Title
,
Tooltip
,
Legend
,
Filler
}
from
'
chart.js
'
ChartJS
.
register
(
CategoryScale
,
LinearScale
,
PointElement
,
LineElement
,
ArcElement
,
Title
,
Tooltip
,
Legend
,
Filler
)
const
props
=
defineProps
<
{
loading
:
boolean
,
startDate
:
string
,
endDate
:
string
,
granularity
:
string
,
trend
:
TrendDataPoint
[],
models
:
ModelStat
[]
}
>
()
defineEmits
([
'
update:startDate
'
,
'
update:endDate
'
,
'
update:granularity
'
,
'
dateRangeChange
'
,
'
granularityChange
'
])
const
{
t
}
=
useI18n
()
const
modelData
=
computed
(()
=>
!
props
.
models
?.
length
?
null
:
{
labels
:
props
.
models
.
map
((
m
:
ModelStat
)
=>
m
.
model
),
datasets
:
[{
data
:
props
.
models
.
map
((
m
:
ModelStat
)
=>
m
.
total_tokens
),
backgroundColor
:
[
'
#3b82f6
'
,
'
#10b981
'
,
'
#f59e0b
'
,
'
#ef4444
'
,
'
#8b5cf6
'
,
'
#ec4899
'
,
'
#06b6d4
'
,
'
#84cc16
'
]
}]
})
const
trendData
=
computed
(()
=>
!
props
.
trend
?.
length
?
null
:
{
labels
:
props
.
trend
.
map
((
d
:
TrendDataPoint
)
=>
d
.
date
),
datasets
:
[
{
label
:
t
(
'
dashboard.input
'
),
data
:
props
.
trend
.
map
((
d
:
TrendDataPoint
)
=>
d
.
input_tokens
),
borderColor
:
'
#3b82f6
'
,
backgroundColor
:
'
rgba(59, 130, 246, 0.1)
'
,
tension
:
0.3
,
fill
:
true
},
{
label
:
t
(
'
dashboard.output
'
),
data
:
props
.
trend
.
map
((
d
:
TrendDataPoint
)
=>
d
.
output_tokens
),
borderColor
:
'
#10b981
'
,
backgroundColor
:
'
rgba(16, 185, 129, 0.1)
'
,
tension
:
0.3
,
fill
:
true
}
]
})
const
doughnutOptions
=
{
responsive
:
true
,
maintainAspectRatio
:
false
,
plugins
:
{
legend
:
{
display
:
false
},
tooltip
:
{
callbacks
:
{
label
:
(
context
:
any
)
=>
`
${
context
.
label
}
:
${
formatTokens
(
context
.
parsed
)}
tokens`
}
}
}
}
const
lineOptions
=
{
responsive
:
true
,
maintainAspectRatio
:
false
,
plugins
:
{
legend
:
{
display
:
true
,
position
:
'
top
'
as
const
},
tooltip
:
{
callbacks
:
{
label
:
(
context
:
any
)
=>
`
${
context
.
dataset
.
label
}
:
${
formatTokens
(
context
.
parsed
.
y
)}
tokens`
}
}
},
scales
:
{
y
:
{
beginAtZero
:
true
,
ticks
:
{
callback
:
(
value
:
any
)
=>
formatTokens
(
value
)
}
}
}
}
</
script
>
frontend/src/components/user/dashboard/UserDashboardQuickActions.vue
0 → 100644
View file @
fd29fe11
<
template
>
<div
class=
"card"
>
<div
class=
"border-b border-gray-100 px-6 py-4 dark:border-dark-700"
>
<h2
class=
"text-lg font-semibold text-gray-900 dark:text-white"
>
{{
t
(
'
dashboard.quickActions
'
)
}}
</h2>
</div>
<div
class=
"space-y-3 p-4"
>
<button
@
click=
"router.push('/keys')"
class=
"group flex w-full items-center gap-4 rounded-xl bg-gray-50 p-4 text-left transition-all duration-200 hover:bg-gray-100 dark:bg-dark-800/50 dark:hover:bg-dark-800"
>
<div
class=
"flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl bg-primary-100 transition-transform group-hover:scale-105 dark:bg-primary-900/30"
>
<svg
class=
"h-6 w-6 text-primary-600 dark:text-primary-400"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"
/>
</svg>
</div>
<div
class=
"min-w-0 flex-1"
>
<p
class=
"text-sm font-medium text-gray-900 dark:text-white"
>
{{
t
(
'
dashboard.createApiKey
'
)
}}
</p>
<p
class=
"text-xs text-gray-500 dark:text-dark-400"
>
{{
t
(
'
dashboard.generateNewKey
'
)
}}
</p>
</div>
<svg
class=
"h-5 w-5 text-gray-400 transition-colors group-hover:text-primary-500 dark:text-dark-500"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M8.25 4.5l7.5 7.5-7.5 7.5"
/>
</svg>
</button>
<button
@
click=
"router.push('/usage')"
class=
"group flex w-full items-center gap-4 rounded-xl bg-gray-50 p-4 text-left transition-all duration-200 hover:bg-gray-100 dark:bg-dark-800/50 dark:hover:bg-dark-800"
>
<div
class=
"flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl bg-emerald-100 transition-transform group-hover:scale-105 dark:bg-emerald-900/30"
>
<svg
class=
"h-6 w-6 text-emerald-600 dark:text-emerald-400"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z"
/>
</svg>
</div>
<div
class=
"min-w-0 flex-1"
>
<p
class=
"text-sm font-medium text-gray-900 dark:text-white"
>
{{
t
(
'
dashboard.viewUsage
'
)
}}
</p>
<p
class=
"text-xs text-gray-500 dark:text-dark-400"
>
{{
t
(
'
dashboard.checkDetailedLogs
'
)
}}
</p>
</div>
<svg
class=
"h-5 w-5 text-gray-400 transition-colors group-hover:text-emerald-500 dark:text-dark-500"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M8.25 4.5l7.5 7.5-7.5 7.5"
/>
</svg>
</button>
<button
@
click=
"router.push('/redeem')"
class=
"group flex w-full items-center gap-4 rounded-xl bg-gray-50 p-4 text-left transition-all duration-200 hover:bg-gray-100 dark:bg-dark-800/50 dark:hover:bg-dark-800"
>
<div
class=
"flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl bg-amber-100 transition-transform group-hover:scale-105 dark:bg-amber-900/30"
>
<svg
class=
"h-6 w-6 text-amber-600 dark:text-amber-400"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M21 11.25v8.25a1.5 1.5 0 01-1.5 1.5H5.25a1.5 1.5 0 01-1.5-1.5v-8.25M12 4.875A2.625 2.625 0 109.375 7.5H12m0-2.625V7.5m0-2.625A2.625 2.625 0 1114.625 7.5H12m0 0V21m-8.625-9.75h18c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125h-18c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z"
/>
</svg>
</div>
<div
class=
"min-w-0 flex-1"
>
<p
class=
"text-sm font-medium text-gray-900 dark:text-white"
>
{{
t
(
'
dashboard.redeemCode
'
)
}}
</p>
<p
class=
"text-xs text-gray-500 dark:text-dark-400"
>
{{
t
(
'
dashboard.addBalance
'
)
}}
</p>
</div>
<svg
class=
"h-5 w-5 text-gray-400 transition-colors group-hover:text-amber-500 dark:text-dark-500"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M8.25 4.5l7.5 7.5-7.5 7.5"
/>
</svg>
</button>
</div>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
useRouter
}
from
'
vue-router
'
import
{
useI18n
}
from
'
vue-i18n
'
const
router
=
useRouter
()
const
{
t
}
=
useI18n
()
</
script
>
\ No newline at end of file
frontend/src/components/user/dashboard/UserDashboardRecentUsage.vue
0 → 100644
View file @
fd29fe11
<
template
>
<div
class=
"card"
>
<div
class=
"flex items-center justify-between border-b border-gray-100 px-6 py-4 dark:border-dark-700"
>
<h2
class=
"text-lg font-semibold text-gray-900 dark:text-white"
>
{{
t
(
'
dashboard.recentUsage
'
)
}}
</h2>
<span
class=
"badge badge-gray"
>
{{
t
(
'
dashboard.last7Days
'
)
}}
</span>
</div>
<div
class=
"p-6"
>
<div
v-if=
"loading"
class=
"flex items-center justify-center py-12"
>
<LoadingSpinner
size=
"lg"
/>
</div>
<div
v-else-if=
"data.length === 0"
class=
"py-8"
>
<EmptyState
:title=
"t('dashboard.noUsageRecords')"
:description=
"t('dashboard.startUsingApi')"
/>
</div>
<div
v-else
class=
"space-y-3"
>
<div
v-for=
"log in data"
:key=
"log.id"
class=
"flex items-center justify-between rounded-xl bg-gray-50 p-4 transition-colors hover:bg-gray-100 dark:bg-dark-800/50 dark:hover:bg-dark-800"
>
<div
class=
"flex items-center gap-4"
>
<div
class=
"flex h-10 w-10 items-center justify-center rounded-xl bg-primary-100 dark:bg-primary-900/30"
>
<svg
class=
"h-5 w-5 text-primary-600 dark:text-primary-400"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M9.75 3.104v5.714a2.25 2.25 0 01-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 014.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0112 15a9.065 9.065 0 00-6.23.693L5 14.5m14.8.8l1.402 1.402c1.232 1.232.65 3.318-1.067 3.611A48.309 48.309 0 0112 21c-2.773 0-5.491-.235-8.135-.687-1.718-.293-2.3-2.379-1.067-3.61L5 14.5"
/>
</svg>
</div>
<div>
<p
class=
"text-sm font-medium text-gray-900 dark:text-white"
>
{{
log
.
model
}}
</p>
<p
class=
"text-xs text-gray-500 dark:text-dark-400"
>
{{
formatDateTime
(
log
.
created_at
)
}}
</p>
</div>
</div>
<div
class=
"text-right"
>
<p
class=
"text-sm font-semibold"
>
<span
class=
"text-green-600 dark:text-green-400"
:title=
"t('dashboard.actual')"
>
$
{{
formatCost
(
log
.
actual_cost
)
}}
</span>
<span
class=
"font-normal text-gray-400 dark:text-gray-500"
:title=
"t('dashboard.standard')"
>
/ $
{{
formatCost
(
log
.
total_cost
)
}}
</span>
</p>
<p
class=
"text-xs text-gray-500 dark:text-dark-400"
>
{{
(
log
.
input_tokens
+
log
.
output_tokens
).
toLocaleString
()
}}
tokens
</p>
</div>
</div>
<router-link
to=
"/usage"
class=
"flex items-center justify-center gap-2 py-3 text-sm font-medium text-primary-600 transition-colors hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
>
{{
t
(
'
dashboard.viewAllUsage
'
)
}}
<svg
class=
"h-4 w-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3"
/>
</svg>
</router-link>
</div>
</div>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
useI18n
}
from
'
vue-i18n
'
import
LoadingSpinner
from
'
@/components/common/LoadingSpinner.vue
'
import
EmptyState
from
'
@/components/common/EmptyState.vue
'
import
{
formatDateTime
}
from
'
@/utils/format
'
import
type
{
UsageLog
}
from
'
@/types
'
defineProps
<
{
data
:
UsageLog
[]
loading
:
boolean
}
>
()
const
{
t
}
=
useI18n
()
const
formatCost
=
(
c
:
number
)
=>
c
.
toFixed
(
4
)
</
script
>
frontend/src/components/user/dashboard/UserDashboardStats.vue
0 → 100644
View file @
fd29fe11
<
template
>
<!-- Row 1: Core Stats -->
<div
class=
"grid grid-cols-2 gap-4 lg:grid-cols-4"
>
<!-- Balance -->
<div
v-if=
"!isSimple"
class=
"card p-4"
>
<div
class=
"flex items-center gap-3"
>
<div
class=
"rounded-lg bg-emerald-100 p-2 dark:bg-emerald-900/30"
>
<svg
class=
"h-5 w-5 text-emerald-600 dark:text-emerald-400"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M2.25 18.75a60.07 60.07 0 0115.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 013 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 00-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 01-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 003 15h-.75M15 10.5a3 3 0 11-6 0 3 3 0 016 0zm3 0h.008v.008H18V10.5zm-12 0h.008v.008H6V10.5z"
/>
</svg>
</div>
<div>
<p
class=
"text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{
t
(
'
dashboard.balance
'
)
}}
</p>
<p
class=
"text-xl font-bold text-emerald-600 dark:text-emerald-400"
>
$
{{
formatBalance
(
balance
)
}}
</p>
<p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
common.available
'
)
}}
</p>
</div>
</div>
</div>
<!-- API Keys -->
<div
class=
"card p-4"
>
<div
class=
"flex items-center gap-3"
>
<div
class=
"rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30"
>
<svg
class=
"h-5 w-5 text-blue-600 dark:text-blue-400"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"
/>
</svg>
</div>
<div>
<p
class=
"text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{
t
(
'
dashboard.apiKeys
'
)
}}
</p>
<p
class=
"text-xl font-bold text-gray-900 dark:text-white"
>
{{
stats
?.
total_api_keys
||
0
}}
</p>
<p
class=
"text-xs text-green-600 dark:text-green-400"
>
{{
stats
?.
active_api_keys
||
0
}}
{{
t
(
'
common.active
'
)
}}
</p>
</div>
</div>
</div>
<!-- Today Requests -->
<div
class=
"card p-4"
>
<div
class=
"flex items-center gap-3"
>
<div
class=
"rounded-lg bg-green-100 p-2 dark:bg-green-900/30"
>
<svg
class=
"h-5 w-5 text-green-600 dark:text-green-400"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z"
/>
</svg>
</div>
<div>
<p
class=
"text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{
t
(
'
dashboard.todayRequests
'
)
}}
</p>
<p
class=
"text-xl font-bold text-gray-900 dark:text-white"
>
{{
stats
?.
today_requests
||
0
}}
</p>
<p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
common.total
'
)
}}
:
{{
formatNumber
(
stats
?.
total_requests
||
0
)
}}
</p>
</div>
</div>
</div>
<!-- Today Cost -->
<div
class=
"card p-4"
>
<div
class=
"flex items-center gap-3"
>
<div
class=
"rounded-lg bg-purple-100 p-2 dark:bg-purple-900/30"
>
<svg
class=
"h-5 w-5 text-purple-600 dark:text-purple-400"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M12 6v12m-3-2.818l.879.659c1.171.879 3.07.879 4.242 0 1.172-.879 1.172-2.303 0-3.182C13.536 12.219 12.768 12 12 12c-.725 0-1.45-.22-2.003-.659-1.106-.879-1.106-2.303 0-3.182s2.9-.879 4.006 0l.415.33M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<div>
<p
class=
"text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{
t
(
'
dashboard.todayCost
'
)
}}
</p>
<p
class=
"text-xl font-bold text-gray-900 dark:text-white"
>
<span
class=
"text-purple-600 dark:text-purple-400"
:title=
"t('dashboard.actual')"
>
$
{{
formatCost
(
stats
?.
today_actual_cost
||
0
)
}}
</span>
<span
class=
"text-sm font-normal text-gray-400 dark:text-gray-500"
:title=
"t('dashboard.standard')"
>
/ $
{{
formatCost
(
stats
?.
today_cost
||
0
)
}}
</span>
</p>
<p
class=
"text-xs"
>
<span
class=
"text-gray-500 dark:text-gray-400"
>
{{
t
(
'
common.total
'
)
}}
:
</span>
<span
class=
"text-purple-600 dark:text-purple-400"
:title=
"t('dashboard.actual')"
>
$
{{
formatCost
(
stats
?.
total_actual_cost
||
0
)
}}
</span>
<span
class=
"text-gray-400 dark:text-gray-500"
:title=
"t('dashboard.standard')"
>
/ $
{{
formatCost
(
stats
?.
total_cost
||
0
)
}}
</span>
</p>
</div>
</div>
</div>
</div>
<!-- Row 2: Token Stats -->
<div
class=
"grid grid-cols-2 gap-4 lg:grid-cols-4"
>
<!-- Today Tokens -->
<div
class=
"card p-4"
>
<div
class=
"flex items-center gap-3"
>
<div
class=
"rounded-lg bg-amber-100 p-2 dark:bg-amber-900/30"
>
<svg
class=
"h-5 w-5 text-amber-600 dark:text-amber-400"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"m21 7.5-9-5.25L3 7.5m18 0-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9"
/>
</svg>
</div>
<div>
<p
class=
"text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{
t
(
'
dashboard.todayTokens
'
)
}}
</p>
<p
class=
"text-xl font-bold text-gray-900 dark:text-white"
>
{{
formatTokens
(
stats
?.
today_tokens
||
0
)
}}
</p>
<p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
dashboard.input
'
)
}}
:
{{
formatTokens
(
stats
?.
today_input_tokens
||
0
)
}}
/
{{
t
(
'
dashboard.output
'
)
}}
:
{{
formatTokens
(
stats
?.
today_output_tokens
||
0
)
}}
</p>
</div>
</div>
</div>
<!-- Total Tokens -->
<div
class=
"card p-4"
>
<div
class=
"flex items-center gap-3"
>
<div
class=
"rounded-lg bg-indigo-100 p-2 dark:bg-indigo-900/30"
>
<svg
class=
"h-5 w-5 text-indigo-600 dark:text-indigo-400"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125"
/>
</svg>
</div>
<div>
<p
class=
"text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{
t
(
'
dashboard.totalTokens
'
)
}}
</p>
<p
class=
"text-xl font-bold text-gray-900 dark:text-white"
>
{{
formatTokens
(
stats
?.
total_tokens
||
0
)
}}
</p>
<p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
dashboard.input
'
)
}}
:
{{
formatTokens
(
stats
?.
total_input_tokens
||
0
)
}}
/
{{
t
(
'
dashboard.output
'
)
}}
:
{{
formatTokens
(
stats
?.
total_output_tokens
||
0
)
}}
</p>
</div>
</div>
</div>
<!-- Performance (RPM/TPM) -->
<div
class=
"card p-4"
>
<div
class=
"flex items-center gap-3"
>
<div
class=
"rounded-lg bg-violet-100 p-2 dark:bg-violet-900/30"
>
<svg
class=
"h-5 w-5 text-violet-600 dark:text-violet-400"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
</div>
<div
class=
"flex-1"
>
<p
class=
"text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{
t
(
'
dashboard.performance
'
)
}}
</p>
<div
class=
"flex items-baseline gap-2"
>
<p
class=
"text-xl font-bold text-gray-900 dark:text-white"
>
{{
formatTokens
(
stats
?.
rpm
||
0
)
}}
</p>
<span
class=
"text-xs text-gray-500 dark:text-gray-400"
>
RPM
</span>
</div>
<div
class=
"flex items-baseline gap-2"
>
<p
class=
"text-sm font-semibold text-violet-600 dark:text-violet-400"
>
{{
formatTokens
(
stats
?.
tpm
||
0
)
}}
</p>
<span
class=
"text-xs text-gray-500 dark:text-gray-400"
>
TPM
</span>
</div>
</div>
</div>
</div>
<!-- Avg Response Time -->
<div
class=
"card p-4"
>
<div
class=
"flex items-center gap-3"
>
<div
class=
"rounded-lg bg-rose-100 p-2 dark:bg-rose-900/30"
>
<svg
class=
"h-5 w-5 text-rose-600 dark:text-rose-400"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<div>
<p
class=
"text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{
t
(
'
dashboard.avgResponse
'
)
}}
</p>
<p
class=
"text-xl font-bold text-gray-900 dark:text-white"
>
{{
formatDuration
(
stats
?.
average_duration_ms
||
0
)
}}
</p>
<p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
dashboard.averageTime
'
)
}}
</p>
</div>
</div>
</div>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
useI18n
}
from
'
vue-i18n
'
import
type
{
UserDashboardStats
as
UserStatsType
}
from
'
@/api/usage
'
defineProps
<
{
stats
:
UserStatsType
balance
:
number
isSimple
:
boolean
}
>
()
const
{
t
}
=
useI18n
()
const
formatBalance
=
(
b
:
number
)
=>
new
Intl
.
NumberFormat
(
'
en-US
'
,
{
minimumFractionDigits
:
2
,
maximumFractionDigits
:
2
}).
format
(
b
)
const
formatNumber
=
(
n
:
number
)
=>
n
.
toLocaleString
()
const
formatCost
=
(
c
:
number
)
=>
c
.
toFixed
(
4
)
const
formatTokens
=
(
t
:
number
)
=>
(
t
>=
1000
?
`
${(
t
/
1000
).
toFixed
(
1
)}
K`
:
t
.
toString
())
const
formatDuration
=
(
ms
:
number
)
=>
ms
>=
1000
?
`
${(
ms
/
1000
).
toFixed
(
2
)}
s`
:
`
${
ms
.
toFixed
(
0
)}
ms`
</
script
>
frontend/src/components/user/profile/ProfileEditForm.vue
0 → 100644
View file @
fd29fe11
<
template
>
<div
class=
"card"
>
<div
class=
"border-b border-gray-100 px-6 py-4 dark:border-dark-700"
>
<h2
class=
"text-lg font-medium text-gray-900 dark:text-white"
>
{{
t
(
'
profile.editProfile
'
)
}}
</h2>
</div>
<div
class=
"px-6 py-6"
>
<form
@
submit.prevent=
"handleUpdateProfile"
class=
"space-y-4"
>
<div>
<label
for=
"username"
class=
"input-label"
>
{{
t
(
'
profile.username
'
)
}}
</label>
<input
id=
"username"
v-model=
"username"
type=
"text"
class=
"input"
:placeholder=
"t('profile.enterUsername')"
/>
</div>
<div
class=
"flex justify-end pt-4"
>
<button
type=
"submit"
:disabled=
"loading"
class=
"btn btn-primary"
>
{{
loading
?
t
(
'
profile.updating
'
)
:
t
(
'
profile.updateProfile
'
)
}}
</button>
</div>
</form>
</div>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
ref
,
watch
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAuthStore
}
from
'
@/stores/auth
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
userAPI
}
from
'
@/api
'
const
props
=
defineProps
<
{
initialUsername
:
string
}
>
()
const
{
t
}
=
useI18n
()
const
authStore
=
useAuthStore
()
const
appStore
=
useAppStore
()
const
username
=
ref
(
props
.
initialUsername
)
const
loading
=
ref
(
false
)
watch
(()
=>
props
.
initialUsername
,
(
val
)
=>
{
username
.
value
=
val
})
const
handleUpdateProfile
=
async
()
=>
{
if
(
!
username
.
value
.
trim
())
{
appStore
.
showError
(
t
(
'
profile.usernameRequired
'
))
return
}
loading
.
value
=
true
try
{
const
updatedUser
=
await
userAPI
.
updateProfile
({
username
:
username
.
value
})
authStore
.
user
=
updatedUser
appStore
.
showSuccess
(
t
(
'
profile.updateSuccess
'
))
}
catch
(
error
:
any
)
{
appStore
.
showError
(
error
.
response
?.
data
?.
detail
||
t
(
'
profile.updateFailed
'
))
}
finally
{
loading
.
value
=
false
}
}
</
script
>
frontend/src/components/user/profile/ProfileInfoCard.vue
0 → 100644
View file @
fd29fe11
<
template
>
<div
class=
"card overflow-hidden"
>
<div
class=
"border-b border-gray-100 bg-gradient-to-r from-primary-500/10 to-primary-600/5 px-6 py-5 dark:border-dark-700 dark:from-primary-500/20 dark:to-primary-600/10"
>
<div
class=
"flex items-center gap-4"
>
<!-- Avatar -->
<div
class=
"flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-primary-500 to-primary-600 text-2xl font-bold text-white shadow-lg shadow-primary-500/20"
>
{{
user
?.
email
?.
charAt
(
0
).
toUpperCase
()
||
'
U
'
}}
</div>
<div
class=
"min-w-0 flex-1"
>
<h2
class=
"truncate text-lg font-semibold text-gray-900 dark:text-white"
>
{{
user
?.
email
}}
</h2>
<div
class=
"mt-1 flex items-center gap-2"
>
<span
:class=
"['badge', user?.role === 'admin' ? 'badge-primary' : 'badge-gray']"
>
{{
user
?.
role
===
'
admin
'
?
t
(
'
profile.administrator
'
)
:
t
(
'
profile.user
'
)
}}
</span>
<span
:class=
"['badge', user?.status === 'active' ? 'badge-success' : 'badge-danger']"
>
{{
user
?.
status
}}
</span>
</div>
</div>
</div>
</div>
<div
class=
"px-6 py-4"
>
<div
class=
"space-y-3"
>
<div
class=
"flex items-center gap-3 text-sm text-gray-600 dark:text-gray-400"
>
<svg
class=
"h-4 w-4 text-gray-400 dark:text-gray-500"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75"
/>
</svg>
<span
class=
"truncate"
>
{{
user
?.
email
}}
</span>
</div>
<div
v-if=
"user?.username"
class=
"flex items-center gap-3 text-sm text-gray-600 dark:text-gray-400"
>
<svg
class=
"h-4 w-4 text-gray-400 dark:text-gray-500"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z"
/>
</svg>
<span
class=
"truncate"
>
{{
user
.
username
}}
</span>
</div>
</div>
</div>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
useI18n
}
from
'
vue-i18n
'
import
type
{
User
}
from
'
@/types
'
defineProps
<
{
user
:
User
|
null
}
>
()
const
{
t
}
=
useI18n
()
</
script
>
frontend/src/components/user/profile/ProfilePasswordForm.vue
0 → 100644
View file @
fd29fe11
<
template
>
<div
class=
"card"
>
<div
class=
"border-b border-gray-100 px-6 py-4 dark:border-dark-700"
>
<h2
class=
"text-lg font-medium text-gray-900 dark:text-white"
>
{{
t
(
'
profile.changePassword
'
)
}}
</h2>
</div>
<div
class=
"px-6 py-6"
>
<form
@
submit.prevent=
"handleChangePassword"
class=
"space-y-4"
>
<div>
<label
for=
"old_password"
class=
"input-label"
>
{{
t
(
'
profile.currentPassword
'
)
}}
</label>
<input
id=
"old_password"
v-model=
"form.old_password"
type=
"password"
required
autocomplete=
"current-password"
class=
"input"
/>
</div>
<div>
<label
for=
"new_password"
class=
"input-label"
>
{{
t
(
'
profile.newPassword
'
)
}}
</label>
<input
id=
"new_password"
v-model=
"form.new_password"
type=
"password"
required
autocomplete=
"new-password"
class=
"input"
/>
<p
class=
"input-hint"
>
{{
t
(
'
profile.passwordHint
'
)
}}
</p>
</div>
<div>
<label
for=
"confirm_password"
class=
"input-label"
>
{{
t
(
'
profile.confirmNewPassword
'
)
}}
</label>
<input
id=
"confirm_password"
v-model=
"form.confirm_password"
type=
"password"
required
autocomplete=
"new-password"
class=
"input"
/>
<p
v-if=
"form.new_password && form.confirm_password && form.new_password !== form.confirm_password"
class=
"input-error-text"
>
{{
t
(
'
profile.passwordsNotMatch
'
)
}}
</p>
</div>
<div
class=
"flex justify-end pt-4"
>
<button
type=
"submit"
:disabled=
"loading"
class=
"btn btn-primary"
>
{{
loading
?
t
(
'
profile.changingPassword
'
)
:
t
(
'
profile.changePasswordButton
'
)
}}
</button>
</div>
</form>
</div>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
ref
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
userAPI
}
from
'
@/api
'
const
{
t
}
=
useI18n
()
const
appStore
=
useAppStore
()
const
loading
=
ref
(
false
)
const
form
=
ref
({
old_password
:
''
,
new_password
:
''
,
confirm_password
:
''
})
const
handleChangePassword
=
async
()
=>
{
if
(
form
.
value
.
new_password
!==
form
.
value
.
confirm_password
)
{
appStore
.
showError
(
t
(
'
profile.passwordsNotMatch
'
))
return
}
if
(
form
.
value
.
new_password
.
length
<
8
)
{
appStore
.
showError
(
t
(
'
profile.passwordTooShort
'
))
return
}
loading
.
value
=
true
try
{
await
userAPI
.
changePassword
(
form
.
value
.
old_password
,
form
.
value
.
new_password
)
form
.
value
=
{
old_password
:
''
,
new_password
:
''
,
confirm_password
:
''
}
appStore
.
showSuccess
(
t
(
'
profile.passwordChangeSuccess
'
))
}
catch
(
error
:
any
)
{
appStore
.
showError
(
error
.
response
?.
data
?.
detail
||
t
(
'
profile.passwordChangeFailed
'
))
}
finally
{
loading
.
value
=
false
}
}
</
script
>
frontend/src/composables/useClipboard.ts
View file @
fd29fe11
import
{
ref
}
from
'
vue
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
i18n
}
from
'
@/i18n
'
const
{
t
}
=
i18n
.
global
/**
* 检测是否支持 Clipboard API(需要安全上下文:HTTPS/localhost)
...
...
@@ -31,7 +34,7 @@ export function useClipboard() {
const
copyToClipboard
=
async
(
text
:
string
,
successMessage
=
'
Copied to clipboard
'
successMessage
?:
string
):
Promise
<
boolean
>
=>
{
if
(
!
text
)
return
false
...
...
@@ -50,12 +53,12 @@ export function useClipboard() {
if
(
success
)
{
copied
.
value
=
true
appStore
.
showSuccess
(
successMessage
)
appStore
.
showSuccess
(
successMessage
||
t
(
'
common.copiedToClipboard
'
)
)
setTimeout
(()
=>
{
copied
.
value
=
false
},
2000
)
}
else
{
appStore
.
showError
(
'
C
opy
f
ailed
'
)
appStore
.
showError
(
t
(
'
common.c
opy
F
ailed
'
)
)
}
return
success
...
...
frontend/src/composables/useForm.ts
0 → 100644
View file @
fd29fe11
import
{
ref
}
from
'
vue
'
import
{
useAppStore
}
from
'
@/stores/app
'
interface
UseFormOptions
<
T
>
{
form
:
T
submitFn
:
(
data
:
T
)
=>
Promise
<
void
>
successMsg
?:
string
errorMsg
?:
string
}
/**
* 统一表单提交逻辑
* 管理加载状态、错误捕获及通知
*/
export
function
useForm
<
T
>
(
options
:
UseFormOptions
<
T
>
)
{
const
{
form
,
submitFn
,
successMsg
,
errorMsg
}
=
options
const
loading
=
ref
(
false
)
const
appStore
=
useAppStore
()
const
submit
=
async
()
=>
{
if
(
loading
.
value
)
return
loading
.
value
=
true
try
{
await
submitFn
(
form
)
if
(
successMsg
)
{
appStore
.
showSuccess
(
successMsg
)
}
}
catch
(
error
:
any
)
{
const
detail
=
error
.
response
?.
data
?.
detail
||
error
.
response
?.
data
?.
message
||
error
.
message
appStore
.
showError
(
errorMsg
||
detail
)
// 继续抛出错误,让组件有机会进行局部处理(如验证错误显示)
throw
error
}
finally
{
loading
.
value
=
false
}
}
return
{
loading
,
submit
}
}
frontend/src/composables/useTableLoader.ts
0 → 100644
View file @
fd29fe11
import
{
ref
,
reactive
,
onUnmounted
,
toRaw
}
from
'
vue
'
import
{
useDebounceFn
}
from
'
@vueuse/core
'
import
type
{
BasePaginationResponse
,
FetchOptions
}
from
'
@/types
'
interface
PaginationState
{
page
:
number
page_size
:
number
total
:
number
pages
:
number
}
interface
TableLoaderOptions
<
T
,
P
>
{
fetchFn
:
(
page
:
number
,
pageSize
:
number
,
params
:
P
,
options
?:
FetchOptions
)
=>
Promise
<
BasePaginationResponse
<
T
>>
initialParams
?:
P
pageSize
?:
number
debounceMs
?:
number
}
/**
* 通用表格数据加载 Composable
* 统一处理分页、筛选、搜索防抖和请求取消
*/
export
function
useTableLoader
<
T
,
P
extends
Record
<
string
,
any
>>
(
options
:
TableLoaderOptions
<
T
,
P
>
)
{
const
{
fetchFn
,
initialParams
,
pageSize
=
20
,
debounceMs
=
300
}
=
options
const
items
=
ref
<
T
[]
>
([])
const
loading
=
ref
(
false
)
const
params
=
reactive
<
P
>
({
...(
initialParams
||
{})
}
as
P
)
const
pagination
=
reactive
<
PaginationState
>
({
page
:
1
,
page_size
:
pageSize
,
total
:
0
,
pages
:
0
})
let
abortController
:
AbortController
|
null
=
null
const
isAbortError
=
(
error
:
any
)
=>
{
return
error
?.
name
===
'
AbortError
'
||
error
?.
code
===
'
ERR_CANCELED
'
||
error
?.
name
===
'
CanceledError
'
}
const
load
=
async
()
=>
{
if
(
abortController
)
{
abortController
.
abort
()
}
abortController
=
new
AbortController
()
loading
.
value
=
true
try
{
const
response
=
await
fetchFn
(
pagination
.
page
,
pagination
.
page_size
,
toRaw
(
params
)
as
P
,
{
signal
:
abortController
.
signal
}
)
items
.
value
=
response
.
items
||
[]
pagination
.
total
=
response
.
total
||
0
pagination
.
pages
=
response
.
pages
||
0
}
catch
(
error
)
{
if
(
!
isAbortError
(
error
))
{
console
.
error
(
'
Table load error:
'
,
error
)
throw
error
}
}
finally
{
if
(
abortController
&&
!
abortController
.
signal
.
aborted
)
{
loading
.
value
=
false
}
}
}
const
reload
=
()
=>
{
pagination
.
page
=
1
return
load
()
}
const
debouncedReload
=
useDebounceFn
(
reload
,
debounceMs
)
const
handlePageChange
=
(
page
:
number
)
=>
{
pagination
.
page
=
page
load
()
}
const
handlePageSizeChange
=
(
size
:
number
)
=>
{
pagination
.
page_size
=
size
pagination
.
page
=
1
load
()
}
onUnmounted
(()
=>
{
abortController
?.
abort
()
})
return
{
items
,
loading
,
params
,
pagination
,
load
,
reload
,
debouncedReload
,
handlePageChange
,
handlePageSizeChange
}
}
frontend/src/i18n/locales/en.ts
View file @
fd29fe11
...
...
@@ -47,6 +47,7 @@ export default {
description
:
'
Configure your Sub2API instance
'
,
database
:
{
title
:
'
Database Configuration
'
,
description
:
'
Connect to your PostgreSQL database
'
,
host
:
'
Host
'
,
port
:
'
Port
'
,
username
:
'
Username
'
,
...
...
@@ -63,6 +64,7 @@ export default {
},
redis
:
{
title
:
'
Redis Configuration
'
,
description
:
'
Connect to your Redis server
'
,
host
:
'
Host
'
,
port
:
'
Port
'
,
password
:
'
Password (optional)
'
,
...
...
@@ -71,6 +73,7 @@ export default {
},
admin
:
{
title
:
'
Admin Account
'
,
description
:
'
Create your administrator account
'
,
email
:
'
Email
'
,
password
:
'
Password
'
,
confirmPassword
:
'
Confirm Password
'
,
...
...
@@ -80,9 +83,21 @@ export default {
},
ready
:
{
title
:
'
Ready to Install
'
,
description
:
'
Review your configuration and complete setup
'
,
database
:
'
Database
'
,
redis
:
'
Redis
'
,
adminEmail
:
'
Admin Email
'
},
status
:
{
testing
:
'
Testing...
'
,
success
:
'
Connection Successful
'
,
testConnection
:
'
Test Connection
'
,
installing
:
'
Installing...
'
,
completeInstallation
:
'
Complete Installation
'
,
completed
:
'
Installation completed!
'
,
redirecting
:
'
Redirecting to login page...
'
,
restarting
:
'
Service is restarting, please wait...
'
,
timeout
:
'
Service restart is taking longer than expected. Please refresh the page manually.
'
}
},
...
...
@@ -133,8 +148,10 @@ export default {
selectOption
:
'
Select an option
'
,
searchPlaceholder
:
'
Search...
'
,
noOptionsFound
:
'
No options found
'
,
noGroupsAvailable
:
'
No groups available
'
,
unknownError
:
'
Unknown error occurred
'
,
saving
:
'
Saving...
'
,
refresh
:
'
Refresh
'
,
selectedCount
:
'
({count} selected)
'
,
refresh
:
'
Refresh
'
,
notAvailable
:
'
N/A
'
,
now
:
'
Now
'
,
unknown
:
'
Unknown
'
,
...
...
@@ -673,6 +690,10 @@ export default {
failedToWithdraw
:
'
Failed to withdraw
'
,
useDepositWithdrawButtons
:
'
Please use deposit/withdraw buttons to adjust balance
'
,
insufficientBalance
:
'
Insufficient balance, balance cannot be negative after withdrawal
'
,
roles
:
{
admin
:
'
Admin
'
,
user
:
'
User
'
},
// Settings Dropdowns
filterSettings
:
'
Filter Settings
'
,
columnSettings
:
'
Column Settings
'
,
...
...
@@ -739,6 +760,7 @@ export default {
groups
:
{
title
:
'
Group Management
'
,
description
:
'
Manage API key groups and rate multipliers
'
,
searchGroups
:
'
Search groups...
'
,
createGroup
:
'
Create Group
'
,
editGroup
:
'
Edit Group
'
,
deleteGroup
:
'
Delete Group
'
,
...
...
@@ -794,6 +816,13 @@ export default {
failedToCreate
:
'
Failed to create group
'
,
failedToUpdate
:
'
Failed to update group
'
,
failedToDelete
:
'
Failed to delete group
'
,
platforms
:
{
all
:
'
All Platforms
'
,
anthropic
:
'
Anthropic
'
,
openai
:
'
OpenAI
'
,
gemini
:
'
Gemini
'
,
antigravity
:
'
Antigravity
'
},
deleteConfirm
:
"
Are you sure you want to delete '{name}'? All associated API keys will no longer belong to any group.
"
,
deleteConfirmSubscription
:
...
...
@@ -935,9 +964,16 @@ export default {
antigravityOauth
:
'
Antigravity OAuth
'
},
status
:
{
active
:
'
Active
'
,
inactive
:
'
Inactive
'
,
error
:
'
Error
'
,
cooldown
:
'
Cooldown
'
,
paused
:
'
Paused
'
,
limited
:
'
Limited
'
,
tempUnschedulable
:
'
Temp Unschedulable
'
tempUnschedulable
:
'
Temp Unschedulable
'
,
rateLimitedUntil
:
'
Rate limited until {time}
'
,
overloadedUntil
:
'
Overloaded until {time}
'
,
viewTempUnschedDetails
:
'
View temp unschedulable details
'
},
tempUnschedulable
:
{
title
:
'
Temp Unschedulable
'
,
...
...
@@ -1484,6 +1520,12 @@ export default {
searchProxies
:
'
Search proxies...
'
,
allProtocols
:
'
All Protocols
'
,
allStatus
:
'
All Status
'
,
protocols
:
{
http
:
'
HTTP
'
,
https
:
'
HTTPS
'
,
socks5
:
'
SOCKS5
'
,
socks5h
:
'
SOCKS5H (Remote DNS)
'
},
columns
:
{
name
:
'
Name
'
,
protocol
:
'
Protocol
'
,
...
...
@@ -1601,7 +1643,13 @@ export default {
selectGroupPlaceholder
:
'
Choose a subscription group
'
,
validityDays
:
'
Validity Days
'
,
groupRequired
:
'
Please select a subscription group
'
,
days
:
'
days
'
days
:
'
days
'
,
status
:
{
unused
:
'
Unused
'
,
used
:
'
Used
'
,
expired
:
'
Expired
'
,
disabled
:
'
Disabled
'
}
},
// Usage Records
...
...
@@ -1610,6 +1658,7 @@ export default {
description
:
'
View and manage all user usage records
'
,
userFilter
:
'
User
'
,
searchUserPlaceholder
:
'
Search user by email...
'
,
searchApiKeyPlaceholder
:
'
Search API key by name...
'
,
selectedUser
:
'
Selected
'
,
user
:
'
User
'
,
account
:
'
Account
'
,
...
...
frontend/src/i18n/locales/zh.ts
View file @
fd29fe11
...
...
@@ -44,6 +44,7 @@ export default {
description
:
'
配置您的 Sub2API 实例
'
,
database
:
{
title
:
'
数据库配置
'
,
description
:
'
连接到您的 PostgreSQL 数据库
'
,
host
:
'
主机
'
,
port
:
'
端口
'
,
username
:
'
用户名
'
,
...
...
@@ -60,6 +61,7 @@ export default {
},
redis
:
{
title
:
'
Redis 配置
'
,
description
:
'
连接到您的 Redis 服务器
'
,
host
:
'
主机
'
,
port
:
'
端口
'
,
password
:
'
密码(可选)
'
,
...
...
@@ -68,6 +70,7 @@ export default {
},
admin
:
{
title
:
'
管理员账户
'
,
description
:
'
创建您的管理员账户
'
,
email
:
'
邮箱
'
,
password
:
'
密码
'
,
confirmPassword
:
'
确认密码
'
,
...
...
@@ -77,9 +80,21 @@ export default {
},
ready
:
{
title
:
'
准备安装
'
,
description
:
'
检查您的配置并完成安装
'
,
database
:
'
数据库
'
,
redis
:
'
Redis
'
,
adminEmail
:
'
管理员邮箱
'
},
status
:
{
testing
:
'
测试中...
'
,
success
:
'
连接成功
'
,
testConnection
:
'
测试连接
'
,
installing
:
'
安装中...
'
,
completeInstallation
:
'
完成安装
'
,
completed
:
'
安装完成!
'
,
redirecting
:
'
正在跳转到登录页面...
'
,
restarting
:
'
服务正在重启,请稍候...
'
,
timeout
:
'
服务重启时间超出预期,请手动刷新页面。
'
}
},
...
...
@@ -130,7 +145,10 @@ export default {
selectOption
:
'
请选择
'
,
searchPlaceholder
:
'
搜索...
'
,
noOptionsFound
:
'
无匹配选项
'
,
noGroupsAvailable
:
'
无可用分组
'
,
unknownError
:
'
发生未知错误
'
,
saving
:
'
保存中...
'
,
selectedCount
:
'
(已选 {count} 个)
'
,
refresh
:
'
刷新
'
,
notAvailable
:
'
不可用
'
,
now
:
'
现在
'
,
...
...
@@ -665,10 +683,6 @@ export default {
admin
:
'
管理员
'
,
user
:
'
用户
'
},
statuses
:
{
active
:
'
正常
'
,
banned
:
'
禁用
'
},
form
:
{
emailLabel
:
'
邮箱
'
,
emailPlaceholder
:
'
请输入邮箱
'
,
...
...
@@ -795,6 +809,7 @@ export default {
groups
:
{
title
:
'
分组管理
'
,
description
:
'
管理 API 密钥分组和费率配置
'
,
searchGroups
:
'
搜索分组...
'
,
createGroup
:
'
创建分组
'
,
editGroup
:
'
编辑分组
'
,
deleteGroup
:
'
删除分组
'
,
...
...
@@ -852,8 +867,10 @@ export default {
rateMultiplierHint
:
'
1.0 = 标准费率,0.5 = 半价,2.0 = 双倍
'
,
platforms
:
{
all
:
'
全部平台
'
,
claude
:
'
Claude
'
,
openai
:
'
OpenAI
'
anthropic
:
'
Anthropic
'
,
openai
:
'
OpenAI
'
,
gemini
:
'
Gemini
'
,
antigravity
:
'
Antigravity
'
},
saving
:
'
保存中...
'
,
noGroups
:
'
暂无分组
'
,
...
...
@@ -1054,16 +1071,17 @@ export default {
api_key
:
'
API Key
'
,
cookie
:
'
Cookie
'
},
status
es
:
{
status
:
{
active
:
'
正常
'
,
inactive
:
'
停用
'
,
error
:
'
错误
'
,
cooldown
:
'
冷却中
'
},
status
:
{
paused
:
'
已暂停
'
,
limited
:
'
受限
'
,
tempUnschedulable
:
'
临时不可调度
'
cooldown
:
'
冷却中
'
,
paused
:
'
暂停
'
,
limited
:
'
限流
'
,
tempUnschedulable
:
'
临时不可调度
'
,
rateLimitedUntil
:
'
限流中,重置时间:{time}
'
,
overloadedUntil
:
'
负载过重,重置时间:{time}
'
,
viewTempUnschedDetails
:
'
查看临时不可调度详情
'
},
tempUnschedulable
:
{
title
:
'
临时不可调度
'
,
...
...
@@ -1596,25 +1614,6 @@ export default {
deleteConfirmMessage
:
"
确定要删除代理 '{name}' 吗?
"
,
testProxy
:
'
测试代理
'
,
columns
:
{
name
:
'
名称
'
,
protocol
:
'
协议
'
,
address
:
'
地址
'
,
priority
:
'
优先级
'
,
status
:
'
状态
'
,
lastCheck
:
'
最近检测
'
,
actions
:
'
操作
'
},
protocols
:
{
http
:
'
HTTP
'
,
https
:
'
HTTPS
'
,
socks5
:
'
SOCKS5
'
},
statuses
:
{
active
:
'
正常
'
,
inactive
:
'
停用
'
,
error
:
'
错误
'
},
form
:
{
nameLabel
:
'
名称
'
,
namePlaceholder
:
'
请输入代理名称
'
,
protocolLabel
:
'
协议
'
,
...
...
@@ -1753,7 +1752,7 @@ export default {
validityDays
:
'
有效天数
'
,
groupRequired
:
'
请选择订阅分组
'
,
days
:
'
天
'
,
status
es
:
{
status
:
{
unused
:
'
未使用
'
,
used
:
'
已使用
'
,
expired
:
'
已过期
'
,
...
...
@@ -1805,6 +1804,7 @@ export default {
description
:
'
查看和管理所有用户的使用记录
'
,
userFilter
:
'
用户
'
,
searchUserPlaceholder
:
'
按邮箱搜索用户...
'
,
searchApiKeyPlaceholder
:
'
按名称搜索 API 密钥...
'
,
selectedUser
:
'
已选择
'
,
user
:
'
用户
'
,
account
:
'
账户
'
,
...
...
frontend/src/types/index.ts
View file @
fd29fe11
...
...
@@ -2,6 +2,26 @@
* Core Type Definitions for Sub2API Frontend
*/
// ==================== Common Types ====================
export
interface
SelectOption
{
value
:
string
|
number
|
boolean
|
null
label
:
string
[
key
:
string
]:
any
// Support extra properties for custom templates
}
export
interface
BasePaginationResponse
<
T
>
{
items
:
T
[]
total
:
number
page
:
number
page_size
:
number
pages
:
number
}
export
interface
FetchOptions
{
signal
?:
AbortSignal
}
// ==================== User & Auth Types ====================
export
interface
User
{
...
...
@@ -476,6 +496,7 @@ export interface UpdateAccountRequest {
proxy_id
?:
number
|
null
concurrency
?:
number
priority
?:
number
schedulable
?:
boolean
status
?:
'
active
'
|
'
inactive
'
group_ids
?:
number
[]
confirm_mixed_channel_risk
?:
boolean
...
...
@@ -826,6 +847,7 @@ export type UserAttributeType = 'text' | 'textarea' | 'number' | 'email' | 'url'
export
interface
UserAttributeOption
{
value
:
string
label
:
string
[
key
:
string
]:
unknown
}
export
interface
UserAttributeValidation
{
...
...
frontend/src/utils/format.ts
View file @
fd29fe11
...
...
@@ -3,7 +3,7 @@
* 参考 CRS 项目的 format.js 实现
*/
import
{
i18n
}
from
'
@/i18n
'
import
{
i18n
,
getLocale
}
from
'
@/i18n
'
/**
* 格式化相对时间
...
...
@@ -39,33 +39,39 @@ export function formatRelativeTime(date: string | Date | null | undefined): stri
export
function
formatNumber
(
num
:
number
|
null
|
undefined
):
string
{
if
(
num
===
null
||
num
===
undefined
)
return
'
0
'
const
locale
=
getLocale
()
const
absNum
=
Math
.
abs
(
num
)
if
(
absNum
>=
1
e9
)
{
return
(
num
/
1
e9
).
toFixed
(
2
)
+
'
B
'
}
else
if
(
absNum
>=
1
e6
)
{
return
(
num
/
1
e6
).
toFixed
(
2
)
+
'
M
'
}
else
if
(
absNum
>=
1
e3
)
{
return
(
num
/
1
e3
).
toFixed
(
1
)
+
'
K
'
}
// Use Intl.NumberFormat for compact notation if supported and needed
// Note: Compact notation in 'zh' uses '万/亿', which is appropriate for Chinese
const
formatter
=
new
Intl
.
NumberFormat
(
locale
,
{
notation
:
absNum
>=
10000
?
'
compact
'
:
'
standard
'
,
maximumFractionDigits
:
1
})
return
num
.
toLocaleString
(
)
return
formatter
.
format
(
num
)
}
/**
* 格式化货币金额
* @param amount 金额
* @returns 格式化后的字符串,如 "$1.25" 或 "$0.000123"
* @param currency 货币代码,默认 USD
* @returns 格式化后的字符串,如 "$1.25"
*/
export
function
formatCurrency
(
amount
:
number
|
null
|
undefined
):
string
{
export
function
formatCurrency
(
amount
:
number
|
null
|
undefined
,
currency
:
string
=
'
USD
'
):
string
{
if
(
amount
===
null
||
amount
===
undefined
)
return
'
$0.00
'
// 小于 0.01 时显示更多小数位
if
(
amount
>
0
&&
amount
<
0.01
)
{
return
'
$
'
+
amount
.
toFixed
(
6
)
}
const
locale
=
getLocale
()
return
'
$
'
+
amount
.
toFixed
(
2
)
// For very small amounts, show more decimals
const
fractionDigits
=
amount
>
0
&&
amount
<
0.01
?
6
:
2
return
new
Intl
.
NumberFormat
(
locale
,
{
style
:
'
currency
'
,
currency
:
currency
,
minimumFractionDigits
:
fractionDigits
,
maximumFractionDigits
:
fractionDigits
}).
format
(
amount
)
}
/**
...
...
@@ -89,57 +95,89 @@ export function formatBytes(bytes: number, decimals: number = 2): string {
/**
* 格式化日期
* @param date 日期字符串或 Date 对象
* @param
format 格式字符串,支持 YYYY, MM, DD, HH, mm, s
s
* @param
options Intl.DateTimeFormatOption
s
* @returns 格式化后的日期字符串
*/
export
function
formatDate
(
date
:
string
|
Date
|
null
|
undefined
,
format
:
string
=
'
YYYY-MM-DD HH:mm:ss
'
options
:
Intl
.
DateTimeFormatOptions
=
{
year
:
'
numeric
'
,
month
:
'
2-digit
'
,
day
:
'
2-digit
'
,
hour
:
'
2-digit
'
,
minute
:
'
2-digit
'
,
second
:
'
2-digit
'
,
hour12
:
false
}
):
string
{
if
(
!
date
)
return
''
const
d
=
new
Date
(
date
)
if
(
isNaN
(
d
.
getTime
()))
return
''
const
year
=
d
.
getFullYear
()
const
month
=
String
(
d
.
getMonth
()
+
1
).
padStart
(
2
,
'
0
'
)
const
day
=
String
(
d
.
getDate
()).
padStart
(
2
,
'
0
'
)
const
hours
=
String
(
d
.
getHours
()).
padStart
(
2
,
'
0
'
)
const
minutes
=
String
(
d
.
getMinutes
()).
padStart
(
2
,
'
0
'
)
const
seconds
=
String
(
d
.
getSeconds
()).
padStart
(
2
,
'
0
'
)
return
format
.
replace
(
'
YYYY
'
,
String
(
year
))
.
replace
(
'
MM
'
,
month
)
.
replace
(
'
DD
'
,
day
)
.
replace
(
'
HH
'
,
hours
)
.
replace
(
'
mm
'
,
minutes
)
.
replace
(
'
ss
'
,
seconds
)
const
locale
=
getLocale
()
return
new
Intl
.
DateTimeFormat
(
locale
,
options
).
format
(
d
)
}
/**
* 格式化日期(只显示日期部分)
* @param date 日期字符串或 Date 对象
* @returns 格式化后的日期字符串
,格式为 YYYY-MM-DD
* @returns 格式化后的日期字符串
*/
export
function
formatDateOnly
(
date
:
string
|
Date
|
null
|
undefined
):
string
{
return
formatDate
(
date
,
'
YYYY-MM-DD
'
)
return
formatDate
(
date
,
{
year
:
'
numeric
'
,
month
:
'
2-digit
'
,
day
:
'
2-digit
'
})
}
/**
* 格式化日期时间(完整格式)
* @param date 日期字符串或 Date 对象
* @returns 格式化后的日期时间字符串
,格式为 YYYY-MM-DD HH:mm:ss
* @returns 格式化后的日期时间字符串
*/
export
function
formatDateTime
(
date
:
string
|
Date
|
null
|
undefined
):
string
{
return
formatDate
(
date
,
'
YYYY-MM-DD HH:mm:ss
'
)
return
formatDate
(
date
)
}
/**
* 格式化时间(只显示时分)
* @param date 日期字符串或 Date 对象
* @returns 格式化后的时间字符串
,格式为 HH:mm
* @returns 格式化后的时间字符串
*/
export
function
formatTime
(
date
:
string
|
Date
|
null
|
undefined
):
string
{
return
formatDate
(
date
,
'
HH:mm
'
)
return
formatDate
(
date
,
{
hour
:
'
2-digit
'
,
minute
:
'
2-digit
'
,
hour12
:
false
})
}
/**
* 格式化数字(千分位分隔,不使用紧凑单位)
* @param num 数字
* @returns 格式化后的字符串,如 "12,345"
*/
export
function
formatNumberLocaleString
(
num
:
number
):
string
{
return
num
.
toLocaleString
()
}
/**
* 格式化金额(固定小数位,不带货币符号)
* @param amount 金额
* @param fractionDigits 小数位数,默认 4
* @returns 格式化后的字符串,如 "1.2345"
*/
export
function
formatCostFixed
(
amount
:
number
,
fractionDigits
:
number
=
4
):
string
{
return
amount
.
toFixed
(
fractionDigits
)
}
/**
* 格式化 token 数量(>=1000 显示为 K,保留 1 位小数)
* @param tokens token 数量
* @returns 格式化后的字符串,如 "950", "1.2K"
*/
export
function
formatTokensK
(
tokens
:
number
):
string
{
return
tokens
>=
1000
?
`
${(
tokens
/
1000
).
toFixed
(
1
)}
K`
:
tokens
.
toString
()
}
frontend/src/views/admin/AccountsView.vue
View file @
fd29fe11
<
template
>
<AppLayout>
<TablePageLayout>
<template
#actions
>
<div
class=
"flex justify-end gap-3"
>
<button
@
click=
"loadAccounts"
:disabled=
"loading"
class=
"btn btn-secondary"
:title=
"t('common.refresh')"
>
<svg
:class=
"['h-5 w-5', loading ? 'animate-spin' : '']"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
</button>
<button
@
click=
"showCrsSyncModal = true"
class=
"btn btn-secondary"
:title=
"t('admin.accounts.syncFromCrs')"
>
<svg
class=
"h-5 w-5"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125"
/>
</svg>
</button>
<button
@
click=
"showCreateModal = true"
class=
"btn btn-primary"
data-tour=
"accounts-create-btn"
>
<svg
class=
"mr-2 h-5 w-5"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M12 4.5v15m7.5-7.5h-15"
/>
</svg>
{{
t
(
'
admin.accounts.createAccount
'
)
}}
</button>
</div>
</
template
>
<template
#filters
>
<div
class=
"flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between"
>
<div
class=
"relative max-w-md flex-1"
>
<svg
class=
"absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 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
v-model=
"searchQuery"
type=
"text"
:placeholder=
"t('admin.accounts.searchAccounts')"
class=
"input pl-10"
@
input=
"handleSearch"
<div
class=
"flex flex-wrap-reverse items-start justify-between gap-3"
>
<div
class=
"min-w-0 flex-1"
>
<AccountTableFilters
v-model:searchQuery=
"params.search"
:filters=
"params"
@
change=
"reload"
@
update:searchQuery=
"debouncedReload"
/>
</div>
<div
class=
"flex flex-wrap gap-3"
>
<Select
v-model=
"filters.platform"
:options=
"platformOptions"
:placeholder=
"t('admin.accounts.allPlatforms')"
class=
"w-40"
@
change=
"loadAccounts"
/>
<Select
v-model=
"filters.type"
:options=
"typeOptions"
:placeholder=
"t('admin.accounts.allTypes')"
class=
"w-40"
@
change=
"loadAccounts"
/>
<Select
v-model=
"filters.status"
:options=
"statusOptions"
:placeholder=
"t('admin.accounts.allStatus')"
class=
"w-36"
@
change=
"loadAccounts"
<div
class=
"flex-shrink-0"
>
<AccountTableActions
:loading=
"loading"
@
refresh=
"load"
@
sync=
"showSync = true"
@
create=
"showCreate = true"
/>
</div>
</div>
</
template
>
<
template
#table
>
<!-- Bulk Actions Bar -->
<div
v-if=
"selectedAccountIds.length > 0"
class=
"mb-[5px] mt-[10px] px-5 py-1"
>
<div
class=
"flex flex-wrap items-center justify-between gap-3"
>
<div
class=
"flex flex-wrap items-center gap-2"
>
<span
class=
"text-sm font-medium text-primary-900 dark:text-primary-100"
>
{{
t
(
'
admin.accounts.bulkActions.selected
'
,
{
count
:
selectedAccountIds
.
length
}
)
}}
<
/span
>
<
button
@
click
=
"
selectCurrentPageAccounts
"
class
=
"
text-xs font-medium text-primary-700 hover:text-primary-800 dark:text-primary-300 dark:hover:text-primary-200
"
>
{{
t
(
'
admin.accounts.bulkActions.selectCurrentPage
'
)
}}
<
/button
>
<
span
class
=
"
text-gray-300 dark:text-primary-800
"
>
•
<
/span
>
<
button
@
click
=
"
selectedAccountIds = []
"
class
=
"
text-xs font-medium text-primary-700 hover:text-primary-800 dark:text-primary-300 dark:hover:text-primary-200
"
>
{{
t
(
'
admin.accounts.bulkActions.clear
'
)
}}
<
/button
>
<
/div
>
<
div
class
=
"
flex items-center gap-2
"
>
<
button
@
click
=
"
handleBulkDelete
"
class
=
"
btn btn-danger btn-sm
"
>
<
svg
class
=
"
mr-1.5 h-4 w-4
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
stroke
-
width
=
"
1.5
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
d
=
"
M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0
"
/>
<
/svg
>
{{
t
(
'
admin.accounts.bulkActions.delete
'
)
}}
<
/button
>
<
button
@
click
=
"
showBulkEditModal = true
"
class
=
"
btn btn-primary btn-sm
"
>
<
svg
class
=
"
mr-1.5 h-4 w-4
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
stroke
-
width
=
"
1.5
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
d
=
"
M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10
"
/>
<
/svg
>
{{
t
(
'
admin.accounts.bulkActions.edit
'
)
}}
<
/button
>
<
/div
>
<
/div
>
<
/div
>
<
DataTable
:
columns
=
"
columns
"
:
data
=
"
accounts
"
:
loading
=
"
loading
"
>
<AccountBulkActionsBar
:selected-ids=
"selIds"
@
delete=
"handleBulkDelete"
@
edit=
"showBulkEdit = true"
@
clear=
"selIds = []"
@
select-page=
"selectPage"
/>
<DataTable
:columns=
"cols"
:data=
"accounts"
:loading=
"loading"
>
<template
#cell-select
="
{ row }">
<
input
type
=
"
checkbox
"
:
checked
=
"
selectedAccountIds.includes(row.id)
"
@
change
=
"
toggleAccountSelection(row.id)
"
class
=
"
rounded border-gray-300 text-primary-600 focus:ring-primary-500
"
/>
<input
type=
"checkbox"
:checked=
"selIds.includes(row.id)"
@
change=
"toggleSel(row.id)"
class=
"rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
</
template
>
<
template
#cell-name=
"{ value }"
>
<span
class=
"font-medium text-gray-900 dark:text-white"
>
{{
value
}}
</span>
</
template
>
<
template
#cell-platform_type=
"{ row }"
>
<PlatformTypeBadge
:platform=
"row.platform"
:type=
"row.type"
/>
</
template
>
<
template
#cell-concurrency=
"{ row }"
>
<div
class=
"flex items-center gap-1.5"
>
<
span
:
class
=
"
[
'inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium',
(row.current_concurrency || 0) >= row.concurrency
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
: (row.current_concurrency || 0) > 0
? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400'
]
"
>
<
svg
class
=
"
h-3 w-3
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
stroke
-
width
=
"
2
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
d
=
"
M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z
"
/>
<
/svg
>
<span
:class=
"['inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium', (row.current_concurrency || 0) >= row.concurrency ? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' : (row.current_concurrency || 0) > 0 ? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400' : 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400']"
>
<svg
class=
"h-3 w-3"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"2"
><path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z"
/></svg>
<span
class=
"font-mono"
>
{{
row
.
current_concurrency
||
0
}}
</span>
<span
class=
"text-gray-400 dark:text-gray-500"
>
/
</span>
<span
class=
"font-mono"
>
{{
row
.
concurrency
}}
</span>
</span>
</div>
</
template
>
<
template
#cell-status=
"{ row }"
>
<AccountStatusIndicator
:account=
"row"
@
show-temp-unsched=
"handleShowTempUnsched"
/>
</
template
>
<
template
#cell-schedulable=
"{ row }"
>
<
button
@
click
=
"
handleToggleSchedulable(row)
"
:
disabled
=
"
togglingSchedulable === row.id
"
class
=
"
relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:focus:ring-offset-dark-800
"
:
class
=
"
[
row.schedulable
? 'bg-primary-500 hover:bg-primary-600'
: 'bg-gray-200 hover:bg-gray-300 dark:bg-dark-600 dark:hover:bg-dark-500'
]
"
:
title
=
"
row.schedulable
? t('admin.accounts.schedulableEnabled')
: t('admin.accounts.schedulableDisabled')
"
>
<
span
class
=
"
pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out
"
:
class
=
"
[row.schedulable ? 'translate-x-4' : 'translate-x-0']
"
/>
<button
@
click=
"handleToggleSchedulable(row)"
:disabled=
"togglingSchedulable === row.id"
class=
"relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:focus:ring-offset-dark-800"
:class=
"[row.schedulable ? 'bg-primary-500 hover:bg-primary-600' : 'bg-gray-200 hover:bg-gray-300 dark:bg-dark-600 dark:hover:bg-dark-500']"
:title=
"row.schedulable ? t('admin.accounts.schedulableEnabled') : t('admin.accounts.schedulableDisabled')"
>
<span
class=
"pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
:class=
"[row.schedulable ? 'translate-x-4' : 'translate-x-0']"
/>
</button>
</
template
>
<
template
#cell-today_stats=
"{ row }"
>
<AccountTodayStatsCell
:account=
"row"
/>
</
template
>
<
template
#cell-groups=
"{ row }"
>
<div
v-if=
"row.groups && row.groups.length > 0"
class=
"flex flex-wrap gap-1.5"
>
<
GroupBadge
v
-
for
=
"
group in row.groups
"
:
key
=
"
group.id
"
:
name
=
"
group.name
"
:
platform
=
"
group.platform
"
:
subscription
-
type
=
"
group.subscription_type
"
:
rate
-
multiplier
=
"
group.rate_multiplier
"
:
show
-
rate
=
"
false
"
/>
<GroupBadge
v-for=
"group in row.groups"
:key=
"group.id"
:name=
"group.name"
:platform=
"group.platform"
:subscription-type=
"group.subscription_type"
:rate-multiplier=
"group.rate_multiplier"
:show-rate=
"false"
/>
</div>
<span
v-else
class=
"text-sm text-gray-400 dark:text-dark-500"
>
-
</span>
</
template
>
<
template
#cell-usage=
"{ row }"
>
<AccountUsageCell
:account=
"row"
/>
</
template
>
<
template
#cell-priority=
"{ value }"
>
<span
class=
"text-sm text-gray-700 dark:text-gray-300"
>
{{
value
}}
</span>
</
template
>
<
template
#cell-last_used_at=
"{ value }"
>
<
span
class
=
"
text-sm text-gray-500 dark:text-dark-400
"
>
{{
formatRelativeTime
(
value
)
}}
<
/span
>
<span
class=
"text-sm text-gray-500 dark:text-dark-400"
>
{{
formatRelativeTime
(
value
)
}}
</span>
</
template
>
<
template
#cell-actions=
"{ row }"
>
<div
class=
"flex items-center gap-1"
>
<!--
Edit
Button
-->
<
button
@
click
=
"
handleEdit(row)
"
class
=
"
flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400
"
>
<
svg
class
=
"
h-4 w-4
"
fill
=
"
none
"
stroke
=
"
currentColor
"
viewBox
=
"
0 0 24 24
"
stroke
-
width
=
"
1.5
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
d
=
"
M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10
"
/>
<
/svg
>
<button
@
click=
"handleEdit(row)"
class=
"flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400"
>
<svg
class=
"h-4 w-4"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
><path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
/></svg>
<span
class=
"text-xs"
>
{{
t
(
'
common.edit
'
)
}}
</span>
</button>
<!--
Delete
Button
-->
<
button
@
click
=
"
handleDelete(row)
"
class
=
"
flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400
"
>
<
svg
class
=
"
h-4 w-4
"
fill
=
"
none
"
stroke
=
"
currentColor
"
viewBox
=
"
0 0 24 24
"
stroke
-
width
=
"
1.5
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
d
=
"
M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0
"
/>
<
/svg
>
<button
@
click=
"handleDelete(row)"
class=
"flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
>
<svg
class=
"h-4 w-4"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
><path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
/></svg>
<span
class=
"text-xs"
>
{{
t
(
'
common.delete
'
)
}}
</span>
</button>
<!--
More
Actions
Menu
Trigger
-->
<
button
:
ref
=
"
(el) => setActionButtonRef(row.id, el)
"
@
click
=
"
openActionMenu(row)
"
class
=
"
action-menu-trigger flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:hover:bg-dark-700 dark:hover:text-white
"
:
class
=
"
{ 'bg-gray-100 text-gray-900 dark:bg-dark-700 dark:text-white': activeMenuId === row.id
}
"
>
<
svg
class
=
"
h-4 w-4
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
stroke
-
width
=
"
1.5
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
d
=
"
M6.75 12a.75.75 0 11-1.5 0 .75.75 0 011.5 0zM12.75 12a.75.75 0 11-1.5 0 .75.75 0 011.5 0zM18.75 12a.75.75 0 11-1.5 0 .75.75 0 011.5 0z
"
/>
<
/svg
>
<button
@
click=
"openMenu(row, $event)"
class=
"flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:hover:bg-dark-700 dark:hover:text-white"
>
<svg
class=
"h-4 w-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
><path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M6.75 12a.75.75 0 11-1.5 0 .75.75 0 011.5 0zM12.75 12a.75.75 0 11-1.5 0 .75.75 0 011.5 0zM18.75 12a.75.75 0 11-1.5 0 .75.75 0 011.5 0z"
/></svg>
<span
class=
"text-xs"
>
{{
t
(
'
common.more
'
)
}}
</span>
</button>
</div>
</
template
>
<
template
#
empty
>
<
EmptyState
:
title
=
"
t('admin.accounts.noAccountsYet')
"
:
description
=
"
t('admin.accounts.createFirstAccount')
"
:
action
-
text
=
"
t('admin.accounts.createAccount')
"
@
action
=
"
showCreateModal = true
"
/>
<
/template
>
</DataTable>
</template>
<
template
#
pagination
>
<
Pagination
v
-
if
=
"
pagination.total > 0
"
:
page
=
"
pagination.page
"
:
total
=
"
pagination.total
"
:
page
-
size
=
"
pagination.page_size
"
@
update
:
page
=
"
handlePageChange
"
@
update
:
pageSize
=
"
handlePageSizeChange
"
/>
<
/template
>
<
template
#pagination
><Pagination
v-if=
"pagination.total > 0"
:page=
"pagination.page"
:total=
"pagination.total"
:page-size=
"pagination.page_size"
@
update:page=
"handlePageChange"
/></
template
>
</TablePageLayout>
<!--
Create
Account
Modal
-->
<
CreateAccountModal
:
show
=
"
showCreateModal
"
:
proxies
=
"
proxies
"
:
groups
=
"
groups
"
@
close
=
"
showCreateModal = false
"
@
created
=
"
() => { loadAccounts(); if (onboardingStore.isCurrentStep(`[data-tour='account-form-submit']`)) onboardingStore.nextStep(500)
}
"
/>
<!--
Edit
Account
Modal
-->
<
EditAccountModal
:
show
=
"
showEditModal
"
:
account
=
"
editingAccount
"
:
proxies
=
"
proxies
"
:
groups
=
"
groups
"
@
close
=
"
closeEditModal
"
@
updated
=
"
loadAccounts
"
/>
<!--
Re
-
Auth
Modal
-->
<
ReAuthAccountModal
:
show
=
"
showReAuthModal
"
:
account
=
"
reAuthAccount
"
@
close
=
"
closeReAuthModal
"
@
reauthorized
=
"
loadAccounts
"
/>
<!--
Test
Account
Modal
-->
<
AccountTestModal
:
show
=
"
showTestModal
"
:
account
=
"
testingAccount
"
@
close
=
"
closeTestModal
"
/>
<!--
Account
Stats
Modal
-->
<
AccountStatsModal
:
show
=
"
showStatsModal
"
:
account
=
"
statsAccount
"
@
close
=
"
closeStatsModal
"
/>
<!--
Temp
Unschedulable
Status
Modal
-->
<
TempUnschedStatusModal
:
show
=
"
showTempUnschedModal
"
:
account
=
"
tempUnschedAccount
"
@
close
=
"
closeTempUnschedModal
"
@
reset
=
"
handleTempUnschedReset
"
/>
<!--
Delete
Confirmation
Dialog
-->
<
ConfirmDialog
:
show
=
"
showDeleteDialog
"
:
title
=
"
t('admin.accounts.deleteAccount')
"
:
message
=
"
t('admin.accounts.deleteConfirm', { name: deletingAccount?.name
}
)
"
:
confirm
-
text
=
"
t('common.delete')
"
:
cancel
-
text
=
"
t('common.cancel')
"
:
danger
=
"
true
"
@
confirm
=
"
confirmDelete
"
@
cancel
=
"
showDeleteDialog = false
"
/>
<
ConfirmDialog
:
show
=
"
showBulkDeleteDialog
"
:
title
=
"
t('admin.accounts.bulkDeleteTitle')
"
:
message
=
"
t('admin.accounts.bulkDeleteConfirm', { count: selectedAccountIds.length
}
)
"
:
confirm
-
text
=
"
t('common.delete')
"
:
cancel
-
text
=
"
t('common.cancel')
"
:
danger
=
"
true
"
@
confirm
=
"
confirmBulkDelete
"
@
cancel
=
"
showBulkDeleteDialog = false
"
/>
<
SyncFromCrsModal
:
show
=
"
showCrsSyncModal
"
@
close
=
"
showCrsSyncModal = false
"
@
synced
=
"
handleCrsSynced
"
/>
<!--
Bulk
Edit
Account
Modal
-->
<
BulkEditAccountModal
:
show
=
"
showBulkEditModal
"
:
account
-
ids
=
"
selectedAccountIds
"
:
proxies
=
"
proxies
"
:
groups
=
"
groups
"
@
close
=
"
showBulkEditModal = false
"
@
updated
=
"
handleBulkUpdated
"
/>
<!--
Action
Menu
(
Teleported
)
-->
<
Teleport
to
=
"
body
"
>
<
div
v
-
if
=
"
activeMenuId !== null && menuPosition
"
class
=
"
action-menu-content fixed z-[9999] w-52 overflow-hidden rounded-xl bg-white shadow-lg ring-1 ring-black/5 dark:bg-dark-800 dark:ring-white/10
"
:
style
=
"
{ top: menuPosition.top + 'px', left: menuPosition.left + 'px'
}
"
>
<
div
class
=
"
py-1
"
>
<
template
v
-
for
=
"
account in accounts
"
:
key
=
"
account.id
"
>
<
template
v
-
if
=
"
account.id === activeMenuId
"
>
<
button
@
click
=
"
handleTest(account); closeActionMenu()
"
class
=
"
flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700
"
>
<
svg
class
=
"
h-4 w-4 text-green-500
"
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
>
{{
t
(
'
admin.accounts.testConnection
'
)
}}
<
/button
>
<
button
@
click
=
"
handleViewStats(account); closeActionMenu()
"
class
=
"
flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700
"
>
<
svg
class
=
"
h-4 w-4 text-indigo-500
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
><
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z
"
/><
/svg
>
{{
t
(
'
admin.accounts.viewStats
'
)
}}
<
/button
>
<
template
v
-
if
=
"
account.type === 'oauth' || account.type === 'setup-token'
"
>
<
button
@
click
=
"
handleReAuth(account); closeActionMenu()
"
class
=
"
flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700
"
>
<
svg
class
=
"
h-4 w-4 text-blue-500
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
><
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1
"
/><
/svg
>
{{
t
(
'
admin.accounts.reAuthorize
'
)
}}
<
/button
>
<
button
@
click
=
"
handleRefreshToken(account); closeActionMenu()
"
class
=
"
flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700
"
>
<
svg
class
=
"
h-4 w-4 text-purple-500
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
><
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M4 4v5h5M20 20v-5h-5M4 4l16 16
"
/><
/svg
>
{{
t
(
'
admin.accounts.refreshToken
'
)
}}
<
/button
>
<
/template
>
<
div
v
-
if
=
"
account.status === 'error' || isRateLimited(account) || isOverloaded(account)
"
class
=
"
my-1 border-t border-gray-100 dark:border-dark-700
"
><
/div
>
<
button
v
-
if
=
"
account.status === 'error'
"
@
click
=
"
handleResetStatus(account); closeActionMenu()
"
class
=
"
flex w-full items-center gap-2 px-4 py-2 text-sm text-yellow-600 hover:bg-gray-100 dark:text-yellow-400 dark:hover:bg-dark-700
"
>
<
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
=
"
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
>
{{
t
(
'
admin.accounts.resetStatus
'
)
}}
<
/button
>
<
button
v
-
if
=
"
isRateLimited(account) || isOverloaded(account)
"
@
click
=
"
handleClearRateLimit(account); closeActionMenu()
"
class
=
"
flex w-full items-center gap-2 px-4 py-2 text-sm text-amber-600 hover:bg-gray-100 dark:text-amber-400 dark:hover:bg-dark-700
"
>
<
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
=
"
M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z
"
/><
/svg
>
{{
t
(
'
admin.accounts.clearRateLimit
'
)
}}
<
/button
>
<
/template
>
<
/template
>
<
/div
>
<
/div
>
<
/Teleport
>
<CreateAccountModal
:show=
"showCreate"
:proxies=
"proxies"
:groups=
"groups"
@
close=
"showCreate = false"
@
created=
"reload"
/>
<EditAccountModal
:show=
"showEdit"
:account=
"edAcc"
:proxies=
"proxies"
:groups=
"groups"
@
close=
"showEdit = false"
@
updated=
"load"
/>
<ReAuthAccountModal
:show=
"showReAuth"
:account=
"reAuthAcc"
@
close=
"closeReAuthModal"
@
reauthorized=
"load"
/>
<AccountTestModal
:show=
"showTest"
:account=
"testingAcc"
@
close=
"closeTestModal"
/>
<AccountStatsModal
:show=
"showStats"
:account=
"statsAcc"
@
close=
"closeStatsModal"
/>
<AccountActionMenu
:show=
"menu.show"
:account=
"menu.acc"
:position=
"menu.pos"
@
close=
"menu.show = false"
@
test=
"handleTest"
@
stats=
"handleViewStats"
@
reauth=
"handleReAuth"
@
refresh-token=
"handleRefresh"
@
reset-status=
"handleResetStatus"
@
clear-rate-limit=
"handleClearRateLimit"
/>
<SyncFromCrsModal
:show=
"showSync"
@
close=
"showSync = false"
@
synced=
"reload"
/>
<BulkEditAccountModal
:show=
"showBulkEdit"
:account-ids=
"selIds"
:proxies=
"proxies"
:groups=
"groups"
@
close=
"showBulkEdit = false"
@
updated=
"handleBulkUpdated"
/>
<TempUnschedStatusModal
:show=
"showTempUnsched"
:account=
"tempUnschedAcc"
@
close=
"showTempUnsched = false"
@
reset=
"handleTempUnschedReset"
/>
<ConfirmDialog
:show=
"showDeleteDialog"
:title=
"t('admin.accounts.deleteAccount')"
:message=
"t('admin.accounts.deleteConfirm', { name: deletingAcc?.name })"
:confirm-text=
"t('common.delete')"
:cancel-text=
"t('common.cancel')"
:danger=
"true"
@
confirm=
"confirmDelete"
@
cancel=
"showDeleteDialog = false"
/>
</AppLayout>
</template>
<
script
setup
lang=
"ts"
>
import
{
ref
,
reactive
,
computed
,
onMounted
,
onUnmounted
,
type
ComponentPublicInstance
}
from
'
vue
'
import
{
ref
,
reactive
,
computed
,
onMounted
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
useAuthStore
}
from
'
@/stores/auth
'
import
{
useOnboardingStore
}
from
'
@/stores/onboarding
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
type
{
Account
,
Proxy
,
Group
}
from
'
@/types
'
import
type
{
Column
}
from
'
@/components/common/types
'
import
{
useTableLoader
}
from
'
@/composables/useTableLoader
'
import
AppLayout
from
'
@/components/layout/AppLayout.vue
'
import
TablePageLayout
from
'
@/components/layout/TablePageLayout.vue
'
import
DataTable
from
'
@/components/common/DataTable.vue
'
import
Pagination
from
'
@/components/common/Pagination.vue
'
import
ConfirmDialog
from
'
@/components/common/ConfirmDialog.vue
'
import
EmptyState
from
'
@/components/common/EmptyState.vue
'
import
Select
from
'
@/components/common/Select.vue
'
import
{
CreateAccountModal
,
EditAccountModal
,
BulkEditAccountModal
,
ReAuthAccountModal
,
AccountStatsModal
,
TempUnschedStatusModal
,
SyncFromCrsModal
}
from
'
@/components/account
'
import
{
CreateAccountModal
,
EditAccountModal
,
BulkEditAccountModal
,
SyncFromCrsModal
,
TempUnschedStatusModal
}
from
'
@/components/account
'
import
AccountTableActions
from
'
@/components/admin/account/AccountTableActions.vue
'
import
AccountTableFilters
from
'
@/components/admin/account/AccountTableFilters.vue
'
import
AccountBulkActionsBar
from
'
@/components/admin/account/AccountBulkActionsBar.vue
'
import
AccountActionMenu
from
'
@/components/admin/account/AccountActionMenu.vue
'
import
ReAuthAccountModal
from
'
@/components/admin/account/ReAuthAccountModal.vue
'
import
AccountTestModal
from
'
@/components/admin/account/AccountTestModal.vue
'
import
AccountStatsModal
from
'
@/components/admin/account/AccountStatsModal.vue
'
import
AccountStatusIndicator
from
'
@/components/account/AccountStatusIndicator.vue
'
import
AccountUsageCell
from
'
@/components/account/AccountUsageCell.vue
'
import
AccountTodayStatsCell
from
'
@/components/account/AccountTodayStatsCell.vue
'
import
AccountTestModal
from
'
@/components/account/AccountTestModal.vue
'
import
GroupBadge
from
'
@/components/common/GroupBadge.vue
'
import
PlatformTypeBadge
from
'
@/components/common/PlatformTypeBadge.vue
'
import
{
formatRelativeTime
}
from
'
@/utils/format
'
import
type
{
Account
,
Proxy
,
Group
}
from
'
@/types
'
const
{
t
}
=
useI18n
()
const
appStore
=
useAppStore
()
const
authStore
=
useAuthStore
()
const
onboardingStore
=
useOnboardingStore
()
// Table columns
const
columns
=
computed
<
Column
[]
>
(()
=>
{
const
cols
:
Column
[]
=
[
const
proxies
=
ref
<
Proxy
[]
>
([])
const
groups
=
ref
<
Group
[]
>
([])
const
selIds
=
ref
<
number
[]
>
([])
const
showCreate
=
ref
(
false
)
const
showEdit
=
ref
(
false
)
const
showSync
=
ref
(
false
)
const
showBulkEdit
=
ref
(
false
)
const
showTempUnsched
=
ref
(
false
)
const
showDeleteDialog
=
ref
(
false
)
const
showReAuth
=
ref
(
false
)
const
showTest
=
ref
(
false
)
const
showStats
=
ref
(
false
)
const
edAcc
=
ref
<
Account
|
null
>
(
null
)
const
tempUnschedAcc
=
ref
<
Account
|
null
>
(
null
)
const
deletingAcc
=
ref
<
Account
|
null
>
(
null
)
const
reAuthAcc
=
ref
<
Account
|
null
>
(
null
)
const
testingAcc
=
ref
<
Account
|
null
>
(
null
)
const
statsAcc
=
ref
<
Account
|
null
>
(
null
)
const
togglingSchedulable
=
ref
<
number
|
null
>
(
null
)
const
menu
=
reactive
<
{
show
:
boolean
,
acc
:
Account
|
null
,
pos
:{
top
:
number
,
left
:
number
}
|
null
}
>
({
show
:
false
,
acc
:
null
,
pos
:
null
})
const
{
items
:
accounts
,
loading
,
params
,
pagination
,
load
,
reload
,
debouncedReload
,
handlePageChange
}
=
useTableLoader
<
Account
,
any
>
({
fetchFn
:
adminAPI
.
accounts
.
list
,
initialParams
:
{
platform
:
''
,
type
:
''
,
status
:
''
,
search
:
''
}
})
const
cols
=
computed
(()
=>
{
const
c
=
[
{
key
:
'
select
'
,
label
:
''
,
sortable
:
false
},
{
key
:
'
name
'
,
label
:
t
(
'
admin.accounts.columns.name
'
),
sortable
:
true
},
{
key
:
'
platform_type
'
,
label
:
t
(
'
admin.accounts.columns.platformType
'
),
sortable
:
false
},
...
...
@@ -547,428 +170,38 @@ const columns = computed<Column[]>(() => {
{
key
:
'
schedulable
'
,
label
:
t
(
'
admin.accounts.columns.schedulable
'
),
sortable
:
true
},
{
key
:
'
today_stats
'
,
label
:
t
(
'
admin.accounts.columns.todayStats
'
),
sortable
:
false
}
]
// 简易模式下不显示分组列
if
(
!
authStore
.
isSimpleMode
)
{
c
ols
.
push
({
key
:
'
groups
'
,
label
:
t
(
'
admin.accounts.columns.groups
'
),
sortable
:
false
}
)
c
.
push
({
key
:
'
groups
'
,
label
:
t
(
'
admin.accounts.columns.groups
'
),
sortable
:
false
})
}
cols
.
push
(
c
.
push
(
{
key
:
'
usage
'
,
label
:
t
(
'
admin.accounts.columns.usageWindows
'
),
sortable
:
false
},
{
key
:
'
priority
'
,
label
:
t
(
'
admin.accounts.columns.priority
'
),
sortable
:
true
},
{
key
:
'
last_used_at
'
,
label
:
t
(
'
admin.accounts.columns.lastUsed
'
),
sortable
:
true
},
{
key
:
'
actions
'
,
label
:
t
(
'
admin.accounts.columns.actions
'
),
sortable
:
false
}
)
return
cols
}
)
// Filter options
const
platformOptions
=
computed
(()
=>
[
{
value
:
''
,
label
:
t
(
'
admin.accounts.allPlatforms
'
)
}
,
{
value
:
'
anthropic
'
,
label
:
t
(
'
admin.accounts.platforms.anthropic
'
)
}
,
{
value
:
'
openai
'
,
label
:
t
(
'
admin.accounts.platforms.openai
'
)
}
,
{
value
:
'
gemini
'
,
label
:
t
(
'
admin.accounts.platforms.gemini
'
)
}
,
{
value
:
'
antigravity
'
,
label
:
t
(
'
admin.accounts.platforms.antigravity
'
)
}
])
const
typeOptions
=
computed
(()
=>
[
{
value
:
''
,
label
:
t
(
'
admin.accounts.allTypes
'
)
}
,
{
value
:
'
oauth
'
,
label
:
t
(
'
admin.accounts.oauthType
'
)
}
,
{
value
:
'
setup-token
'
,
label
:
t
(
'
admin.accounts.setupToken
'
)
}
,
{
value
:
'
apikey
'
,
label
:
t
(
'
admin.accounts.apiKey
'
)
}
])
const
statusOptions
=
computed
(()
=>
[
{
value
:
''
,
label
:
t
(
'
admin.accounts.allStatus
'
)
}
,
{
value
:
'
active
'
,
label
:
t
(
'
common.active
'
)
}
,
{
value
:
'
inactive
'
,
label
:
t
(
'
common.inactive
'
)
}
,
{
value
:
'
error
'
,
label
:
t
(
'
common.error
'
)
}
])
// State
const
accounts
=
ref
<
Account
[]
>
([])
const
proxies
=
ref
<
Proxy
[]
>
([])
const
groups
=
ref
<
Group
[]
>
([])
const
loading
=
ref
(
false
)
const
searchQuery
=
ref
(
''
)
const
filters
=
reactive
({
platform
:
''
,
type
:
''
,
status
:
''
}
)
const
pagination
=
reactive
({
page
:
1
,
page_size
:
20
,
total
:
0
,
pages
:
0
}
)
let
abortController
:
AbortController
|
null
=
null
// Modal states
const
showCreateModal
=
ref
(
false
)
const
showEditModal
=
ref
(
false
)
const
showReAuthModal
=
ref
(
false
)
const
showDeleteDialog
=
ref
(
false
)
const
showBulkDeleteDialog
=
ref
(
false
)
const
showTestModal
=
ref
(
false
)
const
showStatsModal
=
ref
(
false
)
const
showTempUnschedModal
=
ref
(
false
)
const
showCrsSyncModal
=
ref
(
false
)
const
showBulkEditModal
=
ref
(
false
)
const
editingAccount
=
ref
<
Account
|
null
>
(
null
)
const
reAuthAccount
=
ref
<
Account
|
null
>
(
null
)
const
deletingAccount
=
ref
<
Account
|
null
>
(
null
)
const
testingAccount
=
ref
<
Account
|
null
>
(
null
)
const
statsAccount
=
ref
<
Account
|
null
>
(
null
)
const
tempUnschedAccount
=
ref
<
Account
|
null
>
(
null
)
const
togglingSchedulable
=
ref
<
number
|
null
>
(
null
)
const
bulkDeleting
=
ref
(
false
)
// Action Menu State
const
activeMenuId
=
ref
<
number
|
null
>
(
null
)
const
menuPosition
=
ref
<
{
top
:
number
;
left
:
number
}
|
null
>
(
null
)
const
actionButtonRefs
=
ref
<
Map
<
number
,
HTMLElement
>>
(
new
Map
())
const
setActionButtonRef
=
(
accountId
:
number
,
el
:
Element
|
ComponentPublicInstance
|
null
)
=>
{
if
(
el
instanceof
HTMLElement
)
{
actionButtonRefs
.
value
.
set
(
accountId
,
el
)
}
else
{
actionButtonRefs
.
value
.
delete
(
accountId
)
}
}
const
openActionMenu
=
(
account
:
Account
)
=>
{
if
(
activeMenuId
.
value
===
account
.
id
)
{
closeActionMenu
()
}
else
{
const
buttonEl
=
actionButtonRefs
.
value
.
get
(
account
.
id
)
if
(
buttonEl
)
{
const
rect
=
buttonEl
.
getBoundingClientRect
()
// Position menu to the left of the button, slightly below
menuPosition
.
value
=
{
top
:
rect
.
bottom
+
4
,
left
:
rect
.
right
-
208
// w-52 is 208px
}
}
activeMenuId
.
value
=
account
.
id
}
}
const
closeActionMenu
=
()
=>
{
activeMenuId
.
value
=
null
menuPosition
.
value
=
null
}
// Close menu when clicking outside
const
handleClickOutside
=
(
event
:
MouseEvent
)
=>
{
const
target
=
event
.
target
as
HTMLElement
if
(
!
target
.
closest
(
'
.action-menu-trigger
'
)
&&
!
target
.
closest
(
'
.action-menu-content
'
))
{
closeActionMenu
()
}
}
// Bulk selection
const
selectedAccountIds
=
ref
<
number
[]
>
([])
const
selectCurrentPageAccounts
=
()
=>
{
const
pageIds
=
accounts
.
value
.
map
((
account
)
=>
account
.
id
)
const
merged
=
new
Set
([...
selectedAccountIds
.
value
,
...
pageIds
])
selectedAccountIds
.
value
=
Array
.
from
(
merged
)
}
// Rate limit / Overload helpers
const
isRateLimited
=
(
account
:
Account
):
boolean
=>
{
if
(
!
account
.
rate_limit_reset_at
)
return
false
return
new
Date
(
account
.
rate_limit_reset_at
)
>
new
Date
()
}
const
isOverloaded
=
(
account
:
Account
):
boolean
=>
{
if
(
!
account
.
overload_until
)
return
false
return
new
Date
(
account
.
overload_until
)
>
new
Date
()
}
// Data loading
const
loadAccounts
=
async
()
=>
{
abortController
?.
abort
()
const
currentAbortController
=
new
AbortController
()
abortController
=
currentAbortController
loading
.
value
=
true
try
{
const
response
=
await
adminAPI
.
accounts
.
list
(
pagination
.
page
,
pagination
.
page_size
,
{
platform
:
filters
.
platform
||
undefined
,
type
:
filters
.
type
||
undefined
,
status
:
filters
.
status
||
undefined
,
search
:
searchQuery
.
value
||
undefined
}
,
{
signal
:
currentAbortController
.
signal
}
)
if
(
currentAbortController
.
signal
.
aborted
)
return
accounts
.
value
=
response
.
items
pagination
.
total
=
response
.
total
pagination
.
pages
=
response
.
pages
}
catch
(
error
)
{
const
errorInfo
=
error
as
{
name
?:
string
;
code
?:
string
}
if
(
errorInfo
?.
name
===
'
AbortError
'
||
errorInfo
?.
name
===
'
CanceledError
'
||
errorInfo
?.
code
===
'
ERR_CANCELED
'
)
{
return
}
appStore
.
showError
(
t
(
'
admin.accounts.failedToLoad
'
))
console
.
error
(
'
Error loading accounts:
'
,
error
)
}
finally
{
if
(
abortController
===
currentAbortController
)
{
loading
.
value
=
false
}
}
}
const
loadProxies
=
async
()
=>
{
try
{
proxies
.
value
=
await
adminAPI
.
proxies
.
getAllWithCount
()
}
catch
(
error
)
{
console
.
error
(
'
Error loading proxies:
'
,
error
)
}
}
const
loadGroups
=
async
()
=>
{
try
{
// Load groups for all platforms to support both Anthropic and OpenAI accounts
groups
.
value
=
await
adminAPI
.
groups
.
getAll
()
}
catch
(
error
)
{
console
.
error
(
'
Error loading groups:
'
,
error
)
}
}
// Search handling
let
searchTimeout
:
ReturnType
<
typeof
setTimeout
>
const
handleSearch
=
()
=>
{
clearTimeout
(
searchTimeout
)
searchTimeout
=
setTimeout
(()
=>
{
pagination
.
page
=
1
loadAccounts
()
}
,
300
)
}
// Pagination
const
handlePageChange
=
(
page
:
number
)
=>
{
pagination
.
page
=
page
loadAccounts
()
}
const
handlePageSizeChange
=
(
pageSize
:
number
)
=>
{
pagination
.
page_size
=
pageSize
pagination
.
page
=
1
loadAccounts
()
}
const
handleCrsSynced
=
()
=>
{
showCrsSyncModal
.
value
=
false
loadAccounts
()
}
// Edit modal
const
handleEdit
=
(
account
:
Account
)
=>
{
editingAccount
.
value
=
account
showEditModal
.
value
=
true
}
const
closeEditModal
=
()
=>
{
showEditModal
.
value
=
false
editingAccount
.
value
=
null
}
// Re-Auth modal
const
handleReAuth
=
(
account
:
Account
)
=>
{
reAuthAccount
.
value
=
account
showReAuthModal
.
value
=
true
}
const
closeReAuthModal
=
()
=>
{
showReAuthModal
.
value
=
false
reAuthAccount
.
value
=
null
}
// Temp unschedulable modal
const
handleShowTempUnsched
=
(
account
:
Account
)
=>
{
tempUnschedAccount
.
value
=
account
showTempUnschedModal
.
value
=
true
}
const
closeTempUnschedModal
=
()
=>
{
showTempUnschedModal
.
value
=
false
tempUnschedAccount
.
value
=
null
}
const
handleTempUnschedReset
=
()
=>
{
loadAccounts
()
}
// Token refresh
const
handleRefreshToken
=
async
(
account
:
Account
)
=>
{
try
{
await
adminAPI
.
accounts
.
refreshCredentials
(
account
.
id
)
appStore
.
showSuccess
(
t
(
'
admin.accounts.tokenRefreshed
'
))
loadAccounts
()
}
catch
(
error
:
any
)
{
appStore
.
showError
(
error
.
response
?.
data
?.
detail
||
t
(
'
admin.accounts.failedToRefresh
'
))
console
.
error
(
'
Error refreshing token:
'
,
error
)
}
}
// Delete
const
handleDelete
=
(
account
:
Account
)
=>
{
deletingAccount
.
value
=
account
showDeleteDialog
.
value
=
true
}
const
confirmDelete
=
async
()
=>
{
if
(
!
deletingAccount
.
value
)
return
try
{
await
adminAPI
.
accounts
.
delete
(
deletingAccount
.
value
.
id
)
appStore
.
showSuccess
(
t
(
'
admin.accounts.accountDeleted
'
))
showDeleteDialog
.
value
=
false
deletingAccount
.
value
=
null
loadAccounts
()
}
catch
(
error
:
any
)
{
appStore
.
showError
(
error
.
response
?.
data
?.
detail
||
t
(
'
admin.accounts.failedToDelete
'
))
console
.
error
(
'
Error deleting account:
'
,
error
)
}
}
const
handleBulkDelete
=
()
=>
{
if
(
selectedAccountIds
.
value
.
length
===
0
)
return
showBulkDeleteDialog
.
value
=
true
}
const
confirmBulkDelete
=
async
()
=>
{
if
(
bulkDeleting
.
value
||
selectedAccountIds
.
value
.
length
===
0
)
return
bulkDeleting
.
value
=
true
const
ids
=
[...
selectedAccountIds
.
value
]
try
{
const
results
=
await
Promise
.
allSettled
(
ids
.
map
((
id
)
=>
adminAPI
.
accounts
.
delete
(
id
)))
const
success
=
results
.
filter
((
result
)
=>
result
.
status
===
'
fulfilled
'
).
length
const
failed
=
results
.
length
-
success
if
(
failed
===
0
)
{
appStore
.
showSuccess
(
t
(
'
admin.accounts.bulkDeleteSuccess
'
,
{
count
:
success
}
))
}
else
{
appStore
.
showError
(
t
(
'
admin.accounts.bulkDeletePartial
'
,
{
success
,
failed
}
))
}
showBulkDeleteDialog
.
value
=
false
selectedAccountIds
.
value
=
[]
loadAccounts
()
}
catch
(
error
:
any
)
{
appStore
.
showError
(
error
.
response
?.
data
?.
detail
||
t
(
'
admin.accounts.bulkDeleteFailed
'
))
console
.
error
(
'
Error deleting accounts:
'
,
error
)
}
finally
{
bulkDeleting
.
value
=
false
}
}
// Clear rate limit
const
handleClearRateLimit
=
async
(
account
:
Account
)
=>
{
try
{
await
adminAPI
.
accounts
.
clearRateLimit
(
account
.
id
)
appStore
.
showSuccess
(
t
(
'
admin.accounts.rateLimitCleared
'
))
loadAccounts
()
}
catch
(
error
:
any
)
{
appStore
.
showError
(
error
.
response
?.
data
?.
detail
||
t
(
'
admin.accounts.failedToClearRateLimit
'
))
console
.
error
(
'
Error clearing rate limit:
'
,
error
)
}
}
// Reset account status (clear error and rate limit)
const
handleResetStatus
=
async
(
account
:
Account
)
=>
{
try
{
// Clear error status
await
adminAPI
.
accounts
.
clearError
(
account
.
id
)
// Also clear rate limit if exists
if
(
isRateLimited
(
account
)
||
isOverloaded
(
account
))
{
await
adminAPI
.
accounts
.
clearRateLimit
(
account
.
id
)
}
appStore
.
showSuccess
(
t
(
'
admin.accounts.statusReset
'
))
loadAccounts
()
}
catch
(
error
:
any
)
{
appStore
.
showError
(
error
.
response
?.
data
?.
detail
||
t
(
'
admin.accounts.failedToResetStatus
'
))
console
.
error
(
'
Error resetting account status:
'
,
error
)
}
}
// Toggle schedulable
const
handleToggleSchedulable
=
async
(
account
:
Account
)
=>
{
togglingSchedulable
.
value
=
account
.
id
try
{
const
updatedAccount
=
await
adminAPI
.
accounts
.
setSchedulable
(
account
.
id
,
!
account
.
schedulable
)
const
index
=
accounts
.
value
.
findIndex
((
a
)
=>
a
.
id
===
account
.
id
)
if
(
index
!==
-
1
)
{
accounts
.
value
[
index
]
=
updatedAccount
}
appStore
.
showSuccess
(
updatedAccount
.
schedulable
?
t
(
'
admin.accounts.schedulableEnabled
'
)
:
t
(
'
admin.accounts.schedulableDisabled
'
)
)
}
catch
(
error
:
any
)
{
appStore
.
showError
(
error
.
response
?.
data
?.
detail
||
t
(
'
admin.accounts.failedToToggleSchedulable
'
)
)
console
.
error
(
'
Error toggling schedulable:
'
,
error
)
}
finally
{
togglingSchedulable
.
value
=
null
}
}
// Test modal
const
handleTest
=
(
account
:
Account
)
=>
{
testingAccount
.
value
=
account
showTestModal
.
value
=
true
}
const
closeTestModal
=
()
=>
{
showTestModal
.
value
=
false
testingAccount
.
value
=
null
}
// Stats modal
const
handleViewStats
=
(
account
:
Account
)
=>
{
statsAccount
.
value
=
account
showStatsModal
.
value
=
true
}
const
closeStatsModal
=
()
=>
{
showStatsModal
.
value
=
false
statsAccount
.
value
=
null
}
// Bulk selection toggle
const
toggleAccountSelection
=
(
accountId
:
number
)
=>
{
const
index
=
selectedAccountIds
.
value
.
indexOf
(
accountId
)
if
(
index
===
-
1
)
{
selectedAccountIds
.
value
.
push
(
accountId
)
}
else
{
selectedAccountIds
.
value
.
splice
(
index
,
1
)
}
}
// Bulk update handler
const
handleBulkUpdated
=
()
=>
{
showBulkEditModal
.
value
=
false
selectedAccountIds
.
value
=
[]
loadAccounts
()
}
// Initialize
onMounted
(()
=>
{
loadAccounts
()
loadProxies
()
loadGroups
()
document
.
addEventListener
(
'
click
'
,
handleClickOutside
)
return
c
})
onUnmounted
(()
=>
{
abortController
?.
abort
()
abortController
=
null
document
.
removeEventListener
(
'
click
'
,
handleClickOutside
)
}
)
const
handleEdit
=
(
a
:
Account
)
=>
{
edAcc
.
value
=
a
;
showEdit
.
value
=
true
}
const
openMenu
=
(
a
:
Account
,
e
:
MouseEvent
)
=>
{
menu
.
acc
=
a
;
menu
.
pos
=
{
top
:
e
.
clientY
,
left
:
e
.
clientX
-
200
};
menu
.
show
=
true
}
const
toggleSel
=
(
id
:
number
)
=>
{
const
i
=
selIds
.
value
.
indexOf
(
id
);
if
(
i
===
-
1
)
selIds
.
value
.
push
(
id
);
else
selIds
.
value
.
splice
(
i
,
1
)
}
const
selectPage
=
()
=>
{
selIds
.
value
=
[...
new
Set
([...
selIds
.
value
,
...
accounts
.
value
.
map
(
a
=>
a
.
id
)])]
}
const
handleBulkDelete
=
async
()
=>
{
if
(
!
confirm
(
t
(
'
common.confirm
'
)))
return
;
try
{
await
Promise
.
all
(
selIds
.
value
.
map
(
id
=>
adminAPI
.
accounts
.
delete
(
id
)));
selIds
.
value
=
[];
reload
()
}
catch
{}
}
const
handleBulkUpdated
=
()
=>
{
showBulkEdit
.
value
=
false
;
selIds
.
value
=
[];
reload
()
}
const
closeTestModal
=
()
=>
{
showTest
.
value
=
false
;
testingAcc
.
value
=
null
}
const
closeStatsModal
=
()
=>
{
showStats
.
value
=
false
;
statsAcc
.
value
=
null
}
const
closeReAuthModal
=
()
=>
{
showReAuth
.
value
=
false
;
reAuthAcc
.
value
=
null
}
const
handleTest
=
(
a
:
Account
)
=>
{
testingAcc
.
value
=
a
;
showTest
.
value
=
true
}
const
handleViewStats
=
(
a
:
Account
)
=>
{
statsAcc
.
value
=
a
;
showStats
.
value
=
true
}
const
handleReAuth
=
(
a
:
Account
)
=>
{
reAuthAcc
.
value
=
a
;
showReAuth
.
value
=
true
}
const
handleRefresh
=
async
(
a
:
Account
)
=>
{
try
{
await
adminAPI
.
accounts
.
refreshCredentials
(
a
.
id
);
load
()
}
catch
{}
}
const
handleResetStatus
=
async
(
a
:
Account
)
=>
{
try
{
await
adminAPI
.
accounts
.
clearError
(
a
.
id
);
appStore
.
showSuccess
(
t
(
'
common.success
'
));
load
()
}
catch
{}
}
const
handleClearRateLimit
=
async
(
a
:
Account
)
=>
{
try
{
await
adminAPI
.
accounts
.
clearError
(
a
.
id
);
appStore
.
showSuccess
(
t
(
'
common.success
'
));
load
()
}
catch
{}
}
const
handleDelete
=
(
a
:
Account
)
=>
{
deletingAcc
.
value
=
a
;
showDeleteDialog
.
value
=
true
}
const
confirmDelete
=
async
()
=>
{
if
(
!
deletingAcc
.
value
)
return
;
try
{
await
adminAPI
.
accounts
.
delete
(
deletingAcc
.
value
.
id
);
showDeleteDialog
.
value
=
false
;
deletingAcc
.
value
=
null
;
reload
()
}
catch
{}
}
const
handleToggleSchedulable
=
async
(
a
:
Account
)
=>
{
togglingSchedulable
.
value
=
a
.
id
;
try
{
await
adminAPI
.
accounts
.
update
(
a
.
id
,
{
schedulable
:
!
a
.
schedulable
});
load
()
}
finally
{
togglingSchedulable
.
value
=
null
}
}
const
handleShowTempUnsched
=
(
a
:
Account
)
=>
{
tempUnschedAcc
.
value
=
a
;
showTempUnsched
.
value
=
true
}
const
handleTempUnschedReset
=
async
()
=>
{
if
(
!
tempUnschedAcc
.
value
)
return
;
try
{
await
adminAPI
.
accounts
.
clearError
(
tempUnschedAcc
.
value
.
id
);
showTempUnsched
.
value
=
false
;
tempUnschedAcc
.
value
=
null
;
load
()
}
catch
{}
}
onMounted
(
async
()
=>
{
load
();
try
{
const
[
p
,
g
]
=
await
Promise
.
all
([
adminAPI
.
proxies
.
getAll
(),
adminAPI
.
groups
.
getAll
()]);
proxies
.
value
=
p
;
groups
.
value
=
g
}
catch
{}
})
</
script
>
frontend/src/views/admin/DashboardView.vue
View file @
fd29fe11
...
...
@@ -504,7 +504,7 @@ const userTrendChartData = computed(() => {
if
(
email
&&
email
.
includes
(
'
@
'
))
{
return
email
.
split
(
'
@
'
)[
0
]
}
return
`User #
${
userId
}
`
return
t
(
'
admin.redeem.userPrefix
'
,
{
id
:
userId
})
}
// Group by user
...
...
@@ -652,16 +652,4 @@ onMounted(() => {
</
script
>
<
style
scoped
>
/* Compact Select styling for dashboard */
:deep
(
.select-trigger
)
{
@apply
rounded-lg
px-3
py-1.5
text-sm;
}
:deep
(
.select-dropdown
)
{
@apply
rounded-lg;
}
:deep
(
.select-option
)
{
@apply
px-3
py-2
text-sm;
}
</
style
>
frontend/src/views/admin/GroupsView.vue
View file @
fd29fe11
<
template
>
<AppLayout>
<TablePageLayout>
<template
#actions
>
<div
class=
"flex justify-end gap-3"
>
<template
#filters
>
<div
class=
"flex flex-col justify-between gap-4 lg:flex-row lg:items-start"
>
<!-- Left: fuzzy search + filters (can wrap to multiple lines) -->
<div
class=
"flex flex-1 flex-wrap items-center gap-3"
>
<div
class=
"relative w-full sm:w-72 lg:w-80"
>
<svg
class=
"absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 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
v-model=
"searchQuery"
type=
"text"
:placeholder=
"t('admin.groups.searchGroups')"
class=
"input pl-10"
/>
</div>
<Select
v-model=
"filters.platform"
:options=
"platformFilterOptions"
:placeholder=
"t('admin.groups.allPlatforms')"
class=
"w-44"
@
change=
"loadGroups"
/>
<Select
v-model=
"filters.status"
:options=
"statusOptions"
:placeholder=
"t('admin.groups.allStatus')"
class=
"w-40"
@
change=
"loadGroups"
/>
<Select
v-model=
"filters.is_exclusive"
:options=
"exclusiveOptions"
:placeholder=
"t('admin.groups.allGroups')"
class=
"w-44"
@
change=
"loadGroups"
/>
</div>
<!-- Right: actions -->
<div
class=
"flex w-full flex-shrink-0 flex-wrap items-center justify-end gap-3 lg:w-auto"
>
<button
@
click=
"loadGroups"
:disabled=
"loading"
...
...
@@ -35,41 +83,20 @@
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M12 4.5v15m7.5-7.5h-15"
/>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M12 4.5v15m7.5-7.5h-15"
/>
</svg>
{{
t
(
'
admin.groups.createGroup
'
)
}}
</button>
</div>
</
template
>
<
template
#filters
>
<div
class=
"flex flex-wrap gap-3"
>
<Select
v-model=
"filters.platform"
:options=
"platformFilterOptions"
:placeholder=
"t('admin.groups.allPlatforms')"
class=
"w-44"
@
change=
"loadGroups"
/>
<Select
v-model=
"filters.status"
:options=
"statusOptions"
:placeholder=
"t('admin.groups.allStatus')"
class=
"w-40"
@
change=
"loadGroups"
/>
<Select
v-model=
"filters.is_exclusive"
:options=
"exclusiveOptions"
:placeholder=
"t('admin.groups.allGroups')"
class=
"w-44"
@
change=
"loadGroups"
/>
</div>
</
template
>
<
template
#table
>
<DataTable
:columns=
"columns"
:data=
"
g
roups"
:loading=
"loading"
>
<DataTable
:columns=
"columns"
:data=
"
displayedG
roups"
:loading=
"loading"
>
<template
#cell-name
="
{ value }">
<span
class=
"font-medium text-gray-900 dark:text-white"
>
{{
value
}}
</span>
</
template
>
...
...
@@ -88,15 +115,7 @@
]"
>
<PlatformIcon
:platform=
"value"
size=
"xs"
/>
{{
value
===
'
anthropic
'
?
'
Anthropic
'
:
value
===
'
openai
'
?
'
OpenAI
'
:
value
===
'
antigravity
'
?
'
Antigravity
'
:
'
Gemini
'
}}
{{
t
(
'
admin.groups.platforms.
'
+
value
)
}}
</span>
</
template
>
...
...
@@ -172,7 +191,7 @@
<
template
#
cell
-
status
=
"
{ value
}
"
>
<
span
:
class
=
"
['badge', value === 'active' ? 'badge-success' : 'badge-danger']
"
>
{{
value
}}
{{
t
(
'
admin.accounts.status.
'
+
value
)
}}
<
/span
>
<
/template
>
...
...
@@ -691,8 +710,8 @@ const columns = computed<Column[]>(() => [
// Filter options
const
statusOptions
=
computed
(()
=>
[
{
value
:
''
,
label
:
t
(
'
admin.groups.allStatus
'
)
}
,
{
value
:
'
active
'
,
label
:
t
(
'
common
.active
'
)
}
,
{
value
:
'
inactive
'
,
label
:
t
(
'
common
.inactive
'
)
}
{
value
:
'
active
'
,
label
:
t
(
'
admin.accounts.status
.active
'
)
}
,
{
value
:
'
inactive
'
,
label
:
t
(
'
admin.accounts.status
.inactive
'
)
}
])
const
exclusiveOptions
=
computed
(()
=>
[
...
...
@@ -717,8 +736,8 @@ const platformFilterOptions = computed(() => [
])
const
editStatusOptions
=
computed
(()
=>
[
{
value
:
'
active
'
,
label
:
t
(
'
common
.active
'
)
}
,
{
value
:
'
inactive
'
,
label
:
t
(
'
common
.inactive
'
)
}
{
value
:
'
active
'
,
label
:
t
(
'
admin.accounts.status
.active
'
)
}
,
{
value
:
'
inactive
'
,
label
:
t
(
'
admin.accounts.status
.inactive
'
)
}
])
const
subscriptionTypeOptions
=
computed
(()
=>
[
...
...
@@ -728,6 +747,7 @@ const subscriptionTypeOptions = computed(() => [
const
groups
=
ref
<
Group
[]
>
([])
const
loading
=
ref
(
false
)
const
searchQuery
=
ref
(
''
)
const
filters
=
reactive
({
platform
:
''
,
status
:
''
,
...
...
@@ -742,6 +762,16 @@ const pagination = reactive({
let
abortController
:
AbortController
|
null
=
null
const
displayedGroups
=
computed
(()
=>
{
const
q
=
searchQuery
.
value
.
trim
().
toLowerCase
()
if
(
!
q
)
return
groups
.
value
return
groups
.
value
.
filter
((
group
)
=>
{
const
name
=
group
.
name
?.
toLowerCase
?.()
??
''
const
description
=
group
.
description
?.
toLowerCase
?.()
??
''
return
name
.
includes
(
q
)
||
description
.
includes
(
q
)
}
)
}
)
const
showCreateModal
=
ref
(
false
)
const
showEditModal
=
ref
(
false
)
const
showDeleteDialog
=
ref
(
false
)
...
...
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