feat(channels): add "Available Channels" aggregate view

Add a read-only aggregate view per channel: its linked groups and a
deterministic wildcard-free supported-model list with pricing details.

Backend
- service.Channel.SupportedModels(): combine ModelMapping keys with
  same-platform ModelPricing.Models; trailing "*" keys expand via
  pricing prefix match; platforms without a mapping produce no
  entries (intentional "no mapping = not shown" rule).
- Extract splitWildcardSuffix() shared with toModelEntry.
- Build a per-call pricing lookup map (platform+lowerName -> *pricing)
  to avoid O(N*M) scans in SupportedModels.
- ChannelService.ListAvailable() aggregates channels + active groups;
  filters out group IDs no longer active.
- Admin route GET /api/v1/admin/channels/available returns the full
  DTO (id, status, billing_model_source, restrict_models, groups,
  supported_models).
- User route GET /api/v1/channels/available applies three filters:
  Status==active, visible-group intersection, and platform filter
  on supported_models (prevents cross-platform leak when a channel
  links to both a user-accessible group and an inaccessible one on
  another platform). Response is a plain array (matches the
  /groups/available sibling shape). Field whitelist omits
  billing_model_source, restrict_models, ids, status, sort_order.

Frontend
- New /admin/available-channels and /available-channels views backed
  by a shared AvailableChannelsTable component (admin adds status +
  billing-source columns via slots).
- PricingRow extracted to its own SFC; SupportedModelChip references
  shared billing-mode constants in constants/channel.ts.
- Sidebar: new entry above "渠道管理" for admin; matching entry in
  user nav.
- i18n: zh + en coverage for both namespaces.

Tests
- SupportedModels: wildcard-only pricing skipped, prefix-matches-
  nothing, cross-platform bleed, case-insensitive dedup, empty
  platform mapping.
- ListAvailable: nil groupRepo, inactive-group-ID dropped, stable
  case-insensitive name sort.
- User handler: 401 on unauthenticated, visible-group intersection,
  platform filter on supported_models, JSON whitelist.
- Admin handler: full DTO including default BillingModelSource
  fallback.

Refs: issue #1729
This commit is contained in:
erio
2026-04-21 00:27:10 +08:00
parent c46744f366
commit 654cfb6480
29 changed files with 2012 additions and 45 deletions

View File

@@ -163,5 +163,42 @@ export async function getModelDefaultPricing(model: string): Promise<ModelDefaul
return data
}
const channelsAPI = { list, getById, create, update, remove, getModelDefaultPricing }
// --- Available channels (聚合视图:渠道 + 分组 + 支持模型) ---
export interface AvailableGroupRef {
id: number
name: string
platform: string
}
export interface SupportedModel {
name: string
platform: string
pricing: ChannelModelPricing | null
}
export interface AvailableChannel {
id: number
name: string
description: string
status: string
billing_model_source: string
restrict_models: boolean
groups: AvailableGroupRef[]
supported_models: SupportedModel[]
}
interface AvailableChannelsResponse {
items: AvailableChannel[]
}
/** 列出所有可用渠道(含关联分组与支持模型) */
export async function listAvailable(options?: { signal?: AbortSignal }): Promise<AvailableChannel[]> {
const { data } = await apiClient.get<AvailableChannelsResponse>('/admin/channels/available', {
signal: options?.signal
})
return data.items
}
const channelsAPI = { list, getById, create, update, remove, getModelDefaultPricing, listAvailable }
export default channelsAPI

View File

@@ -0,0 +1,60 @@
/**
* User Channels API endpoints (non-admin)
* 用户侧「可用渠道」聚合查询:渠道 + 用户可访问的分组 + 支持模型(含定价)。
*/
import { apiClient } from './client'
import type { BillingMode } from '@/constants/channel'
export interface UserAvailableGroup {
id: number
name: string
platform: string
}
export interface UserPricingInterval {
min_tokens: number
max_tokens: number | null
tier_label?: string
input_price: number | null
output_price: number | null
cache_write_price: number | null
cache_read_price: number | null
per_request_price: number | null
}
export interface UserSupportedModelPricing {
billing_mode: BillingMode
input_price: number | null
output_price: number | null
cache_write_price: number | null
cache_read_price: number | null
image_output_price: number | null
per_request_price: number | null
intervals: UserPricingInterval[]
}
export interface UserSupportedModel {
name: string
platform: string
pricing: UserSupportedModelPricing | null
}
export interface UserAvailableChannel {
name: string
description: string
groups: UserAvailableGroup[]
supported_models: UserSupportedModel[]
}
/** 列出当前用户可见的「可用渠道」(与 /groups/available 保持一致,返回平数组)。 */
export async function getAvailable(options?: { signal?: AbortSignal }): Promise<UserAvailableChannel[]> {
const { data } = await apiClient.get<UserAvailableChannel[]>('/channels/available', {
signal: options?.signal
})
return data
}
export const userChannelsAPI = { getAvailable }
export default userChannelsAPI

View File

@@ -16,6 +16,7 @@ export { userAPI } from './user'
export { redeemAPI, type RedeemHistoryItem } from './redeem'
export { paymentAPI } from './payment'
export { userGroupsAPI } from './groups'
export { userChannelsAPI } from './channels'
export { totpAPI } from './totp'
export { default as announcementsAPI } from './announcements'
export { channelMonitorUserAPI } from './channelMonitor'

View File

@@ -0,0 +1,110 @@
<template>
<DataTable :columns="columns" :data="rows" :loading="loading">
<template #cell-name="{ row }">
<div class="font-medium text-gray-900 dark:text-white">{{ row.name }}</div>
<div
v-if="row.description"
class="mt-0.5 text-xs text-gray-500 dark:text-gray-400"
>
{{ row.description }}
</div>
</template>
<template #cell-groups="{ row }">
<div v-if="row.groups.length === 0" class="text-xs text-gray-400">
<slot name="empty-groups">-</slot>
</div>
<div v-else class="flex flex-wrap gap-1">
<span
v-for="g in row.groups"
:key="g.id"
class="inline-flex items-center rounded bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-300"
>
{{ g.name }}
</span>
</div>
</template>
<template #cell-supported_models="{ row }">
<div v-if="row.supported_models.length === 0" class="text-xs text-gray-400">
{{ noModelsLabel }}
</div>
<div v-else class="flex max-w-[560px] flex-wrap gap-1">
<SupportedModelChip
v-for="m in row.supported_models"
:key="`${m.platform}-${m.name}`"
:model="m"
:pricing-key-prefix="pricingKeyPrefix"
:no-pricing-label="noPricingLabel"
/>
</div>
</template>
<!-- 允许父组件为额外列提供自定义渲染 admin status / billing_model_source -->
<template v-for="slot in extraCellSlots" :key="slot" #[slot]="scope">
<slot :name="slot" v-bind="scope" />
</template>
<template #empty>
<slot name="empty">
<div class="flex flex-col items-center py-8">
<Icon name="inbox" size="xl" class="mb-3 h-12 w-12 text-gray-400" />
<p class="text-sm text-gray-500 dark:text-gray-400">{{ emptyLabel }}</p>
</div>
</slot>
</template>
</DataTable>
</template>
<script setup lang="ts">
import { computed, useSlots } from 'vue'
import DataTable from '@/components/common/DataTable.vue'
import Icon from '@/components/icons/Icon.vue'
import SupportedModelChip from './SupportedModelChip.vue'
interface GroupRef {
id: number
name: string
platform?: string
}
interface Row {
name: string
description?: string
groups: GroupRef[]
supported_models: Array<{
name: string
platform: string
pricing: unknown | null
}>
[key: string]: unknown
}
interface Column {
key: string
label: string
}
withDefaults(
defineProps<{
columns: Column[]
rows: Row[]
loading: boolean
pricingKeyPrefix: string
noPricingLabel: string
noModelsLabel: string
emptyLabel: string
}>(),
{ loading: false }
)
const slots = useSlots()
/**
* 透传父组件提供的 cell-* 插槽(除本组件内置的 name/groups/supported_models/empty-groups/empty
* 之外),让 admin 场景可以自定义 status / billing_model_source 等列。
*/
const extraCellSlots = computed(() => {
const reserved = new Set(['cell-name', 'cell-groups', 'cell-supported_models', 'empty-groups', 'empty'])
return Object.keys(slots).filter((name) => name.startsWith('cell-') && !reserved.has(name))
})
</script>

View File

@@ -0,0 +1,29 @@
<template>
<div class="flex justify-between gap-2">
<span class="text-gray-500 dark:text-gray-400">{{ label }}</span>
<span class="font-mono">{{ display }}</span>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(
defineProps<{
label: string
value: number | null
unit: string
scale: number
}>(),
{ value: null }
)
function formatScaled(value: number | null, scale: number): string {
if (value == null) return '-'
return `$${(value * scale).toPrecision(10).replace(/\.?0+$/, '')}`
}
const display = computed(() =>
props.value == null ? '-' : `${formatScaled(props.value, props.scale)} ${props.unit}`
)
</script>

View File

@@ -0,0 +1,214 @@
<template>
<div class="group relative inline-block">
<span
class="inline-flex cursor-help items-center rounded-md border border-gray-200 bg-gray-50 px-2 py-0.5 text-xs font-medium text-gray-700 transition-colors hover:border-brand-400 hover:bg-brand-50 hover:text-brand-700 dark:border-dark-600 dark:bg-dark-800 dark:text-gray-300 dark:hover:border-brand-500 dark:hover:bg-brand-900/30 dark:hover:text-brand-300"
>
<span
v-if="showPlatform && model.platform"
class="mr-1 rounded bg-gray-200 px-1 text-[10px] uppercase text-gray-600 dark:bg-dark-700 dark:text-gray-400"
>
{{ model.platform }}
</span>
{{ model.name }}
</span>
<div
class="pointer-events-none invisible absolute left-1/2 z-50 mt-2 w-80 -translate-x-1/2 opacity-0 transition-opacity group-hover:visible group-hover:opacity-100"
>
<div
class="rounded-lg border border-gray-200 bg-white p-3 text-xs shadow-lg dark:border-dark-600 dark:bg-dark-800"
>
<div
class="mb-2 flex items-center justify-between gap-2 border-b border-gray-200 pb-2 dark:border-dark-600"
>
<span class="font-semibold text-gray-900 dark:text-gray-100">{{ model.name }}</span>
<span
v-if="model.platform"
class="rounded bg-gray-100 px-1.5 py-0.5 text-[10px] uppercase text-gray-600 dark:bg-dark-700 dark:text-gray-400"
>
{{ model.platform }}
</span>
</div>
<div v-if="!model.pricing" class="text-gray-500 dark:text-gray-400">
{{ noPricingLabel }}
</div>
<div v-else class="space-y-2 text-gray-700 dark:text-gray-300">
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t(prefixKey('billingMode')) }}</span>
<span>{{ billingModeLabel }}</span>
</div>
<template v-if="model.pricing.billing_mode === BILLING_MODE_TOKEN">
<PricingRow
:label="t(prefixKey('inputPrice'))"
:value="model.pricing.input_price"
:unit="t(prefixKey('unitPerMillion'))"
:scale="perMillionScale"
/>
<PricingRow
:label="t(prefixKey('outputPrice'))"
:value="model.pricing.output_price"
:unit="t(prefixKey('unitPerMillion'))"
:scale="perMillionScale"
/>
<PricingRow
:label="t(prefixKey('cacheWritePrice'))"
:value="model.pricing.cache_write_price"
:unit="t(prefixKey('unitPerMillion'))"
:scale="perMillionScale"
/>
<PricingRow
:label="t(prefixKey('cacheReadPrice'))"
:value="model.pricing.cache_read_price"
:unit="t(prefixKey('unitPerMillion'))"
:scale="perMillionScale"
/>
</template>
<PricingRow
v-if="
model.pricing.billing_mode === BILLING_MODE_PER_REQUEST &&
model.pricing.per_request_price != null
"
:label="t(prefixKey('perRequestPrice'))"
:value="model.pricing.per_request_price"
:unit="t(prefixKey('unitPerRequest'))"
:scale="1"
/>
<PricingRow
v-if="
model.pricing.billing_mode === BILLING_MODE_IMAGE &&
model.pricing.image_output_price != null
"
:label="t(prefixKey('imageOutputPrice'))"
:value="model.pricing.image_output_price"
:unit="t(prefixKey('unitPerRequest'))"
:scale="1"
/>
<div
v-if="model.pricing.intervals && model.pricing.intervals.length > 0"
class="mt-2 border-t border-gray-200 pt-2 dark:border-dark-600"
>
<div class="mb-1 font-medium text-gray-600 dark:text-gray-400">
{{ t(prefixKey('intervals')) }}
</div>
<div class="space-y-1">
<div
v-for="(iv, idx) in model.pricing.intervals"
:key="idx"
class="flex justify-between text-[11px]"
>
<span class="text-gray-500 dark:text-gray-400">
<template v-if="iv.tier_label">{{ iv.tier_label }}</template>
<template v-else>{{ formatRange(iv.min_tokens, iv.max_tokens) }}</template>
</span>
<span>{{ formatInterval(iv, model.pricing.billing_mode) }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import PricingRow from './PricingRow.vue'
import {
BILLING_MODE_TOKEN,
BILLING_MODE_PER_REQUEST,
BILLING_MODE_IMAGE,
type BillingMode
} from '@/constants/channel'
interface PricingInterval {
min_tokens: number
max_tokens: number | null
tier_label?: string
input_price: number | null
output_price: number | null
cache_write_price: number | null
cache_read_price: number | null
per_request_price: number | null
}
interface SupportedModelPricing {
billing_mode: BillingMode
input_price: number | null
output_price: number | null
cache_write_price: number | null
cache_read_price: number | null
image_output_price: number | null
per_request_price: number | null
intervals: PricingInterval[]
}
interface SupportedModelLike {
name: string
platform: string
pricing: SupportedModelPricing | null
}
const props = withDefaults(
defineProps<{
model: SupportedModelLike
/** i18n 前缀管理端传 `admin.availableChannels.pricing`用户端传 `availableChannels.pricing` */
pricingKeyPrefix?: string
noPricingLabel?: string
showPlatform?: boolean
}>(),
{
pricingKeyPrefix: 'availableChannels.pricing',
noPricingLabel: '',
showPlatform: true
}
)
const { t } = useI18n()
/** 按 token 定价展示时的换算单位:每百万 token。 */
const perMillionScale = 1_000_000
function prefixKey(k: string): string {
return `${props.pricingKeyPrefix}.${k}`
}
const billingModeLabel = computed(() => {
const mode = props.model.pricing?.billing_mode
switch (mode) {
case BILLING_MODE_TOKEN:
return t(prefixKey('billingModeToken'))
case BILLING_MODE_PER_REQUEST:
return t(prefixKey('billingModePerRequest'))
case BILLING_MODE_IMAGE:
return t(prefixKey('billingModeImage'))
default:
return '-'
}
})
function formatScaled(value: number | null | undefined, scale: number): string {
if (value == null) return '-'
return `$${(value * scale).toPrecision(10).replace(/\.?0+$/, '')}`
}
function formatRange(min: number, max: number | null): string {
const maxLabel = max == null ? '∞' : String(max)
return `(${min}, ${maxLabel}]`
}
function formatInterval(iv: PricingInterval, mode: BillingMode): string {
if (mode === BILLING_MODE_PER_REQUEST || mode === BILLING_MODE_IMAGE) {
return formatScaled(iv.per_request_price, 1)
}
const input = formatScaled(iv.input_price, perMillionScale)
const output = formatScaled(iv.output_price, perMillionScale)
return `${input} / ${output}`
}
</script>

View File

@@ -648,6 +648,7 @@ function buildSelfNavItems(withDashboard: boolean): NavItem[] {
{ path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon, hideInSimpleMode: true },
{ path: '/purchase', label: t('nav.buySubscription'), icon: RechargeSubscriptionIcon, hideInSimpleMode: true, featureFlag: flagPayment },
{ path: '/orders', label: t('nav.myOrders'), icon: OrderListIcon, hideInSimpleMode: true, featureFlag: flagPayment },
{ path: '/available-channels', label: t('nav.availableChannels'), icon: ChannelIcon, hideInSimpleMode: true },
{ path: '/redeem', label: t('nav.redeem'), icon: GiftIcon, hideInSimpleMode: true },
{ path: '/profile', label: t('nav.profile'), icon: UserIcon },
...customMenuItemsForUser.value.map((item): NavItem => ({

View File

@@ -0,0 +1,22 @@
/** Channel status values (must match service.Status* constants in Go). */
export const CHANNEL_STATUS_ACTIVE = 'active' as const
export const CHANNEL_STATUS_DISABLED = 'disabled' as const
export type ChannelStatus = typeof CHANNEL_STATUS_ACTIVE | typeof CHANNEL_STATUS_DISABLED
/** Billing mode values (must match service.BillingMode* constants in Go). */
export const BILLING_MODE_TOKEN = 'token' as const
export const BILLING_MODE_PER_REQUEST = 'per_request' as const
export const BILLING_MODE_IMAGE = 'image' as const
export type BillingMode =
| typeof BILLING_MODE_TOKEN
| typeof BILLING_MODE_PER_REQUEST
| typeof BILLING_MODE_IMAGE
/** Billing-model-source values (must match service.BillingModelSource* constants in Go). */
export const BILLING_MODEL_SOURCE_REQUESTED = 'requested' as const
export const BILLING_MODEL_SOURCE_UPSTREAM = 'upstream' as const
export const BILLING_MODEL_SOURCE_CHANNEL_MAPPED = 'channel_mapped' as const
export type BillingModelSource =
| typeof BILLING_MODEL_SOURCE_REQUESTED
| typeof BILLING_MODEL_SOURCE_UPSTREAM
| typeof BILLING_MODEL_SOURCE_CHANNEL_MAPPED

View File

@@ -344,6 +344,7 @@ export default {
users: 'Users',
groups: 'Groups',
channels: 'Channels',
availableChannels: 'Available Channels',
subscriptions: 'Subscriptions',
accounts: 'Accounts',
proxies: 'Proxies',
@@ -929,6 +930,38 @@ export default {
}
},
// Available Channels (user-facing)
availableChannels: {
title: 'Available Channels',
description: 'Channels you can access, along with their supported models and pricing',
searchPlaceholder: 'Search channels or models...',
empty: 'No available channels',
noModels: 'No models configured',
noPricing: 'Pricing not configured',
columns: {
name: 'Channel',
groups: 'Your Accessible Groups',
supportedModels: 'Supported Models'
},
pricing: {
billingMode: 'Billing Mode',
billingModeToken: 'Per Token',
billingModePerRequest: 'Per Request',
billingModeImage: 'Per Image',
inputPrice: 'Input',
outputPrice: 'Output',
cacheWritePrice: 'Cache Write',
cacheReadPrice: 'Cache Read',
imageOutputPrice: 'Image Output',
perRequestPrice: 'Per Request',
intervals: 'Tiered Pricing',
tierLabel: 'Tier',
tokenRange: 'Token Range',
unitPerMillion: '/ 1M tokens',
unitPerRequest: '/ request'
}
},
// Redeem
redeem: {
title: 'Redeem Code',
@@ -1980,6 +2013,48 @@ export default {
}
},
// Available Channels (aggregated read-only view)
availableChannels: {
title: 'Available Channels',
description: 'Aggregated view: each channel with its linked groups and supported models (wildcards expanded)',
searchPlaceholder: 'Search channels or models...',
columns: {
name: 'Channel',
status: 'Status',
billingSource: 'Billing Model Source',
groups: 'Linked Groups',
supportedModels: 'Supported Models'
},
empty: 'No data',
noGroups: 'No linked groups',
noModels: 'No model mapping configured',
noPricing: 'Pricing not configured',
statusActive: 'Active',
statusDisabled: 'Disabled',
billingSource: {
requested: 'Requested model',
upstream: 'Upstream model',
channel_mapped: 'Channel-mapped model'
},
pricing: {
billingMode: 'Billing Mode',
billingModeToken: 'Per Token',
billingModePerRequest: 'Per Request',
billingModeImage: 'Per Image',
inputPrice: 'Input',
outputPrice: 'Output',
cacheWritePrice: 'Cache Write',
cacheReadPrice: 'Cache Read',
imageOutputPrice: 'Image Output',
perRequestPrice: 'Per Request',
intervals: 'Tiered Pricing',
tierLabel: 'Tier',
tokenRange: 'Token Range',
unitPerMillion: '/ 1M tokens',
unitPerRequest: '/ request'
}
},
// Channel Management
channels: {
title: 'Channel Management',

View File

@@ -344,6 +344,7 @@ export default {
users: '用户管理',
groups: '分组管理',
channels: '渠道管理',
availableChannels: '可用渠道',
subscriptions: '订阅管理',
accounts: '账号管理',
proxies: 'IP管理',
@@ -933,6 +934,38 @@ export default {
}
},
// Available Channels (user-facing)
availableChannels: {
title: '可用渠道',
description: '查看您可访问的渠道与其支持的模型、定价',
searchPlaceholder: '搜索渠道或模型...',
empty: '暂无可用渠道',
noModels: '未配置模型',
noPricing: '未配置定价',
columns: {
name: '渠道名',
groups: '我可访问的分组',
supportedModels: '支持模型'
},
pricing: {
billingMode: '计费模式',
billingModeToken: '按 Token',
billingModePerRequest: '按次',
billingModeImage: '按图片',
inputPrice: '输入',
outputPrice: '输出',
cacheWritePrice: '缓存写入',
cacheReadPrice: '缓存读取',
imageOutputPrice: '图片输出',
perRequestPrice: '每次请求',
intervals: '阶梯定价',
tierLabel: '层级',
tokenRange: 'Token 区间',
unitPerMillion: '/ 1M token',
unitPerRequest: '/ 次'
}
},
// Redeem
redeem: {
title: '兑换码',
@@ -2059,6 +2092,48 @@ export default {
}
},
// Available Channels (aggregated read-only view)
availableChannels: {
title: '可用渠道',
description: '按渠道聚合查看关联分组与支持模型(已展开通配符)',
searchPlaceholder: '搜索渠道或模型...',
columns: {
name: '渠道名',
status: '状态',
billingSource: '计费模型来源',
groups: '关联分组',
supportedModels: '支持模型'
},
empty: '暂无数据',
noGroups: '未关联分组',
noModels: '未配置模型映射',
noPricing: '未配置定价',
statusActive: '启用',
statusDisabled: '停用',
billingSource: {
requested: '请求模型',
upstream: '上游模型',
channel_mapped: '映射后模型'
},
pricing: {
billingMode: '计费模式',
billingModeToken: '按 Token',
billingModePerRequest: '按次',
billingModeImage: '按图片',
inputPrice: '输入',
outputPrice: '输出',
cacheWritePrice: '缓存写入',
cacheReadPrice: '缓存读取',
imageOutputPrice: '图片输出',
perRequestPrice: '每次请求',
intervals: '阶梯定价',
tierLabel: '层级',
tokenRange: 'Token 区间',
unitPerMillion: '/ 1M token',
unitPerRequest: '/ 次'
}
},
// Channel Management
channels: {
title: '渠道管理',

View File

@@ -197,6 +197,18 @@ const routes: RouteRecordRaw[] = [
descriptionKey: 'redeem.description'
}
},
{
path: '/available-channels',
name: 'UserAvailableChannels',
component: () => import('@/views/user/AvailableChannelsView.vue'),
meta: {
requiresAuth: true,
requiresAdmin: false,
title: 'Available Channels',
titleKey: 'availableChannels.title',
descriptionKey: 'availableChannels.description'
}
},
{
path: '/profile',
name: 'Profile',
@@ -358,6 +370,18 @@ const routes: RouteRecordRaw[] = [
descriptionKey: 'admin.groups.description'
}
},
{
path: '/admin/available-channels',
name: 'AdminAvailableChannels',
component: () => import('@/views/admin/AvailableChannelsView.vue'),
meta: {
requiresAuth: true,
requiresAdmin: true,
title: 'Available Channels',
titleKey: 'admin.availableChannels.title',
descriptionKey: 'admin.availableChannels.description'
}
},
{
path: '/admin/channels',
redirect: '/admin/channels/pricing'

View File

@@ -0,0 +1,135 @@
<template>
<AppLayout>
<TablePageLayout>
<template #filters>
<div class="flex flex-col justify-between gap-4 lg:flex-row lg:items-start">
<div class="flex flex-1 flex-wrap items-center gap-3">
<div class="relative w-full sm:w-80">
<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.availableChannels.searchPlaceholder')"
class="input pl-10"
/>
</div>
</div>
<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>
</div>
</div>
</template>
<template #table>
<AvailableChannelsTable
:columns="columns"
:rows="filteredChannels"
:loading="loading"
pricing-key-prefix="admin.availableChannels.pricing"
:no-pricing-label="t('admin.availableChannels.noPricing')"
:no-models-label="t('admin.availableChannels.noModels')"
:empty-label="t('admin.availableChannels.empty')"
>
<template #empty-groups>{{ t('admin.availableChannels.noGroups') }}</template>
<template #cell-status="{ row }">
<span
:class="
row.status === CHANNEL_STATUS_ACTIVE
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
: 'bg-gray-100 text-gray-600 dark:bg-dark-700 dark:text-gray-400'
"
class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium"
>
{{ statusLabel(row.status) }}
</span>
</template>
<template #cell-billing_model_source="{ row }">
<span class="text-xs text-gray-700 dark:text-gray-300">
{{
t(
`admin.availableChannels.billingSource.${row.billing_model_source || BILLING_MODEL_SOURCE_CHANNEL_MAPPED}`
)
}}
</span>
</template>
</AvailableChannelsTable>
</template>
</TablePageLayout>
</AppLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import AppLayout from '@/components/layout/AppLayout.vue'
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
import Icon from '@/components/icons/Icon.vue'
import AvailableChannelsTable from '@/components/channels/AvailableChannelsTable.vue'
import channelsAPI, { type AvailableChannel } from '@/api/admin/channels'
import { useAppStore } from '@/stores/app'
import { extractApiErrorMessage } from '@/utils/apiError'
import {
CHANNEL_STATUS_ACTIVE,
BILLING_MODEL_SOURCE_CHANNEL_MAPPED
} from '@/constants/channel'
const { t } = useI18n()
const appStore = useAppStore()
const channels = ref<AvailableChannel[]>([])
const loading = ref(false)
const searchQuery = ref('')
const columns = computed(() => [
{ key: 'name', label: t('admin.availableChannels.columns.name') },
{ key: 'status', label: t('admin.availableChannels.columns.status') },
{ key: 'billing_model_source', label: t('admin.availableChannels.columns.billingSource') },
{ key: 'groups', label: t('admin.availableChannels.columns.groups') },
{ key: 'supported_models', label: t('admin.availableChannels.columns.supportedModels') }
])
function statusLabel(status: string): string {
return status === CHANNEL_STATUS_ACTIVE
? t('admin.availableChannels.statusActive')
: t('admin.availableChannels.statusDisabled')
}
const filteredChannels = computed(() => {
const q = searchQuery.value.trim().toLowerCase()
if (!q) return channels.value
return channels.value.filter((ch) => {
if (ch.name.toLowerCase().includes(q)) return true
if ((ch.description || '').toLowerCase().includes(q)) return true
if (ch.groups.some((g) => g.name.toLowerCase().includes(q))) return true
if (ch.supported_models.some((m) => m.name.toLowerCase().includes(q))) return true
return false
})
})
async function loadChannels() {
loading.value = true
try {
channels.value = await channelsAPI.listAvailable()
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('common.error')))
} finally {
loading.value = false
}
}
onMounted(loadChannels)
</script>

View File

@@ -0,0 +1,98 @@
<template>
<AppLayout>
<TablePageLayout>
<template #filters>
<div class="flex flex-col justify-between gap-4 lg:flex-row lg:items-start">
<div class="flex flex-1 flex-wrap items-center gap-3">
<div class="relative w-full sm:w-80">
<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('availableChannels.searchPlaceholder')"
class="input pl-10"
/>
</div>
</div>
<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>
</div>
</div>
</template>
<template #table>
<AvailableChannelsTable
:columns="columns"
:rows="filteredChannels"
:loading="loading"
pricing-key-prefix="availableChannels.pricing"
:no-pricing-label="t('availableChannels.noPricing')"
:no-models-label="t('availableChannels.noModels')"
:empty-label="t('availableChannels.empty')"
/>
</template>
</TablePageLayout>
</AppLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import AppLayout from '@/components/layout/AppLayout.vue'
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
import Icon from '@/components/icons/Icon.vue'
import AvailableChannelsTable from '@/components/channels/AvailableChannelsTable.vue'
import userChannelsAPI, { type UserAvailableChannel } from '@/api/channels'
import { useAppStore } from '@/stores/app'
import { extractApiErrorMessage } from '@/utils/apiError'
const { t } = useI18n()
const appStore = useAppStore()
const channels = ref<UserAvailableChannel[]>([])
const loading = ref(false)
const searchQuery = ref('')
const columns = computed(() => [
{ key: 'name', label: t('availableChannels.columns.name') },
{ key: 'groups', label: t('availableChannels.columns.groups') },
{ key: 'supported_models', label: t('availableChannels.columns.supportedModels') }
])
const filteredChannels = computed(() => {
const q = searchQuery.value.trim().toLowerCase()
if (!q) return channels.value
return channels.value.filter((ch) => {
if (ch.name.toLowerCase().includes(q)) return true
if ((ch.description || '').toLowerCase().includes(q)) return true
if (ch.groups.some((g) => g.name.toLowerCase().includes(q))) return true
if (ch.supported_models.some((m) => m.name.toLowerCase().includes(q))) return true
return false
})
})
async function loadChannels() {
loading.value = true
try {
channels.value = await userChannelsAPI.getAvailable()
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('common.error')))
} finally {
loading.value = false
}
}
onMounted(loadChannels)
</script>