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
91c9b8d0
Commit
91c9b8d0
authored
Apr 04, 2026
by
erio
Browse files
feat(channel): 渠道管理系统 — 多模式定价 + 统一计费解析
Cherry-picked from release/custom-0.1.106: a9117600
parent
b384570d
Changes
27
Hide whitespace changes
Inline
Side-by-side
frontend/src/api/admin/index.ts
View file @
91c9b8d0
...
@@ -25,6 +25,7 @@ import apiKeysAPI from './apiKeys'
...
@@ -25,6 +25,7 @@ import apiKeysAPI from './apiKeys'
import
scheduledTestsAPI
from
'
./scheduledTests
'
import
scheduledTestsAPI
from
'
./scheduledTests
'
import
backupAPI
from
'
./backup
'
import
backupAPI
from
'
./backup
'
import
tlsFingerprintProfileAPI
from
'
./tlsFingerprintProfile
'
import
tlsFingerprintProfileAPI
from
'
./tlsFingerprintProfile
'
import
channelsAPI
from
'
./channels
'
/**
/**
* Unified admin API object for convenient access
* Unified admin API object for convenient access
...
@@ -51,7 +52,8 @@ export const adminAPI = {
...
@@ -51,7 +52,8 @@ export const adminAPI = {
apiKeys
:
apiKeysAPI
,
apiKeys
:
apiKeysAPI
,
scheduledTests
:
scheduledTestsAPI
,
scheduledTests
:
scheduledTestsAPI
,
backup
:
backupAPI
,
backup
:
backupAPI
,
tlsFingerprintProfiles
:
tlsFingerprintProfileAPI
tlsFingerprintProfiles
:
tlsFingerprintProfileAPI
,
channels
:
channelsAPI
}
}
export
{
export
{
...
@@ -76,7 +78,8 @@ export {
...
@@ -76,7 +78,8 @@ export {
apiKeysAPI
,
apiKeysAPI
,
scheduledTestsAPI
,
scheduledTestsAPI
,
backupAPI
,
backupAPI
,
tlsFingerprintProfileAPI
tlsFingerprintProfileAPI
,
channelsAPI
}
}
export
default
adminAPI
export
default
adminAPI
...
...
frontend/src/components/admin/channel/IntervalRow.vue
0 → 100644
View file @
91c9b8d0
<
template
>
<div
class=
"flex items-start gap-2 rounded border border-gray-200 bg-white p-2 dark:border-dark-500 dark:bg-dark-700"
>
<!-- Token mode: context range + prices -->
<template
v-if=
"mode === 'token'"
>
<div
class=
"w-20"
>
<label
class=
"text-xs text-gray-400"
>
{{
t
(
'
admin.channels.form.minTokens
'
,
'
Min (K)
'
)
}}
</label>
<input
:value=
"interval.min_tokens"
@
input=
"emitField('min_tokens', toInt(($event.target as HTMLInputElement).value))"
type=
"number"
min=
"0"
class=
"input mt-0.5 text-xs"
/>
</div>
<div
class=
"w-20"
>
<label
class=
"text-xs text-gray-400"
>
{{
t
(
'
admin.channels.form.maxTokens
'
,
'
Max (K)
'
)
}}
</label>
<input
:value=
"interval.max_tokens ?? ''"
@
input=
"emitField('max_tokens', toIntOrNull(($event.target as HTMLInputElement).value))"
type=
"number"
min=
"0"
class=
"input mt-0.5 text-xs"
:placeholder=
"'∞'"
/>
</div>
<div
class=
"flex-1"
>
<label
class=
"text-xs text-gray-400"
>
{{
t
(
'
admin.channels.form.inputPrice
'
,
'
Input
'
)
}}
</label>
<input
:value=
"interval.input_price"
@
input=
"emitField('input_price', ($event.target as HTMLInputElement).value)"
type=
"number"
step=
"any"
min=
"0"
class=
"input mt-0.5 text-xs"
/>
</div>
<div
class=
"flex-1"
>
<label
class=
"text-xs text-gray-400"
>
{{
t
(
'
admin.channels.form.outputPrice
'
,
'
Output
'
)
}}
</label>
<input
:value=
"interval.output_price"
@
input=
"emitField('output_price', ($event.target as HTMLInputElement).value)"
type=
"number"
step=
"any"
min=
"0"
class=
"input mt-0.5 text-xs"
/>
</div>
<div
class=
"flex-1"
>
<label
class=
"text-xs text-gray-400"
>
{{
t
(
'
admin.channels.form.cacheWritePrice
'
,
'
Cache W
'
)
}}
</label>
<input
:value=
"interval.cache_write_price"
@
input=
"emitField('cache_write_price', ($event.target as HTMLInputElement).value)"
type=
"number"
step=
"any"
min=
"0"
class=
"input mt-0.5 text-xs"
/>
</div>
<div
class=
"flex-1"
>
<label
class=
"text-xs text-gray-400"
>
{{
t
(
'
admin.channels.form.cacheReadPrice
'
,
'
Cache R
'
)
}}
</label>
<input
:value=
"interval.cache_read_price"
@
input=
"emitField('cache_read_price', ($event.target as HTMLInputElement).value)"
type=
"number"
step=
"any"
min=
"0"
class=
"input mt-0.5 text-xs"
/>
</div>
</
template
>
<!-- Per-request / Image mode: tier label + price -->
<
template
v-else
>
<div
class=
"w-24"
>
<label
class=
"text-xs text-gray-400"
>
{{
mode
===
'
image
'
?
t
(
'
admin.channels.form.resolution
'
,
'
Resolution
'
)
:
t
(
'
admin.channels.form.tierLabel
'
,
'
Tier
'
)
}}
</label>
<input
:value=
"interval.tier_label"
@
input=
"emitField('tier_label', ($event.target as HTMLInputElement).value)"
type=
"text"
class=
"input mt-0.5 text-xs"
:placeholder=
"mode === 'image' ? '1K / 2K / 4K' : ''"
/>
</div>
<div
class=
"w-20"
>
<label
class=
"text-xs text-gray-400"
>
{{
t
(
'
admin.channels.form.minTokens
'
,
'
Min
'
)
}}
</label>
<input
:value=
"interval.min_tokens"
@
input=
"emitField('min_tokens', toInt(($event.target as HTMLInputElement).value))"
type=
"number"
min=
"0"
class=
"input mt-0.5 text-xs"
/>
</div>
<div
class=
"w-20"
>
<label
class=
"text-xs text-gray-400"
>
{{
t
(
'
admin.channels.form.maxTokens
'
,
'
Max
'
)
}}
</label>
<input
:value=
"interval.max_tokens ?? ''"
@
input=
"emitField('max_tokens', toIntOrNull(($event.target as HTMLInputElement).value))"
type=
"number"
min=
"0"
class=
"input mt-0.5 text-xs"
:placeholder=
"'∞'"
/>
</div>
<div
class=
"flex-1"
>
<label
class=
"text-xs text-gray-400"
>
{{
t
(
'
admin.channels.form.perRequestPrice
'
,
'
Price
'
)
}}
</label>
<input
:value=
"interval.per_request_price"
@
input=
"emitField('per_request_price', ($event.target as HTMLInputElement).value)"
type=
"number"
step=
"any"
min=
"0"
class=
"input mt-0.5 text-xs"
/>
</div>
</
template
>
<button
type=
"button"
@
click=
"emit('remove')"
class=
"mt-4 rounded p-0.5 text-gray-400 hover:text-red-500"
>
<Icon
name=
"x"
size=
"sm"
/>
</button>
</div>
</template>
<
script
setup
lang=
"ts"
>
import
{
useI18n
}
from
'
vue-i18n
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
type
{
IntervalFormEntry
}
from
'
./types
'
import
type
{
BillingMode
}
from
'
@/api/admin/channels
'
const
{
t
}
=
useI18n
()
const
props
=
defineProps
<
{
interval
:
IntervalFormEntry
mode
:
BillingMode
}
>
()
const
emit
=
defineEmits
<
{
update
:
[
interval
:
IntervalFormEntry
]
remove
:
[]
}
>
()
function
emitField
(
field
:
keyof
IntervalFormEntry
,
value
:
string
|
number
|
null
)
{
emit
(
'
update
'
,
{
...
props
.
interval
,
[
field
]:
value
===
''
?
null
:
value
})
}
function
toInt
(
val
:
string
):
number
{
const
n
=
parseInt
(
val
,
10
)
return
isNaN
(
n
)
?
0
:
n
}
function
toIntOrNull
(
val
:
string
):
number
|
null
{
if
(
val
===
''
)
return
null
const
n
=
parseInt
(
val
,
10
)
return
isNaN
(
n
)
?
null
:
n
}
</
script
>
frontend/src/components/admin/channel/PricingEntryCard.vue
0 → 100644
View file @
91c9b8d0
<
template
>
<div
class=
"rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-dark-600 dark:bg-dark-800"
>
<!-- Header: Models + Billing Mode + Remove -->
<div
class=
"mb-2 flex items-start gap-2"
>
<div
class=
"flex-1"
>
<label
class=
"text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.channels.form.models
'
,
'
Models (comma separated, supports *)
'
)
}}
</label>
<textarea
:value=
"entry.modelsInput"
@
input=
"emit('update',
{ ...entry, modelsInput: ($event.target as HTMLTextAreaElement).value })"
rows="2"
class="input mt-1 text-sm"
:placeholder="t('admin.channels.form.modelsPlaceholder', 'claude-sonnet-4-20250514, claude-opus-4-20250514, *')"
>
</textarea>
</div>
<div
class=
"w-40"
>
<label
class=
"text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.channels.form.billingMode
'
,
'
Billing Mode
'
)
}}
</label>
<Select
:modelValue=
"entry.billing_mode"
@
update:modelValue=
"emit('update',
{ ...entry, billing_mode: $event as BillingMode, intervals: [] })"
:options="billingModeOptions"
class="mt-1"
/>
</div>
<button
type=
"button"
@
click=
"emit('remove')"
class=
"mt-5 rounded p-1 text-gray-400 hover:text-red-500"
>
<Icon
name=
"trash"
size=
"sm"
/>
</button>
</div>
<!-- Token mode: flat prices + intervals -->
<div
v-if=
"entry.billing_mode === 'token'"
>
<!-- Flat prices (used when no intervals) -->
<div
class=
"grid grid-cols-2 gap-2 sm:grid-cols-4"
>
<div>
<label
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.channels.form.inputPrice
'
,
'
Input Price
'
)
}}
</label>
<input
:value=
"entry.input_price"
@
input=
"emitField('input_price', ($event.target as HTMLInputElement).value)"
type=
"number"
step=
"any"
min=
"0"
class=
"input mt-1 text-sm"
:placeholder=
"t('admin.channels.form.pricePlaceholder', 'Default')"
/>
</div>
<div>
<label
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.channels.form.outputPrice
'
,
'
Output Price
'
)
}}
</label>
<input
:value=
"entry.output_price"
@
input=
"emitField('output_price', ($event.target as HTMLInputElement).value)"
type=
"number"
step=
"any"
min=
"0"
class=
"input mt-1 text-sm"
:placeholder=
"t('admin.channels.form.pricePlaceholder', 'Default')"
/>
</div>
<div>
<label
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.channels.form.cacheWritePrice
'
,
'
Cache Write
'
)
}}
</label>
<input
:value=
"entry.cache_write_price"
@
input=
"emitField('cache_write_price', ($event.target as HTMLInputElement).value)"
type=
"number"
step=
"any"
min=
"0"
class=
"input mt-1 text-sm"
:placeholder=
"t('admin.channels.form.pricePlaceholder', 'Default')"
/>
</div>
<div>
<label
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.channels.form.cacheReadPrice
'
,
'
Cache Read
'
)
}}
</label>
<input
:value=
"entry.cache_read_price"
@
input=
"emitField('cache_read_price', ($event.target as HTMLInputElement).value)"
type=
"number"
step=
"any"
min=
"0"
class=
"input mt-1 text-sm"
:placeholder=
"t('admin.channels.form.pricePlaceholder', 'Default')"
/>
</div>
</div>
<!-- Token intervals -->
<div
class=
"mt-3"
>
<div
class=
"flex items-center justify-between"
>
<label
class=
"text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.channels.form.intervals
'
,
'
Context Intervals (optional)
'
)
}}
</label>
<button
type=
"button"
@
click=
"addInterval"
class=
"text-xs text-primary-600 hover:text-primary-700"
>
+
{{
t
(
'
admin.channels.form.addInterval
'
,
'
Add Interval
'
)
}}
</button>
</div>
<div
v-if=
"entry.intervals && entry.intervals.length > 0"
class=
"mt-2 space-y-2"
>
<IntervalRow
v-for=
"(iv, idx) in entry.intervals"
:key=
"idx"
:interval=
"iv"
:mode=
"entry.billing_mode"
@
update=
"updateInterval(idx, $event)"
@
remove=
"removeInterval(idx)"
/>
</div>
</div>
</div>
<!-- Per-request mode: tiers -->
<div
v-else-if=
"entry.billing_mode === 'per_request'"
>
<div
class=
"flex items-center justify-between"
>
<label
class=
"text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.channels.form.requestTiers
'
,
'
Request Tiers
'
)
}}
</label>
<button
type=
"button"
@
click=
"addInterval"
class=
"text-xs text-primary-600 hover:text-primary-700"
>
+
{{
t
(
'
admin.channels.form.addTier
'
,
'
Add Tier
'
)
}}
</button>
</div>
<div
v-if=
"entry.intervals && entry.intervals.length > 0"
class=
"mt-2 space-y-2"
>
<IntervalRow
v-for=
"(iv, idx) in entry.intervals"
:key=
"idx"
:interval=
"iv"
:mode=
"entry.billing_mode"
@
update=
"updateInterval(idx, $event)"
@
remove=
"removeInterval(idx)"
/>
</div>
<div
v-else
class=
"mt-2 rounded border border-dashed border-gray-300 p-3 text-center text-xs text-gray-400 dark:border-dark-500"
>
{{
t
(
'
admin.channels.form.noTiersYet
'
,
'
No tiers. Add one to configure per-request pricing.
'
)
}}
</div>
</div>
<!-- Image mode: tiers -->
<div
v-else-if=
"entry.billing_mode === 'image'"
>
<div
class=
"flex items-center justify-between"
>
<label
class=
"text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.channels.form.imageTiers
'
,
'
Image Tiers
'
)
}}
</label>
<button
type=
"button"
@
click=
"addImageTier"
class=
"text-xs text-primary-600 hover:text-primary-700"
>
+
{{
t
(
'
admin.channels.form.addTier
'
,
'
Add Tier
'
)
}}
</button>
</div>
<div
v-if=
"entry.intervals && entry.intervals.length > 0"
class=
"mt-2 space-y-2"
>
<IntervalRow
v-for=
"(iv, idx) in entry.intervals"
:key=
"idx"
:interval=
"iv"
:mode=
"entry.billing_mode"
@
update=
"updateInterval(idx, $event)"
@
remove=
"removeInterval(idx)"
/>
</div>
<div
v-else
>
<!-- Legacy image_output_price fallback -->
<div
class=
"mt-2 grid grid-cols-2 gap-2 sm:grid-cols-4"
>
<div>
<label
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.channels.form.imageOutputPrice
'
,
'
Image Output Price
'
)
}}
</label>
<input
:value=
"entry.image_output_price"
@
input=
"emitField('image_output_price', ($event.target as HTMLInputElement).value)"
type=
"number"
step=
"any"
min=
"0"
class=
"input mt-1 text-sm"
:placeholder=
"t('admin.channels.form.pricePlaceholder', 'Default')"
/>
</div>
</div>
</div>
</div>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
computed
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
Select
from
'
@/components/common/Select.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
IntervalRow
from
'
./IntervalRow.vue
'
import
type
{
PricingFormEntry
,
IntervalFormEntry
}
from
'
./types
'
import
type
{
BillingMode
}
from
'
@/api/admin/channels
'
const
{
t
}
=
useI18n
()
const
props
=
defineProps
<
{
entry
:
PricingFormEntry
}
>
()
const
emit
=
defineEmits
<
{
update
:
[
entry
:
PricingFormEntry
]
remove
:
[]
}
>
()
const
billingModeOptions
=
computed
(()
=>
[
{
value
:
'
token
'
,
label
:
t
(
'
admin.channels.billingMode.token
'
,
'
Token
'
)
},
{
value
:
'
per_request
'
,
label
:
t
(
'
admin.channels.billingMode.perRequest
'
,
'
Per Request
'
)
},
{
value
:
'
image
'
,
label
:
t
(
'
admin.channels.billingMode.image
'
,
'
Image
'
)
}
])
function
emitField
(
field
:
keyof
PricingFormEntry
,
value
:
string
)
{
emit
(
'
update
'
,
{
...
props
.
entry
,
[
field
]:
value
===
''
?
null
:
value
})
}
function
addInterval
()
{
const
intervals
=
[...(
props
.
entry
.
intervals
||
[])]
intervals
.
push
({
min_tokens
:
0
,
max_tokens
:
null
,
tier_label
:
''
,
input_price
:
null
,
output_price
:
null
,
cache_write_price
:
null
,
cache_read_price
:
null
,
per_request_price
:
null
,
sort_order
:
intervals
.
length
})
emit
(
'
update
'
,
{
...
props
.
entry
,
intervals
})
}
function
addImageTier
()
{
const
intervals
=
[...(
props
.
entry
.
intervals
||
[])]
const
labels
=
[
'
1K
'
,
'
2K
'
,
'
4K
'
,
'
HD
'
]
const
nextLabel
=
labels
[
intervals
.
length
]
||
''
intervals
.
push
({
min_tokens
:
0
,
max_tokens
:
null
,
tier_label
:
nextLabel
,
input_price
:
null
,
output_price
:
null
,
cache_write_price
:
null
,
cache_read_price
:
null
,
per_request_price
:
null
,
sort_order
:
intervals
.
length
})
emit
(
'
update
'
,
{
...
props
.
entry
,
intervals
})
}
function
updateInterval
(
idx
:
number
,
updated
:
IntervalFormEntry
)
{
const
intervals
=
[...(
props
.
entry
.
intervals
||
[])]
intervals
[
idx
]
=
updated
emit
(
'
update
'
,
{
...
props
.
entry
,
intervals
})
}
function
removeInterval
(
idx
:
number
)
{
const
intervals
=
[...(
props
.
entry
.
intervals
||
[])]
intervals
.
splice
(
idx
,
1
)
emit
(
'
update
'
,
{
...
props
.
entry
,
intervals
})
}
</
script
>
frontend/src/components/admin/channel/types.ts
0 → 100644
View file @
91c9b8d0
import
type
{
BillingMode
,
PricingInterval
}
from
'
@/api/admin/channels
'
export
interface
IntervalFormEntry
{
min_tokens
:
number
max_tokens
:
number
|
null
tier_label
:
string
input_price
:
number
|
string
|
null
output_price
:
number
|
string
|
null
cache_write_price
:
number
|
string
|
null
cache_read_price
:
number
|
string
|
null
per_request_price
:
number
|
string
|
null
sort_order
:
number
}
export
interface
PricingFormEntry
{
modelsInput
:
string
billing_mode
:
BillingMode
input_price
:
number
|
string
|
null
output_price
:
number
|
string
|
null
cache_write_price
:
number
|
string
|
null
cache_read_price
:
number
|
string
|
null
per_request_price
:
number
|
string
|
null
image_output_price
:
number
|
string
|
null
intervals
:
IntervalFormEntry
[]
}
export
function
toNullableNumber
(
val
:
number
|
string
|
null
|
undefined
):
number
|
null
{
if
(
val
===
null
||
val
===
undefined
||
val
===
''
)
return
null
const
num
=
Number
(
val
)
return
isNaN
(
num
)
?
null
:
num
}
export
function
apiIntervalsToForm
(
intervals
:
PricingInterval
[]):
IntervalFormEntry
[]
{
return
(
intervals
||
[]).
map
(
iv
=>
({
min_tokens
:
iv
.
min_tokens
,
max_tokens
:
iv
.
max_tokens
,
tier_label
:
iv
.
tier_label
||
''
,
input_price
:
iv
.
input_price
,
output_price
:
iv
.
output_price
,
cache_write_price
:
iv
.
cache_write_price
,
cache_read_price
:
iv
.
cache_read_price
,
per_request_price
:
iv
.
per_request_price
,
sort_order
:
iv
.
sort_order
}))
}
export
function
formIntervalsToAPI
(
intervals
:
IntervalFormEntry
[]):
PricingInterval
[]
{
return
(
intervals
||
[]).
map
(
iv
=>
({
min_tokens
:
iv
.
min_tokens
,
max_tokens
:
iv
.
max_tokens
,
tier_label
:
iv
.
tier_label
,
input_price
:
toNullableNumber
(
iv
.
input_price
),
output_price
:
toNullableNumber
(
iv
.
output_price
),
cache_write_price
:
toNullableNumber
(
iv
.
cache_write_price
),
cache_read_price
:
toNullableNumber
(
iv
.
cache_read_price
),
per_request_price
:
toNullableNumber
(
iv
.
per_request_price
),
sort_order
:
iv
.
sort_order
}))
}
frontend/src/components/layout/AppSidebar.vue
View file @
91c9b8d0
...
@@ -287,6 +287,21 @@ const FolderIcon = {
...
@@ -287,6 +287,21 @@ const FolderIcon = {
)
)
}
}
const
ChannelIcon
=
{
render
:
()
=>
h
(
'
svg
'
,
{
fill
:
'
none
'
,
viewBox
:
'
0 0 24 24
'
,
stroke
:
'
currentColor
'
,
'
stroke-width
'
:
'
1.5
'
},
[
h
(
'
path
'
,
{
'
stroke-linecap
'
:
'
round
'
,
'
stroke-linejoin
'
:
'
round
'
,
d
:
'
M6.429 9.75L2.25 12l4.179 2.25m0-4.5l5.571 3 5.571-3m-11.142 0L2.25 7.5 12 2.25l9.75 5.25-4.179 2.25m0 0l4.179 2.25L12 17.25 2.25 12m15.321-2.25l4.179 2.25L12 17.25l-9.75-5.25
'
})
]
)
}
const
CreditCardIcon
=
{
const
CreditCardIcon
=
{
render
:
()
=>
render
:
()
=>
h
(
h
(
...
@@ -568,6 +583,7 @@ const adminNavItems = computed((): NavItem[] => {
...
@@ -568,6 +583,7 @@ const adminNavItems = computed((): NavItem[] => {
:
[]),
:
[]),
{
path
:
'
/admin/users
'
,
label
:
t
(
'
nav.users
'
),
icon
:
UsersIcon
,
hideInSimpleMode
:
true
},
{
path
:
'
/admin/users
'
,
label
:
t
(
'
nav.users
'
),
icon
:
UsersIcon
,
hideInSimpleMode
:
true
},
{
path
:
'
/admin/groups
'
,
label
:
t
(
'
nav.groups
'
),
icon
:
FolderIcon
,
hideInSimpleMode
:
true
},
{
path
:
'
/admin/groups
'
,
label
:
t
(
'
nav.groups
'
),
icon
:
FolderIcon
,
hideInSimpleMode
:
true
},
{
path
:
'
/admin/channels
'
,
label
:
t
(
'
nav.channels
'
,
'
渠道管理
'
),
icon
:
ChannelIcon
,
hideInSimpleMode
:
true
},
{
path
:
'
/admin/subscriptions
'
,
label
:
t
(
'
nav.subscriptions
'
),
icon
:
CreditCardIcon
,
hideInSimpleMode
:
true
},
{
path
:
'
/admin/subscriptions
'
,
label
:
t
(
'
nav.subscriptions
'
),
icon
:
CreditCardIcon
,
hideInSimpleMode
:
true
},
{
path
:
'
/admin/accounts
'
,
label
:
t
(
'
nav.accounts
'
),
icon
:
GlobeIcon
},
{
path
:
'
/admin/accounts
'
,
label
:
t
(
'
nav.accounts
'
),
icon
:
GlobeIcon
},
{
path
:
'
/admin/announcements
'
,
label
:
t
(
'
nav.announcements
'
),
icon
:
BellIcon
},
{
path
:
'
/admin/announcements
'
,
label
:
t
(
'
nav.announcements
'
),
icon
:
BellIcon
},
...
...
frontend/src/router/index.ts
View file @
91c9b8d0
...
@@ -278,6 +278,16 @@ const routes: RouteRecordRaw[] = [
...
@@ -278,6 +278,16 @@ const routes: RouteRecordRaw[] = [
descriptionKey
:
'
admin.groups.description
'
descriptionKey
:
'
admin.groups.description
'
}
}
},
},
{
path
:
'
/admin/channels
'
,
name
:
'
AdminChannels
'
,
component
:
()
=>
import
(
'
@/views/admin/ChannelsView.vue
'
),
meta
:
{
requiresAuth
:
true
,
requiresAdmin
:
true
,
title
:
'
Channel Management
'
}
},
{
{
path
:
'
/admin/subscriptions
'
,
path
:
'
/admin/subscriptions
'
,
name
:
'
AdminSubscriptions
'
,
name
:
'
AdminSubscriptions
'
,
...
...
frontend/src/views/admin/ChannelsView.vue
0 → 100644
View file @
91c9b8d0
<
template
>
<AppLayout>
<TablePageLayout>
<template
#filters
>
<div
class=
"flex flex-col justify-between gap-4 lg:flex-row lg:items-start"
>
<!-- Left: Search + Filters -->
<div
class=
"flex flex-1 flex-wrap items-center gap-3"
>
<div
class=
"relative w-full sm:w-64"
>
<Icon
name=
"search"
size=
"md"
class=
"absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500"
/>
<input
v-model=
"searchQuery"
type=
"text"
:placeholder=
"t('admin.channels.searchChannels', 'Search channels...')"
class=
"input pl-10"
@
input=
"handleSearch"
/>
</div>
<Select
v-model=
"filters.status"
:options=
"statusFilterOptions"
:placeholder=
"t('admin.channels.allStatus', 'All Status')"
class=
"w-40"
@
change=
"loadChannels"
/>
</div>
<!-- Right: Actions -->
<div
class=
"flex w-full flex-shrink-0 flex-wrap items-center justify-end gap-3 lg:w-auto"
>
<button
@
click=
"loadChannels"
:disabled=
"loading"
class=
"btn btn-secondary"
:title=
"t('common.refresh', 'Refresh')"
>
<Icon
name=
"refresh"
size=
"md"
:class=
"loading ? 'animate-spin' : ''"
/>
</button>
<button
@
click=
"openCreateDialog"
class=
"btn btn-primary"
>
<Icon
name=
"plus"
size=
"md"
class=
"mr-2"
/>
{{
t
(
'
admin.channels.createChannel
'
,
'
Create Channel
'
)
}}
</button>
</div>
</div>
</
template
>
<
template
#table
>
<DataTable
:columns=
"columns"
:data=
"channels"
:loading=
"loading"
>
<template
#cell-name
="
{ value }">
<span
class=
"font-medium text-gray-900 dark:text-white"
>
{{
value
}}
</span>
</
template
>
<
template
#cell-description=
"{ value }"
>
<span
class=
"text-sm text-gray-600 dark:text-gray-400"
>
{{
value
||
'
-
'
}}
</span>
</
template
>
<
template
#cell-status=
"{ value }"
>
<span
:class=
"[
'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium',
value === 'active'
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
]"
>
{{
value
===
'
active
'
?
t
(
'
admin.channels.statusActive
'
,
'
Active
'
)
:
t
(
'
admin.channels.statusDisabled
'
,
'
Disabled
'
)
}}
</span>
</
template
>
<
template
#cell-group_count=
"{ row }"
>
<span
class=
"inline-flex items-center rounded bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-800 dark:bg-dark-600 dark:text-gray-300"
>
{{
(
row
.
group_ids
||
[]).
length
}}
{{
t
(
'
admin.channels.groupsUnit
'
,
'
groups
'
)
}}
</span>
</
template
>
<
template
#cell-pricing_count=
"{ row }"
>
<span
class=
"inline-flex items-center rounded bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-800 dark:bg-dark-600 dark:text-gray-300"
>
{{
(
row
.
model_pricing
||
[]).
length
}}
{{
t
(
'
admin.channels.pricingUnit
'
,
'
pricing rules
'
)
}}
</span>
</
template
>
<
template
#cell-created_at=
"{ value }"
>
<span
class=
"text-sm text-gray-600 dark:text-gray-400"
>
{{
formatDate
(
value
)
}}
</span>
</
template
>
<
template
#cell-actions=
"{ row }"
>
<div
class=
"flex items-center gap-1"
>
<button
@
click=
"openEditDialog(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"
>
<Icon
name=
"edit"
size=
"sm"
/>
<span
class=
"text-xs"
>
{{
t
(
'
common.edit
'
,
'
Edit
'
)
}}
</span>
</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"
>
<Icon
name=
"trash"
size=
"sm"
/>
<span
class=
"text-xs"
>
{{
t
(
'
common.delete
'
,
'
Delete
'
)
}}
</span>
</button>
</div>
</
template
>
<
template
#empty
>
<EmptyState
:title=
"t('admin.channels.noChannelsYet', 'No Channels Yet')"
:description=
"t('admin.channels.createFirstChannel', 'Create your first channel to manage model pricing')"
:action-text=
"t('admin.channels.createChannel', 'Create Channel')"
@
action=
"openCreateDialog"
/>
</
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
>
</TablePageLayout>
<!-- Create/Edit Dialog -->
<BaseDialog
:show=
"showDialog"
:title=
"editingChannel ? t('admin.channels.editChannel', 'Edit Channel') : t('admin.channels.createChannel', 'Create Channel')"
width=
"extra-wide"
@
close=
"closeDialog"
>
<form
id=
"channel-form"
@
submit.prevent=
"handleSubmit"
class=
"space-y-5"
>
<!-- Name -->
<div>
<label
class=
"input-label"
>
{{ t('admin.channels.form.name', 'Name') }}
</label>
<input
v-model=
"form.name"
type=
"text"
required
class=
"input"
:placeholder=
"t('admin.channels.form.namePlaceholder', 'Enter channel name')"
/>
</div>
<!-- Description -->
<div>
<label
class=
"input-label"
>
{{ t('admin.channels.form.description', 'Description') }}
</label>
<textarea
v-model=
"form.description"
rows=
"2"
class=
"input"
:placeholder=
"t('admin.channels.form.descriptionPlaceholder', 'Optional description')"
></textarea>
</div>
<!-- Status (edit only) -->
<div
v-if=
"editingChannel"
>
<label
class=
"input-label"
>
{{ t('admin.channels.form.status', 'Status') }}
</label>
<Select
v-model=
"form.status"
:options=
"statusEditOptions"
/>
</div>
<!-- Group Association -->
<div>
<label
class=
"input-label"
>
{{ t('admin.channels.form.groups', 'Associated Groups') }}
</label>
<div
class=
"max-h-48 overflow-auto rounded-lg border border-gray-200 bg-white p-2 dark:border-dark-600 dark:bg-dark-800"
>
<div
v-if=
"groupsLoading"
class=
"py-4 text-center text-sm text-gray-500"
>
{{ t('common.loading', 'Loading...') }}
</div>
<div
v-else-if=
"allGroups.length === 0"
class=
"py-4 text-center text-sm text-gray-500"
>
{{ t('admin.channels.form.noGroupsAvailable', 'No groups available') }}
</div>
<label
v-for=
"group in allGroups"
:key=
"group.id"
class=
"flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 hover:bg-gray-50 dark:hover:bg-dark-700"
:class=
"{ 'opacity-50': isGroupInOtherChannel(group.id) }"
>
<input
type=
"checkbox"
:checked=
"form.group_ids.includes(group.id)"
:disabled=
"isGroupInOtherChannel(group.id)"
class=
"h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
@
change=
"toggleGroup(group.id)"
/>
<span
class=
"text-sm text-gray-700 dark:text-gray-300"
>
{{ group.name }}
</span>
<span
v-if=
"isGroupInOtherChannel(group.id)"
class=
"ml-auto text-xs text-gray-400"
>
{{ getGroupInOtherChannelLabel(group.id) }}
</span>
<span
v-if=
"group.platform"
class=
"ml-auto text-xs text-gray-400 dark:text-gray-500"
>
{{ group.platform }}
</span>
</label>
</div>
</div>
<!-- Model Pricing -->
<div>
<div
class=
"mb-2 flex items-center justify-between"
>
<label
class=
"input-label mb-0"
>
{{ t('admin.channels.form.modelPricing', 'Model Pricing') }}
</label>
<button
type=
"button"
@
click=
"addPricingEntry"
class=
"btn btn-secondary btn-sm"
>
<Icon
name=
"plus"
size=
"sm"
class=
"mr-1"
/>
{{ t('common.add', 'Add') }}
</button>
</div>
<div
v-if=
"form.model_pricing.length === 0"
class=
"rounded-lg border border-dashed border-gray-300 p-4 text-center text-sm text-gray-500 dark:border-dark-500 dark:text-gray-400"
>
{{ t('admin.channels.form.noPricingRules', 'No pricing rules yet. Click "Add" to create one.') }}
</div>
<div
v-else
class=
"space-y-3"
>
<PricingEntryCard
v-for=
"(entry, idx) in form.model_pricing"
:key=
"idx"
:entry=
"entry"
@
update=
"updatePricingEntry(idx, $event)"
@
remove=
"removePricingEntry(idx)"
/>
</div>
</div>
</form>
<
template
#footer
>
<div
class=
"flex justify-end gap-3"
>
<button
@
click=
"closeDialog"
type=
"button"
class=
"btn btn-secondary"
>
{{
t
(
'
common.cancel
'
,
'
Cancel
'
)
}}
</button>
<button
type=
"submit"
form=
"channel-form"
:disabled=
"submitting"
class=
"btn btn-primary"
>
{{
submitting
?
t
(
'
common.submitting
'
,
'
Submitting...
'
)
:
editingChannel
?
t
(
'
common.update
'
,
'
Update
'
)
:
t
(
'
common.create
'
,
'
Create
'
)
}}
</button>
</div>
</
template
>
</BaseDialog>
<!-- Delete Confirmation -->
<ConfirmDialog
:show=
"showDeleteDialog"
:title=
"t('admin.channels.deleteChannel', 'Delete Channel')"
:message=
"deleteConfirmMessage"
:confirm-text=
"t('common.delete', 'Delete')"
:cancel-text=
"t('common.cancel', 'Cancel')"
:danger=
"true"
@
confirm=
"confirmDelete"
@
cancel=
"showDeleteDialog = false"
/>
</AppLayout>
</template>
<
script
setup
lang=
"ts"
>
import
{
ref
,
reactive
,
computed
,
onMounted
,
onUnmounted
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
type
{
Channel
,
ChannelModelPricing
,
CreateChannelRequest
,
UpdateChannelRequest
}
from
'
@/api/admin/channels
'
import
type
{
PricingFormEntry
}
from
'
@/components/admin/channel/types
'
import
{
toNullableNumber
,
apiIntervalsToForm
,
formIntervalsToAPI
}
from
'
@/components/admin/channel/types
'
import
type
{
AdminGroup
}
from
'
@/types
'
import
type
{
Column
}
from
'
@/components/common/types
'
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
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
import
ConfirmDialog
from
'
@/components/common/ConfirmDialog.vue
'
import
EmptyState
from
'
@/components/common/EmptyState.vue
'
import
Select
from
'
@/components/common/Select.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
PricingEntryCard
from
'
@/components/admin/channel/PricingEntryCard.vue
'
import
{
getPersistedPageSize
}
from
'
@/composables/usePersistedPageSize
'
const
{
t
}
=
useI18n
()
const
appStore
=
useAppStore
()
// ── Table columns ──
const
columns
=
computed
<
Column
[]
>
(()
=>
[
{
key
:
'
name
'
,
label
:
t
(
'
admin.channels.columns.name
'
,
'
Name
'
),
sortable
:
true
},
{
key
:
'
description
'
,
label
:
t
(
'
admin.channels.columns.description
'
,
'
Description
'
),
sortable
:
false
},
{
key
:
'
status
'
,
label
:
t
(
'
admin.channels.columns.status
'
,
'
Status
'
),
sortable
:
true
},
{
key
:
'
group_count
'
,
label
:
t
(
'
admin.channels.columns.groups
'
,
'
Groups
'
),
sortable
:
false
},
{
key
:
'
pricing_count
'
,
label
:
t
(
'
admin.channels.columns.pricing
'
,
'
Pricing
'
),
sortable
:
false
},
{
key
:
'
created_at
'
,
label
:
t
(
'
admin.channels.columns.createdAt
'
,
'
Created
'
),
sortable
:
true
},
{
key
:
'
actions
'
,
label
:
t
(
'
admin.channels.columns.actions
'
,
'
Actions
'
),
sortable
:
false
}
])
const
statusFilterOptions
=
computed
(()
=>
[
{
value
:
''
,
label
:
t
(
'
admin.channels.allStatus
'
,
'
All Status
'
)
},
{
value
:
'
active
'
,
label
:
t
(
'
admin.channels.statusActive
'
,
'
Active
'
)
},
{
value
:
'
disabled
'
,
label
:
t
(
'
admin.channels.statusDisabled
'
,
'
Disabled
'
)
}
])
const
statusEditOptions
=
computed
(()
=>
[
{
value
:
'
active
'
,
label
:
t
(
'
admin.channels.statusActive
'
,
'
Active
'
)
},
{
value
:
'
disabled
'
,
label
:
t
(
'
admin.channels.statusDisabled
'
,
'
Disabled
'
)
}
])
// ── State ──
const
channels
=
ref
<
Channel
[]
>
([])
const
loading
=
ref
(
false
)
const
searchQuery
=
ref
(
''
)
const
filters
=
reactive
({
status
:
''
})
const
pagination
=
reactive
({
page
:
1
,
page_size
:
getPersistedPageSize
(),
total
:
0
})
// Dialog state
const
showDialog
=
ref
(
false
)
const
editingChannel
=
ref
<
Channel
|
null
>
(
null
)
const
submitting
=
ref
(
false
)
const
showDeleteDialog
=
ref
(
false
)
const
deletingChannel
=
ref
<
Channel
|
null
>
(
null
)
// Groups
const
allGroups
=
ref
<
AdminGroup
[]
>
([])
const
groupsLoading
=
ref
(
false
)
// Form data
const
form
=
reactive
({
name
:
''
,
description
:
''
,
status
:
'
active
'
,
group_ids
:
[]
as
number
[],
model_pricing
:
[]
as
PricingFormEntry
[]
})
let
abortController
:
AbortController
|
null
=
null
// ── Helpers ──
function
formatDate
(
value
:
string
):
string
{
if
(
!
value
)
return
'
-
'
return
new
Date
(
value
).
toLocaleDateString
()
}
// ── Group helpers ──
const
groupToChannelMap
=
computed
(()
=>
{
const
map
=
new
Map
<
number
,
Channel
>
()
for
(
const
ch
of
channels
.
value
)
{
if
(
editingChannel
.
value
&&
ch
.
id
===
editingChannel
.
value
.
id
)
continue
for
(
const
gid
of
ch
.
group_ids
||
[])
{
map
.
set
(
gid
,
ch
)
}
}
return
map
})
function
isGroupInOtherChannel
(
groupId
:
number
):
boolean
{
return
groupToChannelMap
.
value
.
has
(
groupId
)
}
function
getGroupChannelName
(
groupId
:
number
):
string
{
return
groupToChannelMap
.
value
.
get
(
groupId
)?.
name
||
''
}
function
getGroupInOtherChannelLabel
(
groupId
:
number
):
string
{
const
name
=
getGroupChannelName
(
groupId
)
return
t
(
'
admin.channels.form.inOtherChannel
'
,
{
name
},
`In "
${
name
}
"`
)
}
const
deleteConfirmMessage
=
computed
(()
=>
{
const
name
=
deletingChannel
.
value
?.
name
||
''
return
t
(
'
admin.channels.deleteConfirm
'
,
{
name
},
`Are you sure you want to delete channel "
${
name
}
"? This action cannot be undone.`
)
})
function
toggleGroup
(
groupId
:
number
)
{
const
idx
=
form
.
group_ids
.
indexOf
(
groupId
)
if
(
idx
>=
0
)
{
form
.
group_ids
.
splice
(
idx
,
1
)
}
else
{
form
.
group_ids
.
push
(
groupId
)
}
}
// ── Pricing helpers ──
function
addPricingEntry
()
{
form
.
model_pricing
.
push
({
modelsInput
:
''
,
billing_mode
:
'
token
'
,
input_price
:
null
,
output_price
:
null
,
cache_write_price
:
null
,
cache_read_price
:
null
,
per_request_price
:
null
,
image_output_price
:
null
,
intervals
:
[]
})
}
function
updatePricingEntry
(
idx
:
number
,
updated
:
PricingFormEntry
)
{
form
.
model_pricing
[
idx
]
=
updated
}
function
removePricingEntry
(
idx
:
number
)
{
form
.
model_pricing
.
splice
(
idx
,
1
)
}
function
formPricingToAPI
():
ChannelModelPricing
[]
{
return
form
.
model_pricing
.
filter
(
e
=>
e
.
modelsInput
.
trim
())
.
map
(
e
=>
({
models
:
e
.
modelsInput
.
split
(
'
,
'
).
map
(
m
=>
m
.
trim
()).
filter
(
Boolean
),
billing_mode
:
e
.
billing_mode
,
input_price
:
toNullableNumber
(
e
.
input_price
),
output_price
:
toNullableNumber
(
e
.
output_price
),
cache_write_price
:
toNullableNumber
(
e
.
cache_write_price
),
cache_read_price
:
toNullableNumber
(
e
.
cache_read_price
),
image_output_price
:
toNullableNumber
(
e
.
image_output_price
),
intervals
:
formIntervalsToAPI
(
e
.
intervals
||
[])
}))
}
function
apiPricingToForm
(
pricing
:
ChannelModelPricing
[]):
PricingFormEntry
[]
{
return
pricing
.
map
(
p
=>
({
modelsInput
:
p
.
models
.
join
(
'
,
'
),
billing_mode
:
p
.
billing_mode
,
input_price
:
p
.
input_price
,
output_price
:
p
.
output_price
,
cache_write_price
:
p
.
cache_write_price
,
cache_read_price
:
p
.
cache_read_price
,
per_request_price
:
null
,
image_output_price
:
p
.
image_output_price
,
intervals
:
apiIntervalsToForm
(
p
.
intervals
||
[])
}))
}
// ── Load data ──
async
function
loadChannels
()
{
if
(
abortController
)
abortController
.
abort
()
const
ctrl
=
new
AbortController
()
abortController
=
ctrl
loading
.
value
=
true
try
{
const
response
=
await
adminAPI
.
channels
.
list
(
pagination
.
page
,
pagination
.
page_size
,
{
status
:
filters
.
status
||
undefined
,
search
:
searchQuery
.
value
||
undefined
},
{
signal
:
ctrl
.
signal
})
if
(
ctrl
.
signal
.
aborted
||
abortController
!==
ctrl
)
return
channels
.
value
=
response
.
items
||
[]
pagination
.
total
=
response
.
total
}
catch
(
error
:
any
)
{
if
(
error
?.
name
===
'
AbortError
'
||
error
?.
code
===
'
ERR_CANCELED
'
)
return
appStore
.
showError
(
t
(
'
admin.channels.loadError
'
,
'
Failed to load channels
'
))
console
.
error
(
'
Error loading channels:
'
,
error
)
}
finally
{
if
(
abortController
===
ctrl
)
{
loading
.
value
=
false
abortController
=
null
}
}
}
async
function
loadGroups
()
{
groupsLoading
.
value
=
true
try
{
allGroups
.
value
=
await
adminAPI
.
groups
.
getAll
()
}
catch
(
error
)
{
console
.
error
(
'
Error loading groups:
'
,
error
)
}
finally
{
groupsLoading
.
value
=
false
}
}
let
searchTimeout
:
ReturnType
<
typeof
setTimeout
>
function
handleSearch
()
{
clearTimeout
(
searchTimeout
)
searchTimeout
=
setTimeout
(()
=>
{
pagination
.
page
=
1
loadChannels
()
},
300
)
}
function
handlePageChange
(
page
:
number
)
{
pagination
.
page
=
page
loadChannels
()
}
function
handlePageSizeChange
(
pageSize
:
number
)
{
pagination
.
page_size
=
pageSize
pagination
.
page
=
1
loadChannels
()
}
// ── Dialog ──
function
resetForm
()
{
form
.
name
=
''
form
.
description
=
''
form
.
status
=
'
active
'
form
.
group_ids
=
[]
form
.
model_pricing
=
[]
}
function
openCreateDialog
()
{
editingChannel
.
value
=
null
resetForm
()
loadGroups
()
showDialog
.
value
=
true
}
function
openEditDialog
(
channel
:
Channel
)
{
editingChannel
.
value
=
channel
form
.
name
=
channel
.
name
form
.
description
=
channel
.
description
||
''
form
.
status
=
channel
.
status
form
.
group_ids
=
[...(
channel
.
group_ids
||
[])]
form
.
model_pricing
=
apiPricingToForm
(
channel
.
model_pricing
||
[])
loadGroups
()
showDialog
.
value
=
true
}
function
closeDialog
()
{
showDialog
.
value
=
false
editingChannel
.
value
=
null
resetForm
()
}
async
function
handleSubmit
()
{
if
(
submitting
.
value
)
return
if
(
!
form
.
name
.
trim
())
{
appStore
.
showError
(
t
(
'
admin.channels.nameRequired
'
,
'
Please enter a channel name
'
))
return
}
submitting
.
value
=
true
try
{
if
(
editingChannel
.
value
)
{
const
req
:
UpdateChannelRequest
=
{
name
:
form
.
name
.
trim
(),
description
:
form
.
description
.
trim
()
||
undefined
,
status
:
form
.
status
,
group_ids
:
form
.
group_ids
,
model_pricing
:
formPricingToAPI
()
}
await
adminAPI
.
channels
.
update
(
editingChannel
.
value
.
id
,
req
)
appStore
.
showSuccess
(
t
(
'
admin.channels.updateSuccess
'
,
'
Channel updated
'
))
}
else
{
const
req
:
CreateChannelRequest
=
{
name
:
form
.
name
.
trim
(),
description
:
form
.
description
.
trim
()
||
undefined
,
group_ids
:
form
.
group_ids
,
model_pricing
:
formPricingToAPI
()
}
await
adminAPI
.
channels
.
create
(
req
)
appStore
.
showSuccess
(
t
(
'
admin.channels.createSuccess
'
,
'
Channel created
'
))
}
closeDialog
()
loadChannels
()
}
catch
(
error
:
any
)
{
const
msg
=
error
.
response
?.
data
?.
detail
||
(
editingChannel
.
value
?
t
(
'
admin.channels.updateError
'
,
'
Failed to update channel
'
)
:
t
(
'
admin.channels.createError
'
,
'
Failed to create channel
'
))
appStore
.
showError
(
msg
)
console
.
error
(
'
Error saving channel:
'
,
error
)
}
finally
{
submitting
.
value
=
false
}
}
// ── Delete ──
function
handleDelete
(
channel
:
Channel
)
{
deletingChannel
.
value
=
channel
showDeleteDialog
.
value
=
true
}
async
function
confirmDelete
()
{
if
(
!
deletingChannel
.
value
)
return
try
{
await
adminAPI
.
channels
.
remove
(
deletingChannel
.
value
.
id
)
appStore
.
showSuccess
(
t
(
'
admin.channels.deleteSuccess
'
,
'
Channel deleted
'
))
showDeleteDialog
.
value
=
false
deletingChannel
.
value
=
null
loadChannels
()
}
catch
(
error
:
any
)
{
appStore
.
showError
(
error
.
response
?.
data
?.
detail
||
t
(
'
admin.channels.deleteError
'
,
'
Failed to delete channel
'
))
console
.
error
(
'
Error deleting channel:
'
,
error
)
}
}
// ── Lifecycle ──
onMounted
(()
=>
{
loadChannels
()
})
onUnmounted
(()
=>
{
clearTimeout
(
searchTimeout
)
abortController
?.
abort
()
})
</
script
>
Prev
1
2
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