Files
sub2api/frontend/src/views/user/AvailableChannelsView.vue
T
erio 25a5035503 fix(available-channels): description as own column, fixed table layout
- 描述独立成列:渠道名与描述各占一列,均用 rowspan 纵向合并
- 渠道名单元格 text-center + align-middle,合并后视觉居中
- table-fixed:给 name/description/platform 显式宽度,groups 和
  supported_models 在剩余空间均分。支持模型列此前在 table-auto 下
  不会换行导致横向溢出遮挡(反馈截图),加 table-fixed 后天然 flex-wrap
- i18n 增加 availableChannels.columns.description(zh/en)
2026-04-22 19:47:03 +08:00

128 lines
4.5 KiB
Vue

<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="columnLabels"
:rows="filteredChannels"
:loading="loading"
:user-group-rates="userGroupRates"
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 userGroupsAPI from '@/api/groups'
import { useAppStore } from '@/stores/app'
import { extractApiErrorMessage } from '@/utils/apiError'
const { t } = useI18n()
const appStore = useAppStore()
const channels = ref<UserAvailableChannel[]>([])
const userGroupRates = ref<Record<number, number>>({})
const loading = ref(false)
const searchQuery = ref('')
const columnLabels = computed(() => ({
name: t('availableChannels.columns.name'),
description: t('availableChannels.columns.description'),
platform: t('availableChannels.columns.platform'),
groups: t('availableChannels.columns.groups'),
supportedModels: t('availableChannels.columns.supportedModels'),
}))
/**
* 搜索过滤:
* - 命中渠道名/描述 → 整个渠道(所有 platforms)都保留
* - 否则按 platform/group/model 维度在 sections 里过滤,保留有匹配的 section
* - 所有 sections 都不匹配时,渠道本身被过滤掉
*/
const filteredChannels = computed(() => {
const q = searchQuery.value.trim().toLowerCase()
if (!q) return channels.value
return channels.value
.map((ch) => {
const nameHit = ch.name.toLowerCase().includes(q)
const descHit = (ch.description || '').toLowerCase().includes(q)
if (nameHit || descHit) return ch
const matchingSections = ch.platforms.filter(
(p) =>
p.platform.toLowerCase().includes(q) ||
p.groups.some((g) => g.name.toLowerCase().includes(q)) ||
p.supported_models.some((m) => m.name.toLowerCase().includes(q)),
)
if (matchingSections.length === 0) return null
return { ...ch, platforms: matchingSections }
})
.filter((ch): ch is UserAvailableChannel => ch !== null)
})
async function loadChannels() {
loading.value = true
try {
// 渠道列表和用户专属倍率并发拉取。专属倍率失败不阻塞渠道展示——
// 失败时只是无法渲染专属倍率角标,降级为仅显示默认倍率。
const [list, rates] = await Promise.all([
userChannelsAPI.getAvailable(),
userGroupsAPI.getUserGroupRates().catch((err: unknown) => {
console.error('Failed to load user group rates:', err)
return {} as Record<number, number>
}),
])
channels.value = list
userGroupRates.value = rates
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('common.error')))
} finally {
loading.value = false
}
}
onMounted(loadChannels)
</script>