Files
inkreach-uni/pages/product-detail/product-detail.vue
T
2026-09-01 11:56:21 +08:00

593 lines
21 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>
<!-- 产品详情页 - 接入 /public/goods/{goodId} 接口 -->
<view class="detail-page" :style="{ paddingTop: (statusBarHeight + 44) + 'px' }">
<!-- 自定义导航栏返回按钮 + 标题UI-0014navigationStyle:custom 需自绘 -->
<view class="nav-bar" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="nav-inner">
<view class="nav-back" @tap="goBack">
<view class="nav-back-arrow"></view>
</view>
<text class="nav-title">产品详情</text>
</view>
</view>
<!-- 产品图片轮播 + 指示点 -->
<DetailSwiper :images="productImages" />
<!-- 产品信息名称/国家/发货时效/价格 -->
<ProductInfo :info="product" />
<!-- 灰色分隔条 -->
<view class="section-divider" v-if="colors.length"></view>
<!-- 颜色选择根据 variants enabled 可用态 -->
<ColorSelector
v-if="colors.length"
:list="colorListWithAvailability"
:selected-id="selectedColorId"
@select="selectColor"
/>
<!-- 灰色分隔条 -->
<view class="section-divider" v-if="sizes.length"></view>
<!-- 尺码选择根据 variants enabled 可用态 -->
<SizeSelector
v-if="sizes.length"
:list="sizeListWithAvailability"
:selected="selectedSize"
@select="selectSize"
/>
<!-- 灰色分隔条 -->
<view class="section-divider" v-if="logisticsOptions.length"></view>
<!-- 物流选择来自 family 价格矩阵维度 -->
<OptionSelector
v-if="logisticsOptions.length"
title="物流"
:list="logisticsOptions"
:selected="selectedLogistics"
@select="selectLogistics"
/>
<!-- 灰色分隔条 -->
<view class="section-divider" v-if="printCountOptions.length"></view>
<!-- 印花选择来自 family 价格矩阵维度 -->
<OptionSelector
v-if="printCountOptions.length"
title="印花"
:list="printCountOptions"
:selected="selectedPrintCount"
@select="selectPrintCount"
/>
<!-- 灰色分隔条 -->
<view class="section-divider" v-if="craftOptions.length"></view>
<!-- 工艺选择来自 family 价格矩阵维度 -->
<OptionSelector
v-if="craftOptions.length"
title="工艺"
:list="craftOptions"
:selected="selectedCraft"
@select="selectCraft"
/>
<!-- 灰色分隔条 -->
<view class="section-divider" v-if="productParams.length"></view>
<!-- 产品参数含可展开的补充说明 -->
<ParamsTable v-if="productParams.length" :list="productParams" />
<!-- 灰色分隔条 -->
<view class="section-divider" v-if="sizeChart.rows && sizeChart.rows.length"></view>
<!-- 产品尺码表 -->
<SizeChart v-if="sizeChart.rows && sizeChart.rows.length" :data="sizeChart" />
<!-- 灰色分隔条 -->
<view class="section-divider" v-if="packaging"></view>
<!-- 包装规格表接口暂未提供预留 -->
<PackagingSpec v-if="packaging" :data="packaging" />
</view>
</template>
<script>
import DetailSwiper from './components/DetailSwiper.vue'
import ProductInfo from './components/ProductInfo.vue'
import ColorSelector from './components/ColorSelector.vue'
import SizeSelector from './components/SizeSelector.vue'
import OptionSelector from './components/OptionSelector.vue'
import ParamsTable from './components/ParamsTable.vue'
import SizeChart from './components/SizeChart.vue'
import PackagingSpec from './components/PackagingSpec.vue'
import { getGoodsDetail } from '@/api/public.js'
export default {
components: {
DetailSwiper,
ProductInfo,
ColorSelector,
SizeSelector,
OptionSelector,
ParamsTable,
SizeChart,
PackagingSpec
},
data() {
return {
statusBarHeight: 20,
productId: null,
loading: false,
product: {
name: '',
country: '',
shipTime: '',
price: { symbol: '¥', integer: '0', decimal: '' }
},
productImages: [],
colors: [],
selectedColorId: '',
sizes: [],
selectedSize: '',
productParams: [],
sizeChart: {},
// 变体列表(来自接口 variants),用于校验尺码+颜色组合、获取价格、包装规格
variants: [],
// 按颜色分组的图片映射:{ [colorId]: [url, ...] }(来自 detail.mediaByColor
mediaByColor: {},
// 默认轮播图(无颜色或 mediaByColor 缺失时使用,来自 detail.media.images
defaultImages: [],
packaging: null,
// 产品族价格矩阵(来自 detail.family,含 尺码×颜色×工艺×物流×印花 五维)
family: null,
selectedLogistics: '',
selectedPrintCount: '',
selectedCraft: ''
}
},
onLoad(options) {
try {
const sysInfo = uni.getSystemInfoSync()
this.statusBarHeight = sysInfo.statusBarHeight || 20
} catch (e) {
this.statusBarHeight = 20
}
if (options && options.id) {
this.productId = options.id
this.loadDetail(options.id)
}
},
methods: {
// 返回上一页;无历史(如直接打开详情)时回首页 tab
goBack() {
const pages = getCurrentPages()
if (pages.length > 1) {
uni.navigateBack({ delta: 1 })
} else {
uni.switchTab({ url: '/pages/index/index' })
}
},
async loadDetail(goodId) {
if (!goodId) return
this.loading = true
try {
const res = await getGoodsDetail(goodId)
const detail = (res && res.data && res.data.data) || {}
this.applyDetail(detail)
} catch (err) {
console.error('[product-detail loadDetail Error]', err)
} finally {
this.loading = false
}
},
/**
* 将接口详情数据映射为各组件所需结构
*/
applyDetail(detail) {
// 1. 产品信息:名称 / 国家 / 发货时效 / 价格
this.product = {
name: detail.goodName || '—',
country: (detail.country && detail.country.countryName) || '—',
shipTime: detail.productionCycleHours ? detail.productionCycleHours + '小时' : '—',
price: this.parsePrice(detail.price)
}
// 2. 图片画廊:
// - defaultImages:来自 detail.media.images(按 sortOrder 升序,首图置顶)
// - mediaByColor:来自 detail.mediaByColor,结构 [{ colorId, colorName, colorHex, images:[url,...] }]
// 顶部轮播优先显示选中颜色对应的图片;mediaByColor 缺失或当前颜色无图时回退到 defaultImages
const media = detail.media || {}
let defaultImages = (media.images || [])
.slice()
.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0))
.map((img) => img.url)
.filter((url) => url)
if (media.primaryImageUrl && defaultImages.indexOf(media.primaryImageUrl) === -1) {
defaultImages.unshift(media.primaryImageUrl)
}
if (!defaultImages.length && detail.image) {
defaultImages.push(detail.image)
}
this.defaultImages = defaultImages
this.mediaByColor = this.buildMediaByColor(detail.mediaByColor)
// 3. 颜色:仅取启用项,按 sortOrder 升序
const colors = ((detail.options && detail.options.colors) || [])
.filter((c) => c.enabled !== false)
.slice()
.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0))
.map((c) => ({ id: c.id, name: c.name, value: c.hex }))
this.colors = colors
this.selectedColorId = colors.length ? colors[0].id : ''
// 4. 尺码:仅取启用项,按 sortOrder 升序
const sizes = ((detail.options && detail.options.sizes) || [])
.filter((s) => s.enabled !== false)
.slice()
.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0))
.map((s) => s.name)
this.sizes = sizes
this.selectedSize = sizes.length ? sizes[0] : ''
// 5. 产品参数(含可展开的"补充说明")
this.productParams = this.buildParams(detail)
// 6. 变体:用于尺码+颜色校验、价格联动、包装规格来源
this.variants = Array.isArray(detail.variants) ? detail.variants.slice() : []
// 7. 尺码表
this.sizeChart = this.buildSizeChart(detail.sizeChart)
// 8. 包装规格(从 variants 按尺码去重生成)
this.packaging = this.buildPackaging(this.variants)
// 9. 产品族价格矩阵:解析 family 五维选项(物流/印花/工艺),默认选中第一项
const family = detail.family && detail.family.priceMatrix ? detail.family : null
this.family = family
this.selectedLogistics = family && family.logistics && family.logistics.length ? family.logistics[0] : ''
this.selectedPrintCount = family && family.printCounts && family.printCounts.length ? family.printCounts[0] : ''
this.selectedCraft = family && family.crafts && family.crafts.length ? family.crafts[0] : ''
// 10. 初始价格(family 优先按价格矩阵取,否则按默认选中的 尺码+颜色 匹配变体价格)
this.syncPriceByVariant()
// 11. 初始轮播图(按默认选中颜色取 mediaByColor,无则回退 defaultImages
this.syncImagesByColor()
},
/**
* 将接口 mediaByColor 数组转为 colorId -> [url] 映射
* 入参:[{ colorId, colorName, colorHex, images:[url,...] }]
* 出参:{ '1126198': [url, ...], ... }
*/
buildMediaByColor(list) {
const map = {}
if (Array.isArray(list)) {
list.forEach((item) => {
if (!item || item.colorId == null) return
const imgs = Array.isArray(item.images) ? item.images.filter((u) => u) : []
if (imgs.length) map[String(item.colorId)] = imgs
})
}
return map
},
/**
* 按当前 selectedColorId 同步顶部轮播图:
* - 若 mediaByColor 命中且该颜色有图,则使用该颜色的图片数组
* - 否则回退到 defaultImages
*/
syncImagesByColor() {
const colorId = this.selectedColorId
const byColor = this.mediaByColor || {}
const imgs = colorId && byColor[String(colorId)] ? byColor[String(colorId)] : this.defaultImages
this.productImages = (imgs || []).slice()
},
/**
* 价格拆分:'35' → { symbol:'¥', integer:'35', decimal:'' }
* '35.50' → { symbol:'¥', integer:'35', decimal:'.50' }
*/
parsePrice(price) {
const raw = price != null ? String(price) : ''
let integer = raw
let decimal = ''
const dot = raw.indexOf('.')
if (dot >= 0) {
integer = raw.slice(0, dot) || '0'
decimal = raw.slice(dot)
}
return { symbol: '¥', integer: integer || '0', decimal: decimal }
},
/**
* 构造产品参数列表:克重 / 材质 / 补充说明(可展开)
*/
buildParams(detail) {
const d = detail.details || {}
const params = []
if (detail.minWeightG) {
params.push({ label: '克重', value: detail.minWeightG + 'g' })
}
if (d.materialDescription) {
params.push({ label: '材质', value: d.materialDescription })
}
const expandableText = this.buildExpandableText(d)
if (expandableText) {
params.push({ label: '补充说明', expandable: true, value: expandableText })
}
return params
},
/**
* 拼接"补充说明"多段文本:材质说明/产品性能/适用情景/洗涤说明/图片要求/特殊说明/温馨提示
*/
buildExpandableText(d) {
const lines = []
if (d.materialDescription) lines.push('【 材质说明 】\n' + d.materialDescription)
if (d.productPerformance) lines.push('【 产品性能 】\n' + d.productPerformance)
if (d.applicableScenarios) lines.push('【 适用情景 】\n' + d.applicableScenarios)
if (d.washingInstructions) lines.push('【 洗涤说明 】\n' + d.washingInstructions)
if (d.pictureRequest) lines.push('【 图片要求 】\n' + d.pictureRequest)
if (d.specialDescription) lines.push('【 特殊说明 】\n' + d.specialDescription)
if (d.reminder) lines.push('【 温馨提醒 】\n' + d.reminder)
return lines.join('\n')
},
/**
* 构造尺码表:完全由后端 rows/columns 驱动
* columns 为除"尺码"外每列的表头;rows.measurements 按 key 取值
* 格式异常时返回空对象,页面不渲染该模块(兜底不报错)
*/
buildSizeChart(sizeChart) {
if (!sizeChart || !Array.isArray(sizeChart.rows) || !sizeChart.rows.length) return {}
if (!Array.isArray(sizeChart.columns)) return {}
// 列定义兜底:必须有 key(取值用),name 缺失时用 key 代替
const columns = sizeChart.columns
.filter((c) => c && typeof c.key === 'string' && c.key)
.map((c) => ({ key: c.key, name: typeof c.name === 'string' && c.name ? c.name : c.key }))
if (!columns.length) return {}
const rows = sizeChart.rows
.filter((r) => r && typeof r.sizeName === 'string' && r.sizeName)
.map((row) => {
const measurements = Array.isArray(row.measurements) ? row.measurements : []
const cells = {}
columns.forEach((col) => {
const m = measurements.find((x) => x && x.key === col.key)
const val = m ? (m.cm != null ? m.cm : m.in) : ''
cells[col.key] = val == null ? '' : String(val)
})
return { size: row.sizeName, cells }
})
if (!rows.length) return {}
return { columns, rows }
},
/**
* 构建包装规格表(按尺码聚合 → 每行一个尺码)
* 数据来源:variants[].{ sizeId, sizeName, boxLengthCm, boxWidthCm, boxHeightCm, weightG }
*/
buildPackaging(variants) {
if (!Array.isArray(variants) || !variants.length) return null
const CM_TO_IN = 0.393701
const G_TO_LB = 0.00220462
const map = new Map()
variants.forEach((v) => {
if (!v || !v.sizeId) return
if (map.has(v.sizeId)) return // 按尺码取第一条(所有颜色的包装尺寸/重量一般相同)
const l = parseFloat(v.boxLengthCm) || 0
const w = parseFloat(v.boxWidthCm) || 0
const h = parseFloat(v.boxHeightCm) || 0
const g = parseFloat(v.weightG) || 0
const cm3 = l * w * h
const in3 = cm3 * Math.pow(CM_TO_IN, 3)
map.set(v.sizeId, {
size: v.sizeName || '',
sizeId: v.sizeId,
dimension: {
cm: `${l.toFixed(1)}*${w.toFixed(1)}*${h.toFixed(1)}cm`,
in: `${(l * CM_TO_IN).toFixed(2)}*${(w * CM_TO_IN).toFixed(2)}*${(h * CM_TO_IN).toFixed(2)}in`
},
volume: {
cm: `${cm3.toFixed(2)}cm³`,
in: `${in3.toFixed(2)}in³`
},
weight: {
cm: `${g.toFixed(2)}g`,
in: `${(g * G_TO_LB).toFixed(2)}lb`
}
})
})
if (!map.size) return null
// 按 sizes 顺序排序(保持 S→M→L→XL 顺序)
const orderMap = {}
this.sizes.forEach((s, i) => (orderMap[s] = i))
const rows = Array.from(map.values()).sort((a, b) => {
const ai = orderMap[a.size] == null ? 999 : orderMap[a.size]
const bi = orderMap[b.size] == null ? 999 : orderMap[b.size]
return ai - bi
})
return {
unit: 'cm',
columns: ['尺码', '尺寸', '体积', '重量'],
rows
}
},
/**
* 根据当前 selectedColorId + selectedSize 找到匹配的 variant
* 命中后更新 ProductInfo 的价格;找不到则保持原商品价格
*/
findVariant() {
const sizeName = this.selectedSize
const colorId = this.selectedColorId
if (!sizeName || !colorId || !this.variants.length) return null
return (
this.variants.find(
(v) =>
v &&
String(v.colorId) === String(colorId) &&
String(v.sizeName) === String(sizeName)
) || null
)
},
/**
* 在 family.priceMatrix.rows 中查找当前选中组合(尺码+颜色+物流+印花+工艺)的价格行
* 未选全或无 family 时返回 null
*/
findMatrixRow() {
const f = this.family
if (!f || !f.priceMatrix || !Array.isArray(f.priceMatrix.rows) || !f.priceMatrix.rows.length) {
return null
}
const sizeName = this.selectedSize
const colorId = this.selectedColorId
return (
f.priceMatrix.rows.find(
(row) =>
row &&
(!sizeName || String(row.sizeName) === String(sizeName)) &&
(!colorId || String(row.colorId) === String(colorId)) &&
(!this.selectedLogistics || String(row.logistics) === String(this.selectedLogistics)) &&
(!this.selectedPrintCount || String(row.printCount) === String(this.selectedPrintCount)) &&
(!this.selectedCraft || String(row.craft) === String(this.selectedCraft))
) || null
)
},
/**
* 根据当前选中组合同步价格:
* - 有 family 价格矩阵:按 尺码+颜色+物流+印花+工艺 匹配矩阵行
* - 否则回退:按 尺码+颜色 匹配 variant
*/
syncPriceByVariant() {
const row = this.findMatrixRow()
if (row && row.price != null) {
this.$set(this.product, 'price', this.parsePrice(row.price))
return
}
const v = this.findVariant()
if (v && v.price != null) {
this.$set(this.product, 'price', this.parsePrice(v.price))
}
},
/**
* 颜色列表 + 根据已选尺码标记该颜色是否可用(有对应启用的 variant)
* 数据项: { id, name, value, enabled }
*/
selectColor(id) {
this.selectedColorId = id
this.syncImagesByColor()
this.syncPriceByVariant()
},
/**
* 尺码列表 + 根据已选颜色标记该尺码是否可用
* 数据项: 直接返回字符串(SizeSelector 兼容),不可用的对应按钮会 disabled 掉,因此用对象包一层
*/
selectSize(size) {
this.selectedSize = size
this.syncPriceByVariant()
},
/**
* 物流/印花/工艺选择:来自 family 价格矩阵维度,选中后联动价格
*/
selectLogistics(value) {
this.selectedLogistics = value
this.syncPriceByVariant()
},
selectPrintCount(value) {
this.selectedPrintCount = value
this.syncPriceByVariant()
},
selectCraft(value) {
this.selectedCraft = value
this.syncPriceByVariant()
}
},
computed: {
/**
* 可用性判断数据源:有 family 价格矩阵时用矩阵行,否则用 variants
* 两者都含 sizeName / colorId 字段
*/
availabilitySource() {
const f = this.family
if (f && f.priceMatrix && Array.isArray(f.priceMatrix.rows) && f.priceMatrix.rows.length) {
return f.priceMatrix.rows
}
return this.variants
},
/**
* 物流/印花/工艺选项列表(来自 family)
*/
logisticsOptions() {
return this.family && Array.isArray(this.family.logistics) ? this.family.logistics : []
},
printCountOptions() {
return this.family && Array.isArray(this.family.printCounts) ? this.family.printCounts : []
},
craftOptions() {
return this.family && Array.isArray(this.family.crafts) ? this.family.crafts : []
},
/**
* 颜色列表 + 可用态标记(给 ColorSelector 用,用于 disabled 样式)
* - 如果没选尺码:所有已 enabled 的颜色都可选
* - 如果已选尺码:仅保留存在 (colorId + sizeName) 组合的项
*/
colorListWithAvailability() {
const sizeName = this.selectedSize
const source = this.availabilitySource
return this.colors.map((c) => {
let enabled = c.enabled !== false
if (sizeName && source.length) {
enabled = source.some(
(v) =>
v &&
v.enabled !== false &&
String(v.colorId) === String(c.id) &&
String(v.sizeName) === String(sizeName)
)
}
return Object.assign({}, c, { enabled })
})
},
/**
* 尺码列表 + 可用态标记
* SizeSelector 原始 list 是字符串数组,这里扩展为 [{ name, enabled }]
*/
sizeListWithAvailability() {
const colorId = this.selectedColorId
const source = this.availabilitySource
return this.sizes.map((s) => {
let enabled = true
if (colorId && source.length) {
enabled = source.some(
(v) =>
v &&
v.enabled !== false &&
String(v.colorId) === String(colorId) &&
String(v.sizeName) === String(s)
)
}
return { name: s, enabled }
})
}
}
}
</script>
<style>
@import './product-detail.css';
</style>