380 lines
16 KiB
Vue
380 lines
16 KiB
Vue
<template>
|
||
<!-- .table-wrapper 是 TablePageLayout 滚动链的挂载点:外层 .table-scroll-container
|
||
负责卡片外观并 overflow-hidden,本层接收 overflow-y-auto 才能在内容超高时滚动。 -->
|
||
<div class="table-wrapper">
|
||
<table
|
||
data-testid="desktop-channels"
|
||
class="!hidden w-full table-fixed border-collapse text-sm lg:!table"
|
||
>
|
||
<thead>
|
||
<tr class="border-b border-gray-100 bg-gray-50/50 text-xs font-medium uppercase tracking-wide text-gray-500 dark:border-dark-700 dark:bg-dark-800/50 dark:text-gray-400">
|
||
<th class="w-[180px] px-4 py-3 text-center">{{ columns.name }}</th>
|
||
<th class="w-[200px] px-4 py-3 text-left">{{ columns.description }}</th>
|
||
<th class="w-[140px] px-4 py-3 text-left">{{ columns.platform }}</th>
|
||
<th class="px-4 py-3 text-left">{{ columns.groups }}</th>
|
||
<th class="px-4 py-3 text-left">{{ columns.supportedModels }}</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody v-if="loading">
|
||
<tr>
|
||
<td colspan="5" class="py-10 text-center">
|
||
<Icon name="refresh" size="lg" class="inline-block animate-spin text-gray-400" />
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
<tbody v-else-if="rows.length === 0">
|
||
<tr>
|
||
<td colspan="5" class="py-12 text-center">
|
||
<Icon name="inbox" size="xl" class="mx-auto mb-3 h-12 w-12 text-gray-400" />
|
||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ emptyLabel }}</p>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
<!-- 每个渠道一个 tbody:首行 td rowspan 渠道名,后续行只渲染其余三列。
|
||
tbody 之间强分隔线表达"渠道边界",tbody 内部用淡分隔线区分平台。 -->
|
||
<tbody
|
||
v-else
|
||
v-for="(channel, chIdx) in rows"
|
||
:key="`${channel.name}-${chIdx}`"
|
||
class="border-b-2 border-gray-200 last:border-b-0 dark:border-dark-600"
|
||
>
|
||
<tr
|
||
v-for="(section, secIdx) in channel.platforms"
|
||
:key="`${channel.name}-${section.platform}`"
|
||
class="transition-colors hover:bg-gray-50/40 dark:hover:bg-dark-800/40"
|
||
:class="{ 'border-t border-gray-100/70 dark:border-dark-700/50': secIdx > 0 }"
|
||
>
|
||
<!-- 渠道名:只在第一行渲染并用 rowspan 纵向合并 -->
|
||
<td
|
||
v-if="secIdx === 0"
|
||
:rowspan="channel.platforms.length"
|
||
class="px-4 py-3 text-center align-middle font-medium text-gray-900 dark:text-white"
|
||
>
|
||
{{ channel.name }}
|
||
</td>
|
||
|
||
<!-- 描述:独立一列,同样用 rowspan 纵向合并 -->
|
||
<td
|
||
v-if="secIdx === 0"
|
||
:rowspan="channel.platforms.length"
|
||
class="px-4 py-3 align-middle text-xs text-gray-500 dark:text-gray-400"
|
||
>
|
||
<template v-if="channel.description">{{ channel.description }}</template>
|
||
<span v-else class="text-gray-400">-</span>
|
||
</td>
|
||
|
||
<!-- 平台徽章 -->
|
||
<td class="align-top px-4 py-3">
|
||
<span
|
||
:class="[
|
||
'inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-[11px] font-medium uppercase',
|
||
platformBadgeClass(section.platform),
|
||
]"
|
||
>
|
||
<PlatformIcon :platform="section.platform as GroupPlatform" size="xs" />
|
||
{{ section.platform }}
|
||
</span>
|
||
</td>
|
||
|
||
<!-- 分组:专属分组在前(紫色 shield 行),公开分组在后(灰色 globe 行)。 -->
|
||
<td class="align-top px-4 py-3">
|
||
<div class="flex flex-col gap-1.5">
|
||
<div
|
||
v-if="exclusiveGroups(section).length > 0"
|
||
class="flex flex-wrap items-center gap-1.5"
|
||
>
|
||
<span
|
||
class="inline-flex items-center gap-0.5 text-[10px] font-medium uppercase text-purple-600 dark:text-purple-400"
|
||
:title="t('availableChannels.exclusiveTooltip')"
|
||
>
|
||
<Icon name="shield" size="xs" class="h-3 w-3" />
|
||
{{ t('availableChannels.exclusive') }}
|
||
</span>
|
||
<div
|
||
v-for="g in exclusiveGroups(section)"
|
||
:key="`ex-${g.id}`"
|
||
class="inline-flex flex-wrap items-center gap-1"
|
||
>
|
||
<GroupBadge
|
||
:name="g.name"
|
||
:platform="g.platform as GroupPlatform"
|
||
:subscription-type="(g.subscription_type || 'standard') as SubscriptionType"
|
||
:rate-multiplier="g.rate_multiplier"
|
||
:user-rate-multiplier="userGroupRates[g.id] ?? null"
|
||
always-show-rate
|
||
/>
|
||
<span
|
||
v-if="hasPeakRate(g)"
|
||
class="inline-flex items-center gap-1 rounded-md bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 dark:bg-amber-900/20 dark:text-amber-300"
|
||
:title="peakRateTitle(g)"
|
||
>
|
||
<Icon name="clock" size="xs" class="h-3 w-3" />
|
||
{{ peakRateLabel(g) }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div
|
||
v-if="publicGroups(section).length > 0"
|
||
class="flex flex-wrap items-center gap-1.5"
|
||
>
|
||
<span
|
||
class="inline-flex items-center gap-0.5 text-[10px] font-medium uppercase text-gray-500 dark:text-gray-400"
|
||
:title="t('availableChannels.publicTooltip')"
|
||
>
|
||
<Icon name="globe" size="xs" class="h-3 w-3" />
|
||
{{ t('availableChannels.public') }}
|
||
</span>
|
||
<div
|
||
v-for="g in publicGroups(section)"
|
||
:key="`pub-${g.id}`"
|
||
class="inline-flex flex-wrap items-center gap-1"
|
||
>
|
||
<GroupBadge
|
||
:name="g.name"
|
||
:platform="g.platform as GroupPlatform"
|
||
:subscription-type="(g.subscription_type || 'standard') as SubscriptionType"
|
||
:rate-multiplier="g.rate_multiplier"
|
||
:user-rate-multiplier="userGroupRates[g.id] ?? null"
|
||
always-show-rate
|
||
/>
|
||
<span
|
||
v-if="hasPeakRate(g)"
|
||
class="inline-flex items-center gap-1 rounded-md bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 dark:bg-amber-900/20 dark:text-amber-300"
|
||
:title="peakRateTitle(g)"
|
||
>
|
||
<Icon name="clock" size="xs" class="h-3 w-3" />
|
||
{{ peakRateLabel(g) }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<span v-if="section.groups.length === 0" class="text-xs text-gray-400">-</span>
|
||
</div>
|
||
</td>
|
||
|
||
<!-- 支持模型 -->
|
||
<td class="align-top px-4 py-3">
|
||
<div class="flex flex-wrap gap-1">
|
||
<SupportedModelChip
|
||
v-for="m in section.supported_models"
|
||
:key="`${section.platform}-${m.name}`"
|
||
:model="m"
|
||
:pricing-key-prefix="pricingKeyPrefix"
|
||
:no-pricing-label="noPricingLabel"
|
||
:show-platform="false"
|
||
:platform-hint="section.platform"
|
||
/>
|
||
<span v-if="section.supported_models.length === 0" class="text-xs text-gray-400">
|
||
{{ noModelsLabel }}
|
||
</span>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
|
||
<div data-testid="mobile-channels" class="w-full min-w-0 overflow-x-hidden lg:hidden">
|
||
<div v-if="loading" data-testid="mobile-loading" class="py-10 text-center">
|
||
<Icon name="refresh" size="lg" class="inline-block animate-spin text-gray-400" />
|
||
</div>
|
||
<div v-else-if="rows.length === 0" data-testid="mobile-empty" class="py-12 text-center">
|
||
<Icon name="inbox" size="xl" class="mx-auto mb-3 h-12 w-12 text-gray-400" />
|
||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ emptyLabel }}</p>
|
||
</div>
|
||
<section
|
||
v-else
|
||
v-for="(channel, chIdx) in rows"
|
||
:key="`mobile-${channel.name}-${chIdx}`"
|
||
class="border-b-2 border-gray-200 px-4 py-4 last:border-b-0 dark:border-dark-600"
|
||
>
|
||
<header class="mb-3 min-w-0">
|
||
<h3 class="break-words text-sm font-semibold text-gray-900 dark:text-white">
|
||
{{ channel.name }}
|
||
</h3>
|
||
<p class="mt-1 break-words text-xs leading-5 text-gray-500 dark:text-gray-400">
|
||
{{ channel.description || '-' }}
|
||
</p>
|
||
</header>
|
||
|
||
<div class="divide-y divide-gray-100 dark:divide-dark-700/60">
|
||
<div
|
||
v-for="section in channel.platforms"
|
||
:key="`mobile-${channel.name}-${section.platform}`"
|
||
class="min-w-0 py-3 first:pt-0 last:pb-0"
|
||
>
|
||
<span
|
||
:class="[
|
||
'inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-[11px] font-medium uppercase',
|
||
platformBadgeClass(section.platform),
|
||
]"
|
||
>
|
||
<PlatformIcon :platform="section.platform as GroupPlatform" size="xs" />
|
||
{{ section.platform }}
|
||
</span>
|
||
|
||
<dl class="mt-3 space-y-3">
|
||
<div class="min-w-0">
|
||
<dt class="mb-1.5 text-[11px] font-medium text-gray-500 dark:text-gray-400">
|
||
{{ columns.groups }}
|
||
</dt>
|
||
<dd class="flex min-w-0 flex-col gap-2">
|
||
<div
|
||
v-if="exclusiveGroups(section).length > 0"
|
||
class="flex min-w-0 flex-wrap items-center gap-1.5"
|
||
>
|
||
<span
|
||
class="inline-flex items-center gap-0.5 text-[10px] font-medium uppercase text-purple-600 dark:text-purple-400"
|
||
:title="t('availableChannels.exclusiveTooltip')"
|
||
>
|
||
<Icon name="shield" size="xs" class="h-3 w-3" />
|
||
{{ t('availableChannels.exclusive') }}
|
||
</span>
|
||
<div
|
||
v-for="g in exclusiveGroups(section)"
|
||
:key="`mobile-ex-${g.id}`"
|
||
class="inline-flex max-w-full min-w-0 flex-wrap items-center gap-1"
|
||
>
|
||
<GroupBadge
|
||
class="max-w-full"
|
||
:name="g.name"
|
||
:platform="g.platform as GroupPlatform"
|
||
:subscription-type="(g.subscription_type || 'standard') as SubscriptionType"
|
||
:rate-multiplier="g.rate_multiplier"
|
||
:user-rate-multiplier="userGroupRates[g.id] ?? null"
|
||
always-show-rate
|
||
/>
|
||
<span
|
||
v-if="hasPeakRate(g)"
|
||
class="inline-flex items-center gap-1 rounded-md bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 dark:bg-amber-900/20 dark:text-amber-300"
|
||
:title="peakRateTitle(g)"
|
||
>
|
||
<Icon name="clock" size="xs" class="h-3 w-3" />
|
||
{{ peakRateLabel(g) }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div
|
||
v-if="publicGroups(section).length > 0"
|
||
class="flex min-w-0 flex-wrap items-center gap-1.5"
|
||
>
|
||
<span
|
||
class="inline-flex items-center gap-0.5 text-[10px] font-medium uppercase text-gray-500 dark:text-gray-400"
|
||
:title="t('availableChannels.publicTooltip')"
|
||
>
|
||
<Icon name="globe" size="xs" class="h-3 w-3" />
|
||
{{ t('availableChannels.public') }}
|
||
</span>
|
||
<div
|
||
v-for="g in publicGroups(section)"
|
||
:key="`mobile-pub-${g.id}`"
|
||
class="inline-flex max-w-full min-w-0 flex-wrap items-center gap-1"
|
||
>
|
||
<GroupBadge
|
||
class="max-w-full"
|
||
:name="g.name"
|
||
:platform="g.platform as GroupPlatform"
|
||
:subscription-type="(g.subscription_type || 'standard') as SubscriptionType"
|
||
:rate-multiplier="g.rate_multiplier"
|
||
:user-rate-multiplier="userGroupRates[g.id] ?? null"
|
||
always-show-rate
|
||
/>
|
||
<span
|
||
v-if="hasPeakRate(g)"
|
||
class="inline-flex items-center gap-1 rounded-md bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 dark:bg-amber-900/20 dark:text-amber-300"
|
||
:title="peakRateTitle(g)"
|
||
>
|
||
<Icon name="clock" size="xs" class="h-3 w-3" />
|
||
{{ peakRateLabel(g) }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<span v-if="section.groups.length === 0" class="text-xs text-gray-400">-</span>
|
||
</dd>
|
||
</div>
|
||
|
||
<div class="min-w-0">
|
||
<dt class="mb-1.5 text-[11px] font-medium text-gray-500 dark:text-gray-400">
|
||
{{ columns.supportedModels }}
|
||
</dt>
|
||
<dd class="flex min-w-0 flex-wrap gap-1">
|
||
<SupportedModelChip
|
||
v-for="m in section.supported_models"
|
||
:key="`mobile-${section.platform}-${m.name}`"
|
||
class="max-w-full [&>span]:max-w-full [&>span]:truncate"
|
||
:model="m"
|
||
:pricing-key-prefix="pricingKeyPrefix"
|
||
:no-pricing-label="noPricingLabel"
|
||
:show-platform="false"
|
||
:platform-hint="section.platform"
|
||
/>
|
||
<span v-if="section.supported_models.length === 0" class="text-xs text-gray-400">
|
||
{{ noModelsLabel }}
|
||
</span>
|
||
</dd>
|
||
</div>
|
||
</dl>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { useI18n } from 'vue-i18n'
|
||
import Icon from '@/components/icons/Icon.vue'
|
||
import PlatformIcon from '@/components/common/PlatformIcon.vue'
|
||
import GroupBadge from '@/components/common/GroupBadge.vue'
|
||
import SupportedModelChip from './SupportedModelChip.vue'
|
||
import type { UserAvailableChannel, UserAvailableGroup, UserChannelPlatformSection } from '@/api/channels'
|
||
import type { GroupPlatform, SubscriptionType } from '@/types'
|
||
import { platformBadgeClass } from '@/utils/platformColors'
|
||
import { useAppStore } from '@/stores/app'
|
||
import { hasPeakRate as groupHasPeakRate, formatPeakRateWindow, serverTimezoneLabel } from '@/utils/peak-rate'
|
||
|
||
const props = defineProps<{
|
||
columns: {
|
||
name: string
|
||
description: string
|
||
platform: string
|
||
groups: string
|
||
supportedModels: string
|
||
}
|
||
rows: UserAvailableChannel[]
|
||
loading: boolean
|
||
pricingKeyPrefix: string
|
||
noPricingLabel: string
|
||
noModelsLabel: string
|
||
emptyLabel: string
|
||
/** 用户专属倍率(group_id → multiplier);无专属时由 GroupBadge 仅显示默认倍率。 */
|
||
userGroupRates: Record<number, number>
|
||
}>()
|
||
|
||
// Suppress unused warning — props is accessed via template automatically but
|
||
// the explicit reference here keeps the linter from flagging userGroupRates.
|
||
void props.userGroupRates
|
||
|
||
const { t } = useI18n()
|
||
|
||
function exclusiveGroups(section: UserChannelPlatformSection): UserAvailableGroup[] {
|
||
return section.groups.filter((g) => g.is_exclusive)
|
||
}
|
||
|
||
function publicGroups(section: UserChannelPlatformSection): UserAvailableGroup[] {
|
||
return section.groups.filter((g) => !g.is_exclusive)
|
||
}
|
||
|
||
const appStore = useAppStore()
|
||
|
||
function hasPeakRate(group: UserAvailableGroup): boolean {
|
||
return groupHasPeakRate(group)
|
||
}
|
||
|
||
function peakRateLabel(group: UserAvailableGroup): string {
|
||
return formatPeakRateWindow(group, serverTimezoneLabel(appStore.cachedPublicSettings?.server_utc_offset))
|
||
}
|
||
|
||
function peakRateTitle(group: UserAvailableGroup): string {
|
||
return t('common.peakRateTooltip', { window: peakRateLabel(group) }) + t('common.peakRateImageNote')
|
||
}
|
||
</script>
|