Files
inkreach-uni/pages/products/products.vue
T
2026-08-31 18:11:01 +08:00

587 lines
18 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"
@search="onSearchBlur"
@height-change="onSearchBarHeight"
/>
<!-- 国家选择条 -->
<CountryBar
:list="countries"
:selected-id="selectedCountry"
@select="selectCountry"
@height-change="onCountryBarHeight"
/>
<!-- 主体内容左侧分类侧边栏(固定宽度) + 右侧内容区(自动剩余宽度) -->
<view class="content-body">
<!-- 左侧分类侧边栏 -->
<CategorySidebar
:list="categories"
:active-id="activeCategory"
@select="selectCategory"
/>
<!-- 右侧内容区:上方筛选条件 + 下方商品滚动区 -->
<view class="right-wrap">
<!-- 筛选条件条筛选图标 + 已选标签横向滚动 + 清除同一行 -->
<ActiveFilters
:list="activeFilters"
@open="toggleFilter"
@remove="removeFilter"
@clear-all="clearAllFilters"
@height-change="onActiveFiltersHeight"
/>
<!-- 产品列表(内含子分类条件+滚动商品) -->
<ProductGrid
:list="products"
:sub-categories="subCategories"
:active-sub-id="activeSubCategory"
:has-more="hasMore"
:loading="loadingMore"
:scroll-top-val="scrollTopVal"
:scroll-height="productScrollHeight"
:show-country-tag="!selectedCountry"
@sub-select="selectSubCategory"
@product-click="goDetail"
@scroll-to-lower="onScrollToLower"
@sub-height-change="onSubCategoriesHeight"
/>
</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
},
watch: {
// 筛选标签增减 → 子组件会自测量并 emit,父组件这里不需要 watch
// 保留一个兜底:activeFilters.length 变化时主动触发一次重算
'activeFilters.length'() {
this.recomputeScrollHeight()
},
'subCategories.length'() {
this.recomputeScrollHeight()
}
},
data() {
return {
searchKeyword: '',
selectedCountry: '',
countries: [],
activeCategory: 'all',
categories: [
{
id: 'all',
name: '全部商品'
}
],
subCategories: [],
activeSubCategory: '',
activeFilters: [],
showFilter: false,
filterGroups: [],
minPrice: '',
maxPrice: '',
pagination: {
total: 0,
current: 1,
pageSize: 10
},
hasMore: true,
loadingMore: false,
products: [],
windowHeight: 667,
scrollTopVal: 0,
productScrollHeight: 400, // 商品滚动区高度(px),由子组件测量后动态计算
// 各子组件上报的高度(px),默认 0
_searchBarH: 0,
_countryBarH: 0,
_activeFiltersH: 0,
_subCategoriesH: 0
}
},
async onLoad() {
try {
const sysInfo = uni.getSystemInfoSync()
// tabBar 页 + navigationStyle:custom 时,windowHeight 在不同平台/版本可能不扣 tabBar
// 改用 screenHeight - tabBarHeight 计算,确保可视区高度正确
const tabBarHeight = sysInfo.tabBarHeight || 50
this.windowHeight = (sysInfo.screenHeight || 750) - tabBarHeight
console.log('[onLoad] screenHeight=' + sysInfo.screenHeight +
', tabBarHeight=' + tabBarHeight +
' → windowHeight=' + this.windowHeight)
} catch (e) {
this.windowHeight = 667
}
// 首屏商品请求与基础数据并行发出(goods 无筛选时不依赖国家/分类数据),
// 避免串行等待拖慢首屏(goods 是最慢的接口,应最先发出)
const goodsPromise = this.loadGoods(1)
await this.initData()
this.loadSubCategories('all')
await goodsPromise
// 首屏数据渲染完成后,子组件会通过 mounted 自行测量并 emit 高度
// 这里兜底触发一次重算(防止某些端 mounted 比父组件 onLoad 晚)
this.recomputeScrollHeight()
},
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,
countryName: (item.country && item.country.countryName) || '',
countryIcon: (item.country && item.country.countryIcon) ? buildUrl(item.country.countryIcon) : ''
}))
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
// 数据变更后兜底重算滚动区高度(子组件会自行 emit,这里兜底)
this.recomputeScrollHeight()
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
}
// 价格区间:两个可单独传,也可同时传
if (this.minPrice !== '' && this.minPrice !== null && this.minPrice !== undefined) {
params.minPrice = this.minPrice
}
if (this.maxPrice !== '' && this.maxPrice !== null && this.maxPrice !== undefined) {
params.maxPrice = this.maxPrice
}
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
})
},
/**
* 子组件高度上报回调 —— 每个子组件在 mounted / 内容变化时自行测量 DOM 高度,
* 通过 @height-change 事件上报给父组件。父组件汇总后重算滚动区高度。
*
* 这种方式解决了 uni-app 小程序端 createSelectorQuery 无法跨组件查询 DOM 的问题:
* H5 端 DOM 全局可查,但小程序每个组件有独立节点树,页面级 select 选不到子组件内部的节点。
*/
onSearchBarHeight(h) {
this._searchBarH = h
this.recomputeScrollHeight()
},
onCountryBarHeight(h) {
this._countryBarH = h
this.recomputeScrollHeight()
},
onActiveFiltersHeight(h) {
this._activeFiltersH = h
this.recomputeScrollHeight()
},
onSubCategoriesHeight(h) {
this._subCategoriesH = h
this.recomputeScrollHeight()
},
/**
* 根据 4 个子组件上报的高度,计算 scroll-view 可用的固定高度。
* —— 完全符合 uni-app / 微信小程序官方文档「scroll-y 需给固定 height」的要求。
*/
recomputeScrollHeight() {
const totalUsed = this._searchBarH + this._countryBarH +
this._activeFiltersH + this._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('[recomputeScrollHeight] windowHeight=' + this.windowHeight +
', searchBar=' + this._searchBarH + ', countryBar=' + this._countryBarH +
', activeFilters=' + this._activeFiltersH + ', subCategories=' + this._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 = []
this.minPrice = ''
this.maxPrice = ''
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(payload) {
payload = payload || {}
this.minPrice = payload.minPrice || ''
this.maxPrice = payload.maxPrice || ''
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>