653 lines
24 KiB
Vue
653 lines
24 KiB
Vue
<template>
|
||
<!-- 产品列表页 - 印美达 Inkreach -->
|
||
<view class="products-page">
|
||
<!-- 搜索栏 -->
|
||
<view class="search-bar">
|
||
<view class="search-input-wrap">
|
||
<text class="search-icon">🔍</text>
|
||
<input class="search-input" placeholder="搜索商品" placeholder-class="search-placeholder" @input="onSearchInput" :value="searchKeyword" />
|
||
</view>
|
||
<view class="filter-btn" @tap="toggleFilter">
|
||
<text class="filter-icon">⚙</text>
|
||
<text class="filter-text">筛选</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 国家选择条 -->
|
||
<scroll-view class="country-bar" :scroll-x="true" :enhanced="true" :show-scrollbar="false">
|
||
<view :class="'country-item ' + (selectedCountry === item.id ? 'active' : '')" @tap="selectCountry" :data-id="item.id" v-for="(item, index) in countries" :key="index">
|
||
<text class="country-flag">{{ item.flag }}</text>
|
||
|
||
<text class="country-name">{{ item.name }}</text>
|
||
</view>
|
||
</scroll-view>
|
||
|
||
<!-- 已选筛选标签 -->
|
||
<view class="active-filters" v-if="activeFilters.length > 0">
|
||
<view class="active-filter-tag" v-for="(item, index) in activeFilters" :key="index">
|
||
<text>{{ item.label }}</text>
|
||
|
||
<text class="filter-remove" @tap="removeFilter" :data-key="item.key">✕</text>
|
||
</view>
|
||
<view class="clear-all" @tap="clearAllFilters">清除全部</view>
|
||
</view>
|
||
|
||
<!-- 主体内容:侧边栏 + 产品列表 -->
|
||
<view class="content-body">
|
||
<!-- 左侧分类侧边栏 -->
|
||
<scroll-view class="sidebar" :scroll-y="true">
|
||
<view
|
||
:class="'sidebar-item ' + (activeCategory === item.id ? 'active' : '')"
|
||
@tap="selectCategory"
|
||
:data-id="item.id"
|
||
v-for="(item, index) in categories"
|
||
:key="index"
|
||
>
|
||
<view class="sidebar-indicator" v-if="activeCategory === item.id"></view>
|
||
|
||
<text>{{ item.name }}</text>
|
||
</view>
|
||
<!-- 促销Banner -->
|
||
<view class="sidebar-promo">
|
||
<text class="promo-title">0元拿样</text>
|
||
<text class="promo-desc">全国包邮</text>
|
||
</view>
|
||
</scroll-view>
|
||
|
||
<!-- 右侧产品列表 -->
|
||
<scroll-view class="product-list-area" :scroll-y="true" @scrolltolower="loadMore">
|
||
<!-- 子分类 -->
|
||
<view class="sub-categories" v-if="subCategories.length > 0">
|
||
<view
|
||
:class="'sub-cat-item ' + (activeSubCategory === item.id ? 'active' : '')"
|
||
@tap="selectSubCategory"
|
||
:data-id="item.id"
|
||
v-for="(item, index) in subCategories"
|
||
:key="index"
|
||
>
|
||
{{ item.name }}
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 产品网格 -->
|
||
<view class="product-grid">
|
||
<view class="product-card" @tap="goDetail" :data-id="item.id" v-for="(item, index) in products" :key="index">
|
||
<view class="product-img">
|
||
<image class="product-image" :src="item.image" mode="aspectFill" v-if="item.image"></image>
|
||
<view class="product-badges">
|
||
<view class="product-badge" :style="'background: ' + tag.bg + '; color: ' + tag.color + ';'" v-for="(tag, index1) in item.tags" :key="index1">
|
||
{{ tag.text }}
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="product-info">
|
||
<text class="product-name">{{ item.name }}</text>
|
||
<view class="product-bottom">
|
||
<text class="product-price">
|
||
¥{{ item.price }}
|
||
<text class="product-price-unit">起</text>
|
||
</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 分页 -->
|
||
<view class="pagination">
|
||
<text class="page-info">显示 {{ pagination.start }}-{{ pagination.end }} 共 {{ pagination.total }} 个产品</text>
|
||
<view class="page-controls">
|
||
<view :class="'page-btn ' + (pagination.current === 1 ? 'disabled' : '')" @tap="changePage" data-action="prev">上一页</view>
|
||
<view
|
||
:class="'page-num ' + (pagination.current === item ? 'active' : '')"
|
||
@tap="goToPage"
|
||
:data-page="item"
|
||
v-for="(item, index) in pageNumbers"
|
||
:key="index"
|
||
>
|
||
{{ item }}
|
||
</view>
|
||
<view :class="'page-btn ' + (pagination.current === pagination.totalPages ? 'disabled' : '')" @tap="changePage" data-action="next">下一页</view>
|
||
</view>
|
||
<view class="page-size">
|
||
<text>{{ pagination.pageSize }}/页</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 加载更多提示 -->
|
||
<view class="loading-more" v-if="loadingMore">
|
||
<text>加载中...</text>
|
||
</view>
|
||
</scroll-view>
|
||
</view>
|
||
|
||
<!-- 筛选弹窗 -->
|
||
<view :class="'filter-overlay ' + (showFilter ? 'show' : '')" @tap="closeFilter"></view>
|
||
<view :class="'filter-drawer ' + (showFilter ? 'show' : '')">
|
||
<view class="filter-drawer-header">
|
||
<text class="filter-drawer-title">筛选条件</text>
|
||
<text class="filter-drawer-close" @tap="closeFilter">✕</text>
|
||
</view>
|
||
<scroll-view class="filter-drawer-body" :scroll-y="true">
|
||
<!-- 动态标签筛选组(从 API 获取) -->
|
||
<view class="filter-group" v-for="(group, gIdx) in filterGroups" :key="gIdx">
|
||
<text class="filter-group-title">{{ group.title }}</text>
|
||
<view class="filter-options">
|
||
<view
|
||
:class="'filter-option ' + (group.selected.indexOf(tag.id) > -1 ? 'selected' : '')"
|
||
@tap="toggleFilterTag"
|
||
:data-group-idx="gIdx"
|
||
:data-tag-id="tag.id"
|
||
v-for="(tag, tIdx) in group.tags"
|
||
:key="tIdx"
|
||
>
|
||
{{ tag.name }}
|
||
</view>
|
||
</view>
|
||
</view>
|
||
<!-- 价格区间 -->
|
||
<view class="filter-group">
|
||
<text class="filter-group-title">价格区间</text>
|
||
<view class="filter-price-range">
|
||
<input class="price-input" placeholder="最低价" type="number" />
|
||
<text class="price-divider">-</text>
|
||
<input class="price-input" placeholder="最高价" type="number" />
|
||
</view>
|
||
</view>
|
||
</scroll-view>
|
||
<view class="filter-drawer-footer">
|
||
<view class="filter-reset" @tap="resetFilters">重置</view>
|
||
<view class="filter-confirm" @tap="confirmFilters">确定</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script>
|
||
import { getCountries, getCategories, getTagGroups, getGoods } from '@/api/public.js';
|
||
|
||
export default {
|
||
data() {
|
||
return {
|
||
searchKeyword: '',
|
||
selectedCountry: '',
|
||
|
||
countries: [],
|
||
|
||
activeCategory: 'all',
|
||
|
||
categories: [
|
||
{
|
||
id: 'all',
|
||
name: '全部商品'
|
||
}
|
||
],
|
||
|
||
subCategories: [],
|
||
activeSubCategory: '',
|
||
|
||
activeFilters: [],
|
||
|
||
showFilter: false,
|
||
filterGroups: [],
|
||
|
||
pagination: {
|
||
start: 1,
|
||
end: 10,
|
||
total: 0,
|
||
current: 1,
|
||
pageSize: 10,
|
||
totalPages: 0
|
||
},
|
||
|
||
pageNumbers: [],
|
||
loadingMore: false,
|
||
|
||
products: []
|
||
};
|
||
},
|
||
async onLoad() {
|
||
await this.initData();
|
||
this.loadSubCategories('all');
|
||
await this.loadGoods(1);
|
||
},
|
||
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,
|
||
flag: this.codeToEmoji(c.countryIcon)
|
||
}));
|
||
|
||
this.setData({
|
||
countries
|
||
});
|
||
|
||
// 2. 默认不选中任何国家,显示全部国家的数据
|
||
await this.loadCategoryData('');
|
||
|
||
// 简单检测结构 — 打印到 console 验证
|
||
console.log('=== [Products API Test] ===');
|
||
console.log('[1] countries:', countries.length, '条');
|
||
console.log('=== [Test End] ===');
|
||
} catch (err) {
|
||
console.error('[Products initData Error]', err);
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 加载分类树和标签组(按国家过滤)
|
||
* @param {string} countryId 国家 ID,传空字符串表示全部国家
|
||
*/
|
||
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.setData({
|
||
categories,
|
||
filterGroups,
|
||
activeCategory: 'all',
|
||
subCategories: [],
|
||
activeSubCategory: ''
|
||
});
|
||
|
||
console.log('[loadCategoryData] countryId=' + (countryId || 'all') +
|
||
', categories=' + categories.length +
|
||
', filterGroups=' + filterGroups.length);
|
||
} catch (err) {
|
||
console.error('[loadCategoryData Error]', err);
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 加载商品列表(分页 + 筛选)
|
||
* @param {number} [page] 页码,默认 1
|
||
*/
|
||
async loadGoods(page) {
|
||
try {
|
||
const currentPage = page || this.pagination.current || 1;
|
||
const pageSize = this.pagination.pageSize || 10;
|
||
|
||
var params = {
|
||
page: currentPage,
|
||
pageSize: pageSize
|
||
};
|
||
|
||
// 国家筛选
|
||
if (this.selectedCountry) {
|
||
params.countryId = this.selectedCountry;
|
||
}
|
||
|
||
// 分类筛选(排除 "all")
|
||
if (this.activeCategory && this.activeCategory !== 'all') {
|
||
params.categoryId = this.activeCategory;
|
||
}
|
||
|
||
// 关键词搜索
|
||
if (this.searchKeyword && this.searchKeyword.trim()) {
|
||
params.keyword = this.searchKeyword.trim();
|
||
}
|
||
|
||
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 || [];
|
||
|
||
// 映射:API 字段 → 模板字段
|
||
const products = items.map((item) => ({
|
||
id: item.goodId,
|
||
name: item.goodName,
|
||
price: item.price,
|
||
image: item.image,
|
||
bgColor: '#F5F5F5',
|
||
emoji: '👕',
|
||
tags: (item.tags || []).map(function (t) {
|
||
return {
|
||
text: t.tagName,
|
||
bg: t.tagColor,
|
||
color: t.tagFontColor
|
||
};
|
||
})
|
||
}));
|
||
|
||
const total = data.total || 0;
|
||
const totalPages = Math.ceil(total / pageSize) || 1;
|
||
var pageNumbers = [];
|
||
for (var i = Math.max(1, currentPage - 2); i <= Math.min(totalPages, currentPage + 2); i++) {
|
||
pageNumbers.push(i);
|
||
}
|
||
|
||
this.setData({
|
||
products: products,
|
||
pagination: {
|
||
start: total === 0 ? 0 : (currentPage - 1) * pageSize + 1,
|
||
end: Math.min(currentPage * pageSize, total),
|
||
total: total,
|
||
current: currentPage,
|
||
pageSize: pageSize,
|
||
totalPages: totalPages
|
||
},
|
||
pageNumbers: pageNumbers,
|
||
loadingMore: false
|
||
});
|
||
|
||
console.log('[loadGoods] total=' + total +
|
||
', page=' + currentPage +
|
||
', items=' + products.length);
|
||
} catch (err) {
|
||
console.error('[loadGoods Error]', err);
|
||
this.setData({
|
||
products: [],
|
||
loadingMore: false
|
||
});
|
||
}
|
||
},
|
||
|
||
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.setData({
|
||
subCategories: subCats,
|
||
activeSubCategory: subCats.length > 0 ? subCats[0].id : ''
|
||
});
|
||
},
|
||
|
||
onSearchInput(e) {
|
||
this.setData({
|
||
searchKeyword: e.detail.value
|
||
});
|
||
// 防抖搜索
|
||
if (this._searchTimer) clearTimeout(this._searchTimer);
|
||
var self = this;
|
||
this._searchTimer = setTimeout(function () {
|
||
self.loadGoods(1);
|
||
}, 300);
|
||
},
|
||
|
||
async selectCountry(e) {
|
||
const id = e.currentTarget.dataset.id;
|
||
|
||
// 如果点击的是已选中的国家 → 取消选中,重置为全部国家数据
|
||
if (this.selectedCountry === id) {
|
||
this.setData({
|
||
selectedCountry: '',
|
||
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.setData({
|
||
selectedCountry: id,
|
||
activeFilters: filters
|
||
});
|
||
await this.loadCategoryData(id);
|
||
await this.loadGoods(1);
|
||
},
|
||
|
||
async selectCategory(e) {
|
||
const id = e.currentTarget.dataset.id;
|
||
this.setData({
|
||
activeCategory: id
|
||
});
|
||
this.loadSubCategories(id);
|
||
await this.loadGoods(1);
|
||
},
|
||
|
||
selectSubCategory(e) {
|
||
this.setData({
|
||
activeSubCategory: e.currentTarget.dataset.id
|
||
});
|
||
},
|
||
|
||
toggleFilter() {
|
||
this.setData({
|
||
showFilter: !this.showFilter
|
||
});
|
||
},
|
||
|
||
closeFilter() {
|
||
this.setData({
|
||
showFilter: false
|
||
});
|
||
},
|
||
|
||
toggleFilterTag(e) {
|
||
const gIdx = e.currentTarget.dataset.groupIdx;
|
||
const tagId = e.currentTarget.dataset.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.setData({
|
||
filterGroups: groups
|
||
});
|
||
},
|
||
|
||
async removeFilter(e) {
|
||
const key = e.currentTarget.dataset.key;
|
||
const filters = this.activeFilters.filter((f) => f.key !== key);
|
||
|
||
// 如果清除的是国家筛选 → 重置分类和标签组为全部国家
|
||
if (key === 'country') {
|
||
this.setData({
|
||
selectedCountry: '',
|
||
activeFilters: filters
|
||
});
|
||
await this.loadCategoryData('');
|
||
await this.loadGoods(1);
|
||
return;
|
||
}
|
||
|
||
// 清除标签筛选 → 同步更新 filterGroups.selected
|
||
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.setData({
|
||
activeFilters: filters,
|
||
filterGroups: groups
|
||
});
|
||
await this.loadGoods(1);
|
||
return;
|
||
}
|
||
|
||
this.setData({
|
||
activeFilters: filters
|
||
});
|
||
},
|
||
|
||
async clearAllFilters() {
|
||
// 是否有国家筛选
|
||
const hasCountry = this.activeFilters.some((f) => f.key === 'country');
|
||
this.setData({
|
||
activeFilters: []
|
||
});
|
||
if (hasCountry || this.selectedCountry) {
|
||
this.setData({
|
||
selectedCountry: ''
|
||
});
|
||
await this.loadCategoryData('');
|
||
}
|
||
// 清空标签选中
|
||
var groups = this.filterGroups.map(function (g) {
|
||
return Object.assign({}, g, { selected: [] });
|
||
});
|
||
this.setData({
|
||
filterGroups: groups
|
||
});
|
||
await this.loadGoods(1);
|
||
},
|
||
|
||
resetFilters() {
|
||
var groups = this.filterGroups.map(function (g) {
|
||
return Object.assign({}, g, { selected: [] });
|
||
});
|
||
this.setData({
|
||
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.setData({
|
||
activeFilters: filters,
|
||
showFilter: false
|
||
});
|
||
await this.loadGoods(1);
|
||
},
|
||
|
||
goDetail(e) {
|
||
const id = e.currentTarget.dataset.id;
|
||
uni.navigateTo({
|
||
url: `/pages/product-detail/product-detail?id=${id}`
|
||
});
|
||
},
|
||
|
||
async changePage(e) {
|
||
const action = e.currentTarget.dataset.action;
|
||
let current = this.pagination.current;
|
||
if (action === 'prev' && current > 1) {
|
||
current--;
|
||
}
|
||
if (action === 'next' && current < this.pagination.totalPages) {
|
||
current++;
|
||
}
|
||
await this.loadGoods(current);
|
||
},
|
||
|
||
async goToPage(e) {
|
||
await this.loadGoods(e.currentTarget.dataset.page);
|
||
},
|
||
|
||
updatePagination(current) {
|
||
const total = this.pagination.total;
|
||
const pageSize = this.pagination.pageSize;
|
||
const totalPages = Math.ceil(total / pageSize);
|
||
let pageNumbers = [];
|
||
for (let i = Math.max(1, current - 2); i <= Math.min(totalPages, current + 2); i++) {
|
||
pageNumbers.push(i);
|
||
}
|
||
this.setData({
|
||
pagination: {
|
||
...this.pagination,
|
||
current,
|
||
start: (current - 1) * pageSize + 1,
|
||
end: Math.min(current * pageSize, total),
|
||
totalPages
|
||
},
|
||
pageNumbers
|
||
});
|
||
},
|
||
|
||
loadMore() {
|
||
if (this.loadingMore) {
|
||
return;
|
||
}
|
||
const nextPage = this.pagination.current + 1;
|
||
if (nextPage > this.pagination.totalPages) {
|
||
return;
|
||
}
|
||
this.setData({
|
||
loadingMore: true
|
||
});
|
||
this.loadGoods(nextPage);
|
||
}
|
||
}
|
||
};
|
||
</script>
|
||
<style>
|
||
@import './products.css';
|
||
</style>
|