587 lines
18 KiB
Vue
587 lines
18 KiB
Vue
<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>
|