Merge pull request #4900 from wucm667/fix/issue-4889-mobile-available-channels

fix(frontend): adapt available channels for mobile
This commit is contained in:
Wesley Liddick
2026-07-27 10:19:44 +08:00
committed by GitHub
2 changed files with 309 additions and 2 deletions
@@ -2,7 +2,10 @@
<!-- .table-wrapper 是 TablePageLayout 滚动链的挂载点:外层 .table-scroll-container
负责卡片外观并 overflow-hidden,本层接收 overflow-y-auto 才能在内容超高时滚动。 -->
<div class="table-wrapper">
<table class="w-full table-fixed border-collapse text-sm">
<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>
@@ -168,6 +171,151 @@
</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>
@@ -2,7 +2,19 @@ import { readFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { createPinia } from 'pinia'
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import AvailableChannelsTable from '../AvailableChannelsTable.vue'
import type { UserAvailableChannel } from '@/api/channels'
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({ t: (key: string) => key }),
}
})
const componentPath = resolve(dirname(fileURLToPath(import.meta.url)), '../AvailableChannelsTable.vue')
const componentSource = readFileSync(componentPath, 'utf8')
@@ -18,3 +30,150 @@ describe('AvailableChannelsTable scroll integration', () => {
expect(componentSource).not.toMatch(/<div class="card overflow-hidden">/)
})
})
const rows: UserAvailableChannel[] = [
{
name: 'Primary channel',
description: 'Fast and reliable access',
platforms: [
{
platform: 'anthropic',
groups: [
{
id: 1,
name: 'Exclusive Pro',
platform: 'anthropic',
subscription_type: 'standard',
rate_multiplier: 1.2,
peak_rate_enabled: true,
peak_start: '08:00',
peak_end: '10:00',
peak_rate_multiplier: 1.5,
is_exclusive: true,
},
{
id: 2,
name: 'Public',
platform: 'anthropic',
subscription_type: 'standard',
rate_multiplier: 1,
peak_rate_enabled: false,
peak_start: '',
peak_end: '',
peak_rate_multiplier: 1,
is_exclusive: false,
},
],
supported_models: [{ name: 'claude-test', platform: 'anthropic', pricing: null }],
},
],
},
]
const baseProps = {
columns: {
name: 'Channel',
description: 'Description',
platform: 'Platform',
groups: 'Groups and rates',
supportedModels: 'Models and pricing',
},
rows,
loading: false,
pricingKeyPrefix: 'availableChannels.pricing',
noPricingLabel: 'No pricing',
noModelsLabel: 'No models',
emptyLabel: 'No channels',
userGroupRates: { 1: 0.8 },
}
function mountTable(props = {}) {
return mount(AvailableChannelsTable, {
props: { ...baseProps, ...props },
global: {
plugins: [createPinia()],
stubs: {
Icon: { props: ['name'], template: '<i :data-icon="name" />' },
PlatformIcon: { template: '<i data-platform-icon />' },
GroupBadge: {
props: ['name', 'rateMultiplier', 'userRateMultiplier'],
template:
'<span data-group-badge>{{ name }}:{{ rateMultiplier }}:{{ userRateMultiplier }}</span>',
},
SupportedModelChip: {
props: ['model', 'noPricingLabel'],
template: '<span data-model-chip>{{ model.name }}:{{ noPricingLabel }}</span>',
},
},
},
})
}
describe('AvailableChannelsTable responsive surfaces', () => {
it('keeps the five-column table as the desktop-only surface', () => {
const wrapper = mountTable()
const desktop = wrapper.get('[data-testid="desktop-channels"]')
// TablePageLayout has a more-specific `display: table` rule for descendants,
// so these display utilities must remain important at both breakpoints.
expect(desktop.classes()).toContain('!hidden')
expect(desktop.classes()).toContain('lg:!table')
expect(desktop.findAll('thead th')).toHaveLength(5)
expect(desktop.text()).toContain('Primary channel')
expect(desktop.text()).toContain('Fast and reliable access')
expect(desktop.findAll('[data-group-badge]')).toHaveLength(2)
expect(desktop.get('[data-model-chip]').text()).toContain('claude-test:No pricing')
})
it('renders a mobile-only readable surface with groups, rates, peaks, and model pricing chips', () => {
const wrapper = mountTable()
const mobile = wrapper.get('[data-testid="mobile-channels"]')
expect(mobile.classes()).toContain('lg:hidden')
expect(mobile.classes()).toContain('overflow-x-hidden')
expect(mobile.text()).toContain('Primary channel')
expect(mobile.text()).toContain('Fast and reliable access')
expect(mobile.text()).toContain('Groups and rates')
expect(mobile.text()).toContain('Models and pricing')
expect(mobile.text()).toContain('availableChannels.exclusive')
expect(mobile.text()).toContain('availableChannels.public')
expect(mobile.get('[data-group-badge]').text()).toBe('Exclusive Pro:1.2:0.8')
expect(mobile.findAll('[data-group-badge]')).toHaveLength(2)
expect(mobile.get('[data-icon="clock"]')).toBeTruthy()
expect(mobile.text()).toContain('08:00')
expect(mobile.text()).toContain('10:00')
expect(mobile.text()).toContain('×1.5')
expect(mobile.get('[data-model-chip]').text()).toBe('claude-test:No pricing')
expect(mobile.findAll('.max-w-full')).not.toHaveLength(0)
})
it('keeps the mobile placeholders when a platform has no groups or models', () => {
const wrapper = mountTable({
rows: [
{
name: 'Fallback channel',
description: '',
platforms: [{ platform: 'openai', groups: [], supported_models: [] }],
},
],
})
const mobile = wrapper.get('[data-testid="mobile-channels"]')
expect(mobile.text()).toContain('Fallback channel')
expect(mobile.text()).toContain('openai')
expect(mobile.text()).toContain('No models')
expect(mobile.findAll('dd')[0].text()).toBe('-')
})
it('provides loading and empty states on both responsive surfaces', async () => {
const wrapper = mountTable({ loading: true, rows: [] })
expect(wrapper.get('[data-testid="desktop-channels"] [data-icon="refresh"]')).toBeTruthy()
expect(wrapper.get('[data-testid="mobile-loading"] [data-icon="refresh"]')).toBeTruthy()
await wrapper.setProps({ loading: false })
expect(wrapper.get('[data-testid="desktop-channels"]').text()).toContain('No channels')
expect(wrapper.get('[data-testid="mobile-empty"]').text()).toContain('No channels')
})
})