Files
inkreach-uni/pages/product-detail/product-detail.vue
T
2026-08-22 19:05:26 +08:00

401 lines
13 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">
<!-- 产品图片轮播 + 指示点 -->
<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: [],
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. 图片画廊:按 sortOrder 升序,首图置顶
const media = detail.media || {}
let images = (media.images || [])
.slice()
.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0))
.map((img) => img.url)
.filter((url) => url)
if (media.primaryImageUrl && images.indexOf(media.primaryImageUrl) === -1) {
images.unshift(media.primaryImageUrl)
}
if (!images.length && detail.image) {
images.push(detail.image)
}
this.productImages = images
// 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()
},
/**
* 价格拆分:'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.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>