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:
@@ -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
|
||||
|
||||
60
frontend/src/api/channels.ts
Normal file
60
frontend/src/api/channels.ts
Normal 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
|
||||
@@ -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'
|
||||
|
||||
110
frontend/src/components/channels/AvailableChannelsTable.vue
Normal file
110
frontend/src/components/channels/AvailableChannelsTable.vue
Normal 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>
|
||||
29
frontend/src/components/channels/PricingRow.vue
Normal file
29
frontend/src/components/channels/PricingRow.vue
Normal 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>
|
||||
214
frontend/src/components/channels/SupportedModelChip.vue
Normal file
214
frontend/src/components/channels/SupportedModelChip.vue
Normal 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>
|
||||
@@ -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 => ({
|
||||
|
||||
22
frontend/src/constants/channel.ts
Normal file
22
frontend/src/constants/channel.ts
Normal 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
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '渠道管理',
|
||||
|
||||
@@ -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'
|
||||
|
||||
135
frontend/src/views/admin/AvailableChannelsView.vue
Normal file
135
frontend/src/views/admin/AvailableChannelsView.vue
Normal 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>
|
||||
98
frontend/src/views/user/AvailableChannelsView.vue
Normal file
98
frontend/src/views/user/AvailableChannelsView.vue
Normal 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>
|
||||
Reference in New Issue
Block a user