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
Expand all
Show 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
This diff is collapsed.
Click to expand it.
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