feat: 分类页支持后台配置的四种导航模板

This commit is contained in:
pikachu1995@126.com
2026-10-22 09:27:33 +08:00
parent b8a60a9369
commit b589c551bf
6 changed files with 556 additions and 1 deletions

View File

@@ -0,0 +1,93 @@
<template>
<scroll-view scroll-x scroll-with-animation class="cate-scroller" :scroll-into-view="intoView">
<view
v-for="item in list"
:key="item.id"
:id="'cs-' + item.id"
class="cs-item"
:class="{ active: item.id === currentId, 'cs-item--text': mode === 'text' }"
@click="$emit('change', item)"
>
<template v-if="mode === 'icon'">
<view class="cs-icon">
<u-image width="88rpx" height="88rpx" shape="circle" :src="item.image">
<template #loading><u-loading-icon></u-loading-icon></template>
</u-image>
</view>
<text class="cs-name">{{ item.name }}</text>
</template>
<text v-else class="cs-text-name">{{ item.name }}</text>
</view>
</scroll-view>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(defineProps<{
list?: any[]
currentId?: string | number | null
mode?: 'icon' | 'text'
}>(), {
list: () => [],
currentId: null,
mode: 'icon'
})
defineEmits(['change'])
const intoView = computed(() =>
props.currentId != null ? `cs-${props.currentId}` : ''
)
</script>
<style lang="scss" scoped>
.cate-scroller {
white-space: nowrap;
width: 100%;
background: #fff;
}
.cs-item {
display: inline-flex;
flex-direction: column;
align-items: center;
width: 120rpx;
padding: 16rpx 0 10rpx;
vertical-align: top;
}
.cs-icon {
width: 88rpx;
height: 88rpx;
border-radius: 50%;
overflow: hidden;
border: 2rpx solid transparent;
}
.cs-item.active .cs-icon {
border-color: $light-color;
}
.cs-name {
margin-top: 8rpx;
font-size: 22rpx;
color: #333;
max-width: 110rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cs-item.active .cs-name {
color: $light-color;
font-weight: bold;
}
.cs-item--text {
width: auto;
padding: 18rpx 24rpx;
}
.cs-text-name {
font-size: 26rpx;
color: #333;
}
.cs-item--text.active .cs-text-name {
color: $light-color;
font-weight: bold;
}
</style>

View File

@@ -0,0 +1,51 @@
<template>
<scroll-view scroll-y scroll-with-animation class="side-list">
<view
v-for="item in list"
:key="item.id"
class="side-item"
:class="{ active: item.id === currentId }"
@click="$emit('change', item)"
>
{{ item.name }}
</view>
</scroll-view>
</template>
<script setup lang="ts">
withDefaults(defineProps<{
list?: any[]
currentId?: string | number | null
}>(), {
list: () => [],
currentId: null
})
defineEmits(['change'])
</script>
<style lang="scss" scoped>
.side-list {
width: 160rpx;
flex-shrink: 0;
height: 100%;
background-color: #f7f7f7;
}
.side-item {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
min-height: 90rpx;
padding: 10rpx 8rpx;
box-sizing: border-box;
font-size: 24rpx;
color: #333;
text-align: center;
&.active {
font-weight: bold;
color: $light-color;
background: #fff;
}
}
</style>

View File

@@ -0,0 +1,83 @@
<template>
<view class="sort-bar">
<view
class="sort-item"
:class="{ active: sort === 'releaseTime' }"
@click="onClick('releaseTime')"
>
综合
</view>
<view
class="sort-item"
:class="{ active: sort === 'buyCount' }"
@click="onClick('buyCount')"
>
销量
</view>
<view
class="sort-item"
:class="{ active: sort === 'price' }"
@click="onClick('price')"
>
<text>价格</text>
<view class="arrows">
<u-icon
name="arrow-up"
size="10"
:color="sort === 'price' && order === 'asc' ? '#ff6700' : '#c5c5c5'"
></u-icon>
<u-icon
name="arrow-down"
size="10"
:color="sort === 'price' && order === 'desc' ? '#ff6700' : '#c5c5c5'"
></u-icon>
</view>
</view>
</view>
</template>
<script setup lang="ts">
const props = withDefaults(defineProps<{
sort?: string
order?: string
}>(), {
sort: 'releaseTime',
order: 'desc'
})
const emit = defineEmits(['change'])
function onClick(type: string) {
let nextOrder = 'desc'
if (type === 'price') {
nextOrder = props.sort === 'price' && props.order === 'desc' ? 'asc' : 'desc'
}
emit('change', { sort: type, order: nextOrder })
}
</script>
<style lang="scss" scoped>
.sort-bar {
display: flex;
align-items: center;
height: 72rpx;
background: #fff;
padding: 0 20rpx;
}
.sort-item {
display: flex;
align-items: center;
margin-right: 40rpx;
font-size: 26rpx;
color: #333;
&.active {
color: $light-color;
font-weight: bold;
}
}
.arrows {
display: flex;
flex-direction: column;
margin-left: 6rpx;
}
</style>

View File

@@ -0,0 +1,55 @@
<template>
<scroll-view scroll-x scroll-with-animation class="tag-bar" :scroll-into-view="intoView">
<view
v-for="item in list"
:key="item.id"
:id="'tb-' + item.id"
class="tag-item"
:class="{ active: item.id === currentId }"
@click="$emit('change', item)"
>
{{ item.name }}
</view>
</scroll-view>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(defineProps<{
list?: any[]
currentId?: string | number | null
}>(), {
list: () => [],
currentId: null
})
defineEmits(['change'])
const intoView = computed(() =>
props.currentId != null ? `tb-${props.currentId}` : ''
)
</script>
<style lang="scss" scoped>
.tag-bar {
white-space: nowrap;
width: 100%;
background: #fff;
padding: 8rpx 0;
}
.tag-item {
display: inline-block;
padding: 8rpx 24rpx;
margin-right: 12rpx;
font-size: 24rpx;
color: #333;
background: #f5f5f5;
border-radius: 24rpx;
&.active {
color: #fff;
background: $light-color;
font-weight: bold;
}
}
</style>

View File

@@ -0,0 +1,260 @@
<template>
<view class="tpl-goods">
<!-- 模板2/3:顶部一级分类图标横滑 -->
<category-scroller
v-if="template !== 4"
:list="categoryList"
:current-id="currentL1 && currentL1.id"
mode="icon"
@change="onL1Change"
/>
<!-- 模板2:三级分类标签横滑 -->
<sub-tag-bar
v-if="template === 2 && l3List.length"
:list="l3List"
:current-id="currentL3 && currentL3.id"
@change="onL3Change"
/>
<view class="tpl-main">
<!-- 左侧:模板2/3 为二级分类,模板4 为一级分类 -->
<category-side-list
:list="sideList"
:current-id="sideCurrentId"
@change="onSideChange"
/>
<view class="tpl-right">
<!-- 模板4:右上二级分类文字横滑(起点为当前一级名称,作为"全部"入口) -->
<category-scroller
v-if="template === 4"
:list="l2Items"
:current-id="topCurrentId"
mode="text"
@change="onL2Change"
/>
<sort-bar :sort="sort" :order="order" @change="onSortChange" />
<scroll-view scroll-y class="goods-scroll" @scrolltolower="loadMore">
<m-goods-list
v-if="goodsList.length"
:res="goodsList"
type="oneColumns"
:show-fields="setting.showFields"
:tab-bar-gap="false"
@add-cart="onAddCart"
/>
<view v-else-if="!loading" class="empty">
<text>{{ failed ? '加载失败,请点击重试' : '暂无商品' }}</text>
<view v-if="failed" class="retry" @click="loadGoods(true)">重试</view>
</view>
<uni-load-more
v-if="goodsList.length"
:status="loading ? 'loading' : hasMore ? 'more' : 'noMore'"
></uni-load-more>
</scroll-view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { getGoodsList } from '@/api/goods.js'
import CategoryScroller from './category-scroller.vue'
import CategorySideList from './category-side-list.vue'
import SubTagBar from './sub-tag-bar.vue'
import SortBar from './sort-bar.vue'
import MGoodsList from '@/components/m-goods-list/list.vue'
const props = withDefaults(defineProps<{
categoryList?: any[]
setting?: any
template?: number
}>(), {
categoryList: () => [],
setting: () => ({ template: 1, showFields: {} }),
template: 2
})
const currentL1 = ref<any>(null)
const currentL2 = ref<any>(null)
const currentL3 = ref<any>(null)
const sort = ref('releaseTime')
const order = ref('desc')
const goodsList = ref<any[]>([])
const pageNumber = ref(1)
const hasMore = ref(true)
const loading = ref(false)
const failed = ref(false)
// 模板2/3:左侧为当前一级的二级分类;模板4:左侧为一级分类
const sideList = computed(() =>
props.template === 4
? props.categoryList
: (currentL1.value && currentL1.value.children) || []
)
const sideCurrentId = computed(() =>
props.template === 4
? currentL1.value && currentL1.value.id
: currentL2.value && currentL2.value.id
)
// 模板2 的三级标签
const l3List = computed(() =>
(currentL2.value && currentL2.value.children) || []
)
// 模板4 顶部:当前一级名称(作为"全部") + 其二级分类
const l2Items = computed(() => {
if (!currentL1.value) return []
return [currentL1.value, ...((currentL1.value.children) || [])]
})
const topCurrentId = computed(() =>
(currentL2.value && currentL2.value.id) || (currentL1.value && currentL1.value.id)
)
const queryCategoryId = computed(() =>
(currentL3.value && currentL3.value.id) ||
(currentL2.value && currentL2.value.id) ||
(currentL1.value && currentL1.value.id)
)
watch(
() => props.categoryList,
(list) => {
if (list && list.length && !currentL1.value) {
currentL1.value = list[0]
currentL2.value = props.template === 4 ? null : (list[0].children && list[0].children[0]) || null
currentL3.value = (currentL2.value && currentL2.value.children && currentL2.value.children[0]) || null
loadGoods(true)
}
},
{ immediate: true }
)
function onL1Change(item: any) {
if (currentL1.value && item.id === currentL1.value.id) return
currentL1.value = item
currentL2.value = props.template === 4 ? null : (item.children && item.children[0]) || null
currentL3.value = (currentL2.value && currentL2.value.children && currentL2.value.children[0]) || null
resetSort()
loadGoods(true)
}
function onSideChange(item: any) {
if (props.template === 4) {
onL1Change(item)
return
}
if (currentL2.value && item.id === currentL2.value.id) return
currentL2.value = item
currentL3.value = (item.children && item.children[0]) || null
loadGoods(true)
}
function onL3Change(item: any) {
if (currentL3.value && item.id === currentL3.value.id) return
currentL3.value = item
loadGoods(true)
}
function onL2Change(item: any) {
// 选中一级名称项 = 全部(L2 置空)
if (currentL1.value && item.id === currentL1.value.id) {
currentL2.value = null
} else {
currentL2.value = item
}
loadGoods(true)
}
function onSortChange(payload: { sort: string; order: string }) {
sort.value = payload.sort
order.value = payload.order
loadGoods(true)
}
function resetSort() {
sort.value = 'releaseTime'
order.value = 'desc'
}
async function loadGoods(reset = false) {
if (loading.value || !queryCategoryId.value) return
if (reset) {
pageNumber.value = 1
goodsList.value = []
hasMore.value = true
}
loading.value = true
failed.value = false
try {
const res = await getGoodsList({
categoryId: queryCategoryId.value,
sort: sort.value,
order: order.value,
pageNumber: pageNumber.value,
pageSize: 10,
})
const records = (res.data && res.data.result && res.data.result.records) || []
goodsList.value.push(...records)
hasMore.value = records.length >= 10
} catch (e) {
failed.value = true
uni.showToast({ title: '加载失败,请重试', icon: 'none' })
} finally {
loading.value = false
}
}
function loadMore() {
if (!hasMore.value || loading.value) return
pageNumber.value += 1
loadGoods(false)
}
function onAddCart(item: any) {
uni.navigateTo({
url: `/pages/product/goods?id=${item.id}&goodsId=${item.goodsId}`,
})
}
</script>
<style lang="scss" scoped>
.tpl-goods {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
background: #fff;
}
.tpl-main {
flex: 1;
min-height: 0;
display: flex;
}
.tpl-right {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.goods-scroll {
flex: 1;
min-height: 0;
height: 0;
}
.empty {
padding: 120rpx 0;
display: flex;
flex-direction: column;
align-items: center;
color: #999;
font-size: 26rpx;
}
.retry {
margin-top: 20rpx;
padding: 10rpx 48rpx;
border: 1rpx solid $light-color;
color: $light-color;
border-radius: 32rpx;
font-size: 24rpx;
}
</style>