新增:多层分类功能

This commit is contained in:
ivan 2026-02-13 20:12:30 +08:00
parent 8d103e2d98
commit 0e8d19db0c
2 changed files with 388 additions and 15 deletions

110
src/api/category.ts Normal file
View File

@ -0,0 +1,110 @@
import { get } from './request'
/** 分类树节点(与后端返回结构一致) */
export interface CategoryTreeNode {
id: string
label: string
slug: string
/** 第二层专用MDI 图标名,如 mdi-view-grid-outline */
icon?: string
/** 第三层展示时的区块标题,如「加密货币」 */
sectionTitle?: string
forceShow?: boolean
forceHide?: boolean
publishedAt?: string
updatedBy?: number
createdAt?: string
updatedAt?: string
children?: CategoryTreeNode[]
}
/** 模拟分类数据:一层(财经)、二层(体育/加密带图标)、三层(政治) */
export const MOCK_CATEGORY_TREE: CategoryTreeNode[] = [
{
id: '1',
label: '政治',
slug: 'politics',
children: [
{
id: '11',
label: '政治1',
slug: 'politics1',
children: [
{ id: '111', label: '政治1-A', slug: 'politics1a', children: [] },
{ id: '112', label: '政治1-B', slug: 'politics1b', children: [] },
],
},
{ id: '12', label: '政治2', slug: 'politics2', children: [] },
],
},
{
id: '2',
label: '体育',
slug: 'sports',
children: [
{ id: '21', label: '足球', slug: 'football', children: [] },
{ id: '22', label: '篮球', slug: 'basketball', children: [] },
],
},
{
id: '3',
label: '加密',
slug: 'crypto',
sectionTitle: '加密货币',
children: [
{
id: '31',
label: '全部',
slug: 'all',
icon: 'mdi-view-grid-outline',
children: [
{ id: '311', label: '全部', slug: 'all', children: [] },
{ id: '312', label: 'Above / Below', slug: 'above-below', children: [] },
{ id: '313', label: 'Up / Down', slug: 'up-down', children: [] },
{ id: '314', label: 'BTC', slug: 'btc', children: [] },
{ id: '315', label: 'ETH', slug: 'eth', children: [] },
],
},
{ id: '32', label: '5分钟', slug: '5m', icon: 'mdi-format-list-bulleted', children: [] },
{ id: '33', label: '15分钟', slug: '15m', icon: 'mdi-clock-outline', children: [] },
{ id: '34', label: '每小时', slug: '1h', icon: 'mdi-refresh', children: [] },
{ id: '35', label: '4小时', slug: '4h', icon: 'mdi-clock-outline', children: [] },
{ id: '36', label: '每天', slug: '1d', icon: 'mdi-calendar', children: [] },
],
},
{ id: '4', label: '财务', slug: 'finance', children: [] },
{ id: '5', label: '地缘政治', slug: 'geopolitics', children: [] },
{ id: '0', label: '最新', slug: 'latest', children: [] },
]
/** 分类树接口响应 */
export interface CategoryTreeResponse {
code: number
data: CategoryTreeNode[]
msg: string
}
/**
*
* GET /PmTag/getPmTagPublic
*
* children
* data { list: [] } CategoryTreeNode[]
*/
export async function getCategoryTree(): Promise<CategoryTreeResponse> {
const res = await get<{ code: number; data: CategoryTreeNode[] | { list?: CategoryTreeNode[] }; msg: string }>(
'/PmTag/getPmTagPublic'
)
let data: CategoryTreeNode[] = []
const raw = res.data
if (Array.isArray(raw)) {
data = raw
} else if (raw && typeof raw === 'object') {
if (Array.isArray((raw as { list?: CategoryTreeNode[] }).list)) {
data = (raw as { list: CategoryTreeNode[] }).list
} else if ((raw as CategoryTreeNode).id && (raw as CategoryTreeNode).label) {
data = [raw as CategoryTreeNode]
}
}
return { code: res.code, data, msg: res.msg }
}

View File

@ -1,13 +1,61 @@
<template> <template>
<div class="home-page"> <div class="home-page">
<!-- 分类置顶全宽无左右留白 -->
<div v-if="categoryLayers.length > 0" class="home-category-wrap">
<!-- 第一层纯文字 tabs -->
<div class="home-category-layer home-category-layer--text">
<v-tabs
:model-value="layerActiveValues[0]"
class="home-tab-bar"
@update:model-value="onCategorySelect(0, $event)"
>
<v-tab v-for="item in categoryLayers[0]" :key="item.id" :value="item.id">
{{ item.label }}
</v-tab>
</v-tabs>
</div>
<!-- 第二层带图标的横向选项icon 在上文字在下 -->
<div v-if="categoryLayers.length >= 2" class="home-category-layer home-category-layer--icon">
<div class="home-category-icon-row">
<button
v-for="item in categoryLayers[1]"
:key="item.id"
type="button"
class="home-category-icon-item"
:class="{ 'home-category-icon-item--active': layerActiveValues[1] === item.id }"
@click="onCategorySelect(1, item.id)"
>
<v-icon v-if="item.icon" size="24" class="home-category-icon">{{ item.icon }}</v-icon>
<span v-else class="home-category-icon-placeholder" aria-hidden="true" />
<span class="home-category-icon-label">{{ item.label }}</span>
</button>
</div>
</div>
<!-- 第三层标题 + 搜索/筛选 + 文字 tabs -->
<div v-if="categoryLayers.length >= 3" class="home-category-layer home-category-layer--third">
<div class="home-category-third-header">
<h3 class="home-category-third-title">{{ selectedLayer0SectionTitle }}</h3>
<div class="home-category-third-actions">
<v-btn icon variant="text" size="small" class="home-category-action-btn" aria-label="搜索">
<v-icon size="20">mdi-magnify</v-icon>
</v-btn>
<v-btn icon variant="text" size="small" class="home-category-action-btn" aria-label="筛选">
<v-icon size="20">mdi-filter-outline</v-icon>
</v-btn>
</div>
</div>
<v-tabs
:model-value="layerActiveValues[2]"
class="home-tab-bar home-tab-bar--compact"
@update:model-value="onCategorySelect(2, $event)"
>
<v-tab v-for="item in categoryLayers[2]" :key="item.id" :value="item.id">
{{ item.label }}
</v-tab>
</v-tabs>
</div>
</div>
<v-container fluid class="home-container"> <v-container fluid class="home-container">
<v-row justify="center" align="center" class="home-tabs">
<v-tabs v-model="activeTab" class="home-tab-bar">
<v-tab value="overview">Market Overview</v-tab>
<v-tab value="trending">Trending</v-tab>
<v-tab value="portfolio">Portfolio</v-tab>
</v-tabs>
</v-row>
<!-- 可滚动容器作为 v-pull-to-refresh 的父元素组件据此判断 scrollTop 仅在顶部时才响应下拉 --> <!-- 可滚动容器作为 v-pull-to-refresh 的父元素组件据此判断 scrollTop 仅在顶部时才响应下拉 -->
<div ref="scrollRef" class="home-list-scroll"> <div ref="scrollRef" class="home-list-scroll">
<v-pull-to-refresh class="pull-to-refresh" @load="onRefresh"> <v-pull-to-refresh class="pull-to-refresh" @load="onRefresh">
@ -178,11 +226,85 @@ import {
clearEventListCache, clearEventListCache,
type EventCardItem, type EventCardItem,
} from '../api/event' } from '../api/event'
import { getCategoryTree, MOCK_CATEGORY_TREE, type CategoryTreeNode } from '../api/category'
const { mobile } = useDisplay() const { mobile } = useDisplay()
const isMobile = computed(() => mobile.value) const isMobile = computed(() => mobile.value)
const activeTab = ref('overview') /** 分类树(顶层) */
const categoryTree = ref<CategoryTreeNode[]>([])
/** 每层选中的 id[layer0, layer1?, layer2?] */
const layerActiveValues = ref<string[]>([])
/** 过滤 forceHide 的节点 */
function filterVisible(nodes: CategoryTreeNode[] | undefined): CategoryTreeNode[] {
if (!nodes?.length) return []
return nodes.filter((n) => !n.forceHide)
}
/** 第三层区块标题:取当前选中的第一层节点的 sectionTitle 或 label */
const selectedLayer0SectionTitle = computed(() => {
const root = filterVisible(categoryTree.value)
const id = layerActiveValues.value[0]
const node = id ? root.find((n) => n.id === id) : root[0]
return node?.sectionTitle ?? node?.label ?? ''
})
/** 当前展示的层级数据:[[layer0], [layer1]?, [layer2]?] */
const categoryLayers = computed(() => {
const root = filterVisible(categoryTree.value)
if (root.length === 0) return []
const layers: CategoryTreeNode[][] = [root]
const active = layerActiveValues.value
let currentNodes = root
for (let i = 0; i < 2; i++) {
const selectedId = active[i]
const node = selectedId ? currentNodes.find((n) => n.id === selectedId) : currentNodes[0]
const children = filterVisible(node?.children)
if (children.length === 0) break
layers.push(children)
currentNodes = children
}
return layers
})
/** 分类选中时:若有 children 则展开下一层并默认选中第一个 */
function onCategorySelect(layerIndex: number, selectedId: string) {
const nextValues = [...layerActiveValues.value]
nextValues[layerIndex] = selectedId
nextValues.length = layerIndex + 1
const layers = categoryLayers.value
const layer = layers[layerIndex]
const node = layer?.find((n) => n.id === selectedId)
const children = filterVisible(node?.children)
const firstChild = children[0]
if (firstChild && layerIndex < 2) {
nextValues.push(firstChild.id)
}
layerActiveValues.value = nextValues
}
/** 初始化分类选中:默认选中第一个,若有 children 则递归展开 */
function initCategorySelection() {
const root = filterVisible(categoryTree.value)
if (root.length === 0) return
const values: string[] = []
let current = root
for (let i = 0; i < 3; i++) {
const first = current[0]
if (!first) break
values.push(first.id)
const children = filterVisible(first.children)
if (children.length === 0) break
current = children
}
layerActiveValues.value = values
}
const PAGE_SIZE = 10 const PAGE_SIZE = 10
@ -306,6 +428,28 @@ function checkScrollLoad() {
} }
onMounted(() => { onMounted(() => {
/** 开发时设为 true 可始终使用模拟数据查看一二三层 UI */
const USE_MOCK_CATEGORY = true
if (USE_MOCK_CATEGORY) {
categoryTree.value = MOCK_CATEGORY_TREE
initCategorySelection()
} else {
getCategoryTree()
.then((res) => {
if (res.code === 0 || res.code === 200) {
const data = res.data
categoryTree.value = Array.isArray(data) && data.length > 0 ? data : MOCK_CATEGORY_TREE
} else {
categoryTree.value = MOCK_CATEGORY_TREE
}
initCategorySelection()
})
.catch(() => {
categoryTree.value = MOCK_CATEGORY_TREE
initCategorySelection()
})
}
const cached = getEventListCache() const cached = getEventListCache()
if (cached && cached.list.length > 0) { if (cached && cached.list.length > 0) {
eventList.value = cached.list eventList.value = cached.list
@ -356,6 +500,7 @@ onUnmounted(() => {
max-width: 2560px; max-width: 2560px;
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
padding-top: 20px;
} }
.home-header { .home-header {
@ -467,18 +612,136 @@ onUnmounted(() => {
margin-top: 40px; margin-top: 40px;
} }
.home-tab-bar { /* 分类置顶、全宽(突破父级 padding左右无留白 */
position: fixed; .home-category-wrap {
top: 64px; /* Adjust based on app bar height */ width: 100vw;
left: 0; max-width: 100vw;
transform: none; margin-left: calc(50% - 50vw);
width: 100%; margin-top: 0;
margin-bottom: 0;
padding-left: 0;
padding-right: 0;
position: sticky;
top: 64px;
z-index: 10; z-index: 10;
background-color: white; background-color: white;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
} }
.home-category-layer {
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
}
.home-category-layer:last-child {
border-bottom: none;
}
.home-category-layer--text :deep(.v-tabs) {
min-height: 48px;
}
.home-category-layer--icon {
padding: 12px 16px;
}
.home-category-icon-row {
display: flex;
flex-wrap: nowrap;
gap: 8px;
overflow-x: auto;
padding-bottom: 4px;
scrollbar-width: none;
}
.home-category-icon-row::-webkit-scrollbar {
display: none;
}
.home-category-icon-item {
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
min-width: 56px;
padding: 8px 12px;
border: none;
border-radius: 8px;
background: transparent;
color: #64748b;
font-size: 12px;
cursor: pointer;
transition: background-color 0.2s, color 0.2s;
}
.home-category-icon-item:hover {
background-color: rgba(0, 0, 0, 0.04);
color: #334155;
}
.home-category-icon-item--active {
background-color: rgb(var(--v-theme-primary));
color: rgb(var(--v-theme-on-primary));
}
.home-category-icon {
flex-shrink: 0;
}
.home-category-icon-placeholder {
display: block;
width: 24px;
height: 24px;
flex-shrink: 0;
}
.home-category-icon-label {
white-space: nowrap;
}
.home-category-layer--third {
padding: 12px 16px 0;
}
.home-category-third-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.home-category-third-title {
font-size: 16px;
font-weight: 600;
margin: 0;
color: #1e293b;
}
.home-category-third-actions {
display: flex;
gap: 4px;
}
.home-category-action-btn {
min-width: 36px;
}
.home-tab-bar {
position: relative;
top: auto;
left: auto;
transform: none;
width: 100%;
background-color: transparent;
margin-bottom: 0;
box-shadow: none;
}
.home-tab-bar--compact :deep(.v-tab) {
min-height: 40px;
font-size: 14px;
}
.home-card { .home-card {
border-radius: 12px; border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);