442 lines
15 KiB
Vue
442 lines
15 KiB
Vue
<template>
|
||
<!-- 产品详情页 - 接入 /public/goods/{goodId} 接口 -->
|
||
<view class="detail-page">
|
||
<!-- 产品图片轮播 + 指示点 -->
|
||
<DetailSwiper :images="productImages" />
|
||
|
||
<!-- 产品信息:名称/SKU/国家/价格/4列快捷信息 -->
|
||
<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="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 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,
|
||
ParamsTable,
|
||
SizeChart,
|
||
PackagingSpec
|
||
},
|
||
data() {
|
||
return {
|
||
productId: null,
|
||
loading: false,
|
||
product: {
|
||
name: '',
|
||
sku: '',
|
||
country: '',
|
||
price: { symbol: '¥', integer: '0', decimal: '' },
|
||
quickInfo: []
|
||
},
|
||
productImages: [],
|
||
colors: [],
|
||
selectedColorId: '',
|
||
sizes: [],
|
||
selectedSize: '',
|
||
productParams: [],
|
||
sizeChart: {},
|
||
// 变体列表(来自接口 variants),用于校验尺码+颜色组合、获取价格、包装规格
|
||
variants: [],
|
||
// 按颜色分组的图片映射:{ [colorId]: [url, ...] }(来自 detail.mediaByColor)
|
||
mediaByColor: {},
|
||
// 默认轮播图(无颜色或 mediaByColor 缺失时使用,来自 detail.media.images)
|
||
defaultImages: [],
|
||
packaging: null
|
||
}
|
||
},
|
||
onLoad(options) {
|
||
if (options && options.id) {
|
||
this.productId = options.id
|
||
this.loadDetail(options.id)
|
||
}
|
||
},
|
||
methods: {
|
||
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. 产品信息:名称 / SKU / 国家 / 价格 / 4 列快捷信息
|
||
this.product = {
|
||
name: detail.goodName || '—',
|
||
sku: detail.productCode || '—',
|
||
country: (detail.country && detail.country.countryName) || '—',
|
||
price: this.parsePrice(detail.price),
|
||
quickInfo: [
|
||
{ label: '生产工艺', value: (detail.details && detail.details.productionProcess) || '—' },
|
||
{
|
||
label: '发货时效',
|
||
value: detail.productionCycleHours ? detail.productionCycleHours + '小时' : '—'
|
||
},
|
||
{ label: '印花', value: (detail.details && detail.details.designArea) || '—' },
|
||
{ label: '产品编码', value: detail.productCode || '—' }
|
||
]
|
||
}
|
||
|
||
// 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. 初始价格(按默认选中的 尺码+颜色 匹配变体价格)
|
||
this.syncPriceByVariant()
|
||
|
||
// 10. 初始轮播图(按默认选中颜色取 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')
|
||
},
|
||
|
||
/**
|
||
* 构造尺码表:接口 measurements.key (chest/bodyLength/shoulder/sleeveLength) → 表格列
|
||
*/
|
||
buildSizeChart(sizeChart) {
|
||
if (!sizeChart || !sizeChart.rows || !sizeChart.rows.length) return {}
|
||
const columns = ['尺码', '肩宽', '胸围', '衣长', '袖长']
|
||
const rows = sizeChart.rows.map((row) => {
|
||
const measurements = row.measurements || []
|
||
const get = (key) => {
|
||
const m = measurements.find((x) => x.key === key)
|
||
return m ? m.cm : ''
|
||
}
|
||
return {
|
||
size: row.sizeName || '',
|
||
shoulder: get('shoulder'),
|
||
chest: get('chest'),
|
||
length: get('bodyLength'),
|
||
sleeve: get('sleeveLength')
|
||
}
|
||
})
|
||
return { unit: 'cm', 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
|
||
)
|
||
},
|
||
syncPriceByVariant() {
|
||
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()
|
||
}
|
||
},
|
||
computed: {
|
||
/**
|
||
* 颜色列表 + 可用态标记(给 ColorSelector 用,用于 disabled 样式)
|
||
* - 如果没选尺码:所有已 enabled 的颜色都可选
|
||
* - 如果已选尺码:仅保留存在 (colorId + sizeName) 组合且 variant.enabled 的颜色
|
||
*/
|
||
colorListWithAvailability() {
|
||
const sizeName = this.selectedSize
|
||
return this.colors.map((c) => {
|
||
let enabled = c.enabled !== false
|
||
if (sizeName && this.variants.length) {
|
||
enabled = this.variants.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
|
||
return this.sizes.map((s) => {
|
||
let enabled = true
|
||
if (colorId && this.variants.length) {
|
||
enabled = this.variants.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>
|