Files
inkreach-uni/pages/products/products.vue
T
RyRh 69734f36b4
Test Runner / hello (push) Failing after 32s
修复商品列表滚动错误
2026-08-25 14:25:43 +08:00

545 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<!-- 产品列表页 - 印美达 Inkreach -->
<view class="products-page" :style="{ height: windowHeight + 'px' }">
<!-- 搜索栏 -->
<SearchBar
:keyword="searchKeyword"
@input="onSearchInput"
@blur="onSearchBlur"
@filter="toggleFilter"
/>
<!-- 国家选择条 -->
<CountryBar
:list="countries"
:selected-id="selectedCountry"
@select="selectCountry"
/>
<!-- 主体内容左侧分类侧边栏(固定宽度) + 右侧内容区(自动剩余宽度) -->
<view class="content-body">
<!-- 左侧分类侧边栏 -->
<CategorySidebar
:list="categories"
:active-id="activeCategory"
@select="selectCategory"
/>
<!-- 右侧内容区:上方筛选条件 + 下方商品滚动区 -->
<view class="right-wrap">
<!-- 已选筛选标签(只在右侧顶部出现,不挤压左边) -->
<ActiveFilters
:list="activeFilters"
@remove="removeFilter"
@clear-all="clearAllFilters"
/>
<!-- 产品列表(内含子分类条件+滚动商品) -->
<ProductGrid
:list="products"
:sub-categories="subCategories"
:active-sub-id="activeSubCategory"
:has-more="hasMore"
:loading="loadingMore"
:scroll-top-val="scrollTopVal"
:scroll-height="productScrollHeight"
@sub-select="selectSubCategory"
@product-click="goDetail"
@scroll-to-lower="onScrollToLower"
/>
</view>
</view>
<!-- 筛选弹窗 -->
<FilterDrawer
:visible="showFilter"
:groups="filterGroups"
@close="closeFilter"
@toggle-tag="toggleFilterTag"
@reset="resetFilters"
@confirm="confirmFilters"
/>
</view>
</template>
<script>
import SearchBar from './components/SearchBar.vue'
import CountryBar from './components/CountryBar.vue'
import ActiveFilters from './components/ActiveFilters.vue'
import CategorySidebar from './components/CategorySidebar.vue'
import ProductGrid from './components/ProductGrid.vue'
import FilterDrawer from './components/FilterDrawer.vue'
import { getCountries, getCategories, getTagGroups, getGoods } from '@/api/public.js'
import { buildUrl } from '@/api/request.js'
import { truncateProductTitle } from '@/utils/product.js'
export default {
components: {
SearchBar,
CountryBar,
ActiveFilters,
CategorySidebar,
ProductGrid,
FilterDrawer
},
data() {
return {
searchKeyword: '',
selectedCountry: '',
countries: [],
activeCategory: 'all',
categories: [
{
id: 'all',
name: '全部商品'
}
],
subCategories: [],
activeSubCategory: '',
activeFilters: [],
showFilter: false,
filterGroups: [],
pagination: {
total: 0,
current: 1,
pageSize: 10
},
hasMore: true,
loadingMore: false,
products: [],
windowHeight: 667,
scrollTopVal: 0,
productScrollHeight: 400 // 商品滚动区高度(px),动态计算
}
},
async onLoad() {
try {
const sysInfo = uni.getSystemInfoSync()
// windowHeight 已自动扣除 uni-app 默认导航栏 & TabBar 高度
// 当前页是 navigationStyle:custom + TabBar 页,正好匹配可视区
this.windowHeight = sysInfo.windowHeight || 667
} catch (e) {
this.windowHeight = 667
}
await this.initData()
this.loadSubCategories('all')
await this.loadGoods(1)
// 首屏数据渲染完成后计算滚动区高度
this.$nextTick(() => {
this.calcScrollHeight()
})
},
methods: {
async initData() {
try {
// 1. 国家列表
const countriesRes = await getCountries()
const countriesData = (countriesRes.data && countriesRes.data.data) || []
const countries = countriesData.map((c) => ({
id: c.id,
name: c.countryName,
icon: buildUrl(c.countryIcon || '')
}))
this.countries = countries
// 2. 默认不选中任何国家
await this.loadCategoryData('')
console.log('=== [Products API Test] ===')
console.log('[1] countries:', countries.length, '条')
console.log('=== [Test End] ===')
} catch (err) {
console.error('[Products initData Error]', err)
}
},
/**
* 加载分类树和标签组(按国家过滤)
*/
async loadCategoryData(countryId) {
try {
const params = countryId ? { countryId: countryId } : {}
const [categoriesRes, tagGroupsRes] = await Promise.all([
getCategories(params),
getTagGroups(params)
])
// 分类树
const categoriesData = (categoriesRes.data && categoriesRes.data.data) || []
const categories = [{ id: 'all', name: '全部商品', children: [], productCount: 0 }]
categoriesData.forEach((cat) => {
categories.push({
id: cat.id,
name: cat.categoryName,
children: cat.children || [],
productCount: cat.productCount || 0
})
})
// 标签组
const tagGroupsData = (tagGroupsRes.data && tagGroupsRes.data.data) || []
const filterGroups = tagGroupsData.map((g) => ({
id: g.id,
title: g.groupName,
tags: (g.tags || []).map((t) => ({
id: t.id,
name: t.tagName,
color: t.tagColor,
fontColor: t.tagFontColor,
productCount: t.productCount || 0
})),
selected: []
}))
this.categories = categories
this.filterGroups = filterGroups
this.activeCategory = 'all'
this.subCategories = []
this.activeSubCategory = ''
console.log(
'[loadCategoryData] countryId=' + (countryId || 'all') +
', categories=' + categories.length +
', filterGroups=' + filterGroups.length
)
} catch (err) {
console.error('[loadCategoryData Error]', err)
}
},
/**
* 统一加载商品列表(分页 + 全部筛选条件)
* 所有触发点(搜索失焦 / 分类点击 / 国家切换 / 标签点击 / 确认筛选 / 分页)都走这里
*/
async loadGoods(page) {
try {
const currentPage = page || this.pagination.current || 1
const pageSize = this.pagination.pageSize || 10
// 统一参数对象:由 buildQueryParams 统一拼装
const params = this.buildQueryParams(currentPage)
console.log('[loadGoods] params:', JSON.stringify(params))
const res = await getGoods(params)
const data = (res.data && res.data.data) || { items: [], total: 0, page: 1, pageSize: 10 }
const items = data.items || []
const products = items.map((item) => ({
id: item.goodId,
name: truncateProductTitle(item.goodName),
price: item.price,
image: item.image,
tags: (item.tags || []).map(function (t) {
return {
text: t.tagName,
bg: t.tagColor,
color: t.tagFontColor
}
})
}))
const total = data.total || 0
if (currentPage === 1) {
// 整表替换 = 筛选/分类/国家/搜索/初始化 等切换 → 滚动条回顶部
this.products = products
this.resetScrollToTop()
} else {
// 分页追加(触底加载)→ 保持滚动位置,不回到顶部
this.products = this.products.concat(products)
}
this.pagination = {
total: total,
current: currentPage,
pageSize: pageSize
}
this.hasMore = this.products.length < total
this.loadingMore = false
// 数据变更后重算滚动区高度(筛选标签/子分类数量变化会影响上方占位高度)
this.$nextTick(() => {
this.calcScrollHeight()
})
console.log(
'[loadGoods] total=' + total +
', page=' + currentPage +
', items=' + products.length +
', loaded=' + this.products.length +
', hasMore=' + this.hasMore
)
} catch (err) {
console.error('[loadGoods Error]', err)
this.products = []
this.loadingMore = false
}
},
/**
* 构造统一的查询参数对象
* - countryId / categoryId / keyword / page / pageSize 直传后端
* - categoryId:子分类优先,其次顶级分类
* - tags:对象数组 [{ tagGroupId, tagIds }],同组 OR、跨组 AND(后端处理)
*/
buildQueryParams(page) {
const params = {
page: page || 1,
pageSize: this.pagination.pageSize || 10
}
if (this.selectedCountry) {
params.countryId = this.selectedCountry
}
const catId = this.activeSubCategory || (this.activeCategory !== 'all' ? this.activeCategory : '')
if (catId) {
params.categoryId = catId
}
const kw = (this.searchKeyword || '').trim()
if (kw) {
params.keyword = kw
}
const tagsList = (this.filterGroups || [])
.filter((g) => g.selected && g.selected.length > 0)
.map((g) => ({
tagGroupId: String(g.id),
tagIds: (g.selected || []).map((id) => String(id))
}))
if (tagsList.length > 0) {
params.tags = JSON.stringify(tagsList)
}
return params
},
codeToEmoji(iconPath) {
if (!iconPath) return '🌐'
var match = iconPath.match(/\/([^/]+)\.\w+$/)
if (!match) return '🌐'
var code = match[1].toUpperCase()
if (code.length !== 2) return '🌐'
var A = 0x1f1e6
return String.fromCodePoint(A + code.charCodeAt(0) - 65) + String.fromCodePoint(A + code.charCodeAt(1) - 65)
},
loadSubCategories(catId) {
var subCats = []
if (catId !== 'all') {
var cat = this.categories.find(function (c) { return c.id === catId })
if (cat && cat.children && cat.children.length > 0) {
subCats = cat.children.map(function (child) {
return { id: child.id, name: child.categoryName }
})
}
}
this.subCategories = subCats
this.activeSubCategory = ''
},
onSearchInput(val) {
// 仅更新关键词状态,搜索在失焦时触发
this.searchKeyword = val
},
onSearchBlur() {
this.loadGoods(1)
},
onScrollToLower() {
if (this.loadingMore || !this.hasMore) return
this.loadingMore = true
this.loadGoods(this.pagination.current + 1)
},
/**
* 把商品列表滚动条回到顶部。
* uni-app scroll-view 的 :scroll-top 仅在值变化时触发滚动,
* 所以先写到一个大值(必定变化),再 nextTick 写 0,保证每次都能回到顶部。
*/
resetScrollToTop() {
this.scrollTopVal = 99999
this.$nextTick(() => {
this.scrollTopVal = 0
})
},
/**
* 动态计算商品滚动区高度(px)。
* 用 createSelectorQuery 测量 SearchBar / CountryBar / ActiveFilters / subCategories 的实际高度,
* 用 windowHeight 依次减去,得到 scroll-view 可用的固定高度。
* —— 完全符合 uni-app / 微信小程序官方文档「scroll-y 需给固定 height」的要求。
*/
calcScrollHeight() {
const query = uni.createSelectorQuery().in(this)
// 分别测量 4 个元素的 boundingClientRect
query.select('#searchBar').boundingClientRect()
query.select('#countryBar').boundingClientRect()
query.select('#activeFilters').boundingClientRect()
query.select('#subCategories').boundingClientRect()
query.exec((res) => {
// res[0]=searchBar, res[1]=countryBar, res[2]=activeFilters, res[3]=subCategories
// 注意:v-if 为 false 时 res[i] 可能为 null
const searchBarH = res[0] ? res[0].height : 0
const countryBarH = res[1] ? res[1].height : 0
const activeFiltersH = res[2] ? res[2].height : 0
const subCategoriesH = res[3] ? res[3].height : 0
const totalUsed = searchBarH + countryBarH + activeFiltersH + subCategoriesH
let h = this.windowHeight - totalUsed
// 最小值保护:至少 200px,避免极端情况
if (h < 200) h = 200
if (h > this.windowHeight) h = this.windowHeight
this.productScrollHeight = Math.round(h)
console.log('[calcScrollHeight] windowHeight=' + this.windowHeight +
', searchBar=' + searchBarH + ', countryBar=' + countryBarH +
', activeFilters=' + activeFiltersH + ', subCategories=' + subCategoriesH +
' → scrollHeight=' + this.productScrollHeight)
})
},
async selectCountry(id) {
// 如果点击的是已选中的国家 → 取消选中
if (this.selectedCountry === id) {
this.selectedCountry = ''
this.activeFilters = this.activeFilters.filter((f) => f.key !== 'country')
await this.loadCategoryData('')
await this.loadGoods(1)
return
}
// 选中新国家
const country = this.countries.find((c) => c.id === id)
const filters = this.activeFilters.filter((f) => f.key !== 'country')
if (country) {
filters.push({
key: 'country',
label: country.name
})
}
this.selectedCountry = id
this.activeFilters = filters
await this.loadCategoryData(id)
await this.loadGoods(1)
},
async selectCategory(id) {
this.activeCategory = id
this.loadSubCategories(id)
await this.loadGoods(1)
},
async selectSubCategory(id) {
this.activeSubCategory = id
await this.loadGoods(1)
},
toggleFilter() {
this.showFilter = !this.showFilter
},
closeFilter() {
this.showFilter = false
},
toggleFilterTag(e) {
const gIdx = e.groupIndex
const tagId = e.tagId
var groups = [...this.filterGroups]
var group = groups[gIdx]
if (!group) return
var selected = [...group.selected]
var idx = selected.indexOf(tagId)
if (idx > -1) {
selected.splice(idx, 1)
} else {
selected.push(tagId)
}
group.selected = selected
this.filterGroups = groups
},
async removeFilter(key) {
const filters = this.activeFilters.filter((f) => f.key !== key)
if (key === 'country') {
this.selectedCountry = ''
this.activeFilters = filters
await this.loadCategoryData('')
await this.loadGoods(1)
return
}
if (key.indexOf('tag_') === 0) {
var groupId = key.replace('tag_', '')
var groups = this.filterGroups.map(function (g) {
if (String(g.id) === String(groupId)) {
return Object.assign({}, g, { selected: [] })
}
return g
})
this.activeFilters = filters
this.filterGroups = groups
await this.loadGoods(1)
return
}
this.activeFilters = filters
},
async clearAllFilters() {
const hasCountry = this.activeFilters.some((f) => f.key === 'country')
this.activeFilters = []
if (hasCountry || this.selectedCountry) {
this.selectedCountry = ''
await this.loadCategoryData('')
}
var groups = this.filterGroups.map(function (g) {
return Object.assign({}, g, { selected: [] })
})
this.filterGroups = groups
await this.loadGoods(1)
},
resetFilters() {
var groups = this.filterGroups.map(function (g) {
return Object.assign({}, g, { selected: [] })
})
this.filterGroups = groups
},
async confirmFilters() {
var filters = []
var countryFilter = this.activeFilters.find(function (f) { return f.key === 'country' })
if (countryFilter) filters.push(countryFilter)
this.filterGroups.forEach(function (group) {
if (group.selected.length > 0) {
var labels = group.tags
.filter(function (t) { return group.selected.indexOf(t.id) > -1 })
.map(function (t) { return t.name })
if (labels.length > 0) {
filters.push({
key: 'tag_' + group.id,
label: labels.join(',')
})
}
}
})
this.activeFilters = filters
this.showFilter = false
await this.loadGoods(1)
},
goDetail(item) {
uni.navigateTo({
url: `/pages/product-detail/product-detail?id=${item.id}`
})
}
}
}
</script>
<style>
@import './products.css';
</style>