mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-07 16:48:45 +08:00
Merge pull request #4900 from wucm667/fix/issue-4889-mobile-available-channels
fix(frontend): adapt available channels for mobile
This commit is contained in:
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user