932 lines
33 KiB
Vue
932 lines
33 KiB
Vue
<template>
|
||
<!-- 产品详情页 - 接入 /public/goods/{goodId} 接口 -->
|
||
<view class="detail-page" :style="{ paddingTop: (statusBarHeight + 44) + 'px' }">
|
||
<!-- 自定义导航栏:返回按钮 + 标题(UI-0014,navigationStyle: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 class="share-footer">
|
||
<view class="share-btn" @tap="generateShareImage">生成分享图</view>
|
||
</view>
|
||
|
||
<!-- 生成分享图用的画布(uni-app CanvasContext 旧版接口,跨端):固定定位移出屏幕,仅用于绘制导出 -->
|
||
<canvas canvas-id="shareCanvas" id="shareCanvas" class="share-canvas"></canvas>
|
||
</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)
|
||
} else {
|
||
// 钩子保护:无 id 时(如异常入口进入详情页)回产品列表页(tabBar 页需用 switchTab)
|
||
uni.switchTab({ url: '/pages/products/products' })
|
||
}
|
||
},
|
||
// 微信小程序:分享给朋友(带上当前产品 id,好友点开直达该产品详情)
|
||
onShareAppMessage() {
|
||
return {
|
||
title: this.product.name || '产品详情',
|
||
path: this.productId
|
||
? `/pages/product-detail/product-detail?id=${this.productId}`
|
||
: '/pages/product-detail/product-detail',
|
||
imageUrl: (this.productImages && this.productImages[0]) || ''
|
||
}
|
||
},
|
||
// 微信小程序:分享到朋友圈
|
||
// query 是查询字符串(不带 ?),朋友圈单页模式打开时直接传给本页 onLoad(options),
|
||
// 现有 onLoad 已处理 options.id,无需额外跳转(单页模式下 navigateTo 不可用)
|
||
onShareTimeline() {
|
||
return {
|
||
title: this.product.name || '产品详情',
|
||
query: this.productId ? `id=${this.productId}` : '',
|
||
imageUrl: (this.productImages && this.productImages[0]) || ''
|
||
}
|
||
},
|
||
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()
|
||
},
|
||
|
||
/**
|
||
* 底部按钮:生成分享图
|
||
* 流程:加载产品首图 + 本地小程序码 → CanvasContext 绘制 → 导出图片 → 预览
|
||
*/
|
||
generateShareImage() {
|
||
if (this._generating) return
|
||
this._generating = true
|
||
uni.showLoading({ title: '生成中...', mask: true })
|
||
this.drawShareImage()
|
||
},
|
||
|
||
/**
|
||
* 绘制分享图并导出预览(uni-app CanvasContext 旧版接口,跨端)
|
||
* 整图固定比例 2.5 : 4(750 x 1200):上半部分产品图(右下角叠小程序码),
|
||
* 下方 名称/颜色点/尺码/价格;文字区高度自适应,剩余空间全部给产品图区
|
||
* 画布组件 CSS 尺寸为其一半(375x600),绘制前 scale(0.5),导出时按像素密度放大
|
||
*/
|
||
async drawShareImage() {
|
||
try {
|
||
const ctx = uni.createCanvasContext('shareCanvas', this)
|
||
|
||
// ===== 设计稿逻辑尺寸(1 逻辑单位 = 画布 CSS 像素 × 2)=====
|
||
const W = 750
|
||
const H = 1200 // 整图固定 750 x 1200,比例约 2.5 : 4
|
||
const PAD = 32 // 页边距
|
||
const IMG_W = 686 // 产品图区宽(圆角,高按剩余空间自适应)
|
||
const NAME_FONT = 'bold 36px sans-serif'
|
||
const NAME_LH = 50 // 名称行高
|
||
const DOT_SIZE = 30 // 颜色点直径
|
||
|
||
// 名称折行(measureText 为同步测量,与缩放无关,直接测即可)
|
||
ctx.font = NAME_FONT
|
||
const nameLines = this.wrapNameLines(ctx, this.product.name || '—', IMG_W, 2)
|
||
const sizeText = this.sizes.length
|
||
? this.sizes[0] + ' - ' + this.sizes[this.sizes.length - 1]
|
||
: ''
|
||
const hasDots = this.colors.length > 0
|
||
|
||
// 自下而上排文字区:价格 → 尺码 → 颜色点 → 名称,剩余高度全部给产品图区
|
||
const priceBaseline = H - 36 // 底部留白 36
|
||
let y = priceBaseline - 62 // 价格块顶部
|
||
let sizeTop = 0
|
||
if (sizeText) {
|
||
sizeTop = y - 20 - 38
|
||
y = sizeTop
|
||
}
|
||
let dotsTop = 0
|
||
if (hasDots) {
|
||
dotsTop = y - 20 - DOT_SIZE
|
||
y = dotsTop
|
||
}
|
||
const nameTop = y - 24 - nameLines.length * NAME_LH
|
||
const imgBottom = nameTop - 26
|
||
const IMG_H = imgBottom - PAD // 产品图区高
|
||
|
||
// 画布组件 CSS 尺寸为设计稿一半,绘制统一缩放 0.5
|
||
ctx.scale(0.5, 0.5)
|
||
|
||
// ===== 加载产品首图信息(真实宽高用于 aspectFit)=====
|
||
// 小程序码为代码包内静态资源,drawImage 直接支持包内路径,无需 getImageInfo 转换;
|
||
// H5 下仍预热一次(触发浏览器缓存),确保 drawImage 内部加载即时完成
|
||
const [productImg] = await Promise.all([
|
||
this.loadImageInfo(this.productImages[0] || ''),
|
||
this.loadImageInfo('/static/images/detail/qrcode.png')
|
||
])
|
||
|
||
// 白底铺满画布(jpg 导出透明区域会变黑,需全画布垫白)
|
||
ctx.setFillStyle('#ffffff')
|
||
ctx.fillRect(0, 0, W, H)
|
||
|
||
// ===== 产品图区:圆角矩形 + 灰底 + 图片 aspectFit 居中 =====
|
||
ctx.setFillStyle('#f2f2f2')
|
||
this.pathRoundRect(ctx, PAD, PAD, IMG_W, IMG_H, 24)
|
||
ctx.fill()
|
||
// 优先用 getImageInfo 的真实宽高做 aspectFit;
|
||
// 获取失败(如未配置 downloadFile 域名)时回退直接画网络图,按 3:4 估算宽高
|
||
const drawPath = productImg ? productImg.path : this.productImages[0] || ''
|
||
const imgW = productImg ? productImg.width : 3
|
||
const imgH = productImg ? productImg.height : 4
|
||
if (drawPath) {
|
||
ctx.save()
|
||
this.pathRoundRect(ctx, PAD, PAD, IMG_W, IMG_H, 24)
|
||
ctx.clip()
|
||
const s = Math.min(IMG_W / imgW, IMG_H / imgH)
|
||
const dw = imgW * s
|
||
const dh = imgH * s
|
||
ctx.drawImage(drawPath, PAD + (IMG_W - dw) / 2, PAD + (IMG_H - dh) / 2, dw, dh)
|
||
ctx.restore()
|
||
}
|
||
|
||
// ===== "新"角标(图区左上角)=====
|
||
ctx.setFillStyle('#00b578')
|
||
this.pathRoundRect(ctx, PAD + 20, PAD + 20, 68, 68, 14)
|
||
ctx.fill()
|
||
ctx.setFillStyle('#ffffff')
|
||
ctx.font = 'bold 36px sans-serif'
|
||
ctx.textAlign = 'center'
|
||
ctx.textBaseline = 'middle'
|
||
ctx.fillText('新', PAD + 20 + 34, PAD + 20 + 36)
|
||
ctx.textAlign = 'left'
|
||
ctx.textBaseline = 'alphabetic'
|
||
|
||
// ===== 小程序码:白底圆角衬底,叠在产品图区右下角 =====
|
||
// 代码包内静态资源直接用包内路径绘制(drawImage 原生支持;
|
||
// getImageInfo 对包内路径在部分基础库/工具上会失败,导致二维码被跳过)
|
||
const qrBg = 200
|
||
const qrX = PAD + IMG_W - qrBg - 16
|
||
const qrY = PAD + IMG_H - qrBg - 16
|
||
ctx.setFillStyle('#ffffff')
|
||
this.pathRoundRect(ctx, qrX, qrY, qrBg, qrBg, 14)
|
||
ctx.fill()
|
||
ctx.drawImage('/static/images/detail/qrcode.png', qrX + 10, qrY + 10, 180, 180)
|
||
|
||
// ===== 名称(最多两行,已折好行)=====
|
||
ctx.setFillStyle('#1a1a1a')
|
||
ctx.font = NAME_FONT
|
||
nameLines.forEach((line, i) => {
|
||
ctx.fillText(line, PAD, nameTop + i * NAME_LH + 33)
|
||
})
|
||
|
||
// ===== 颜色点 =====
|
||
if (hasDots) {
|
||
ctx.setLineWidth(1)
|
||
this.colors.slice(0, 8).forEach((c, i) => {
|
||
ctx.beginPath()
|
||
ctx.arc(PAD + 15 + i * 44, dotsTop + 15, 15, 0, Math.PI * 2)
|
||
ctx.setFillStyle(c.value || '#e0e0e0')
|
||
ctx.fill()
|
||
ctx.setStrokeStyle('rgba(0, 0, 0, 0.08)')
|
||
ctx.stroke()
|
||
})
|
||
}
|
||
|
||
// ===== 尺码(首 - 尾)=====
|
||
if (sizeText) {
|
||
ctx.setFillStyle('#808080')
|
||
ctx.font = '28px sans-serif'
|
||
ctx.fillText(sizeText, PAD, sizeTop + 28)
|
||
}
|
||
|
||
// ===== 价格(符号/小数 32px,整数 60px,共用一条基线)=====
|
||
const p = this.product.price || {}
|
||
ctx.setFillStyle('#ff0000')
|
||
let px = PAD
|
||
ctx.font = '32px sans-serif'
|
||
ctx.fillText(p.symbol || '¥', px, priceBaseline)
|
||
px += ctx.measureText(p.symbol || '¥').width + 2
|
||
ctx.font = 'bold 60px sans-serif'
|
||
ctx.fillText(p.integer || '0', px, priceBaseline)
|
||
px += ctx.measureText(p.integer || '0').width + 2
|
||
ctx.fillText(p.decimal || '', px, priceBaseline)
|
||
|
||
// ===== 一次性渲染到画布,完成后导出 =====
|
||
ctx.draw(false, () => {
|
||
// #ifdef H5
|
||
this.exportShareImageH5()
|
||
return
|
||
// #endif
|
||
// #ifdef MP-WEIXIN
|
||
const dpr = this.getPixelRatio()
|
||
uni.canvasToTempFilePath({
|
||
canvasId: 'shareCanvas',
|
||
x: 0,
|
||
y: 0,
|
||
width: W / 2,
|
||
height: H / 2,
|
||
destWidth: (W / 2) * dpr,
|
||
destHeight: (H / 2) * dpr,
|
||
fileType: 'jpg',
|
||
quality: 0.9,
|
||
success: (res) => {
|
||
uni.previewImage({ urls: [res.tempFilePath] })
|
||
},
|
||
fail: (err) => {
|
||
console.error('[share canvasToTempFilePath fail]', err)
|
||
uni.showToast({ title: '生成失败', icon: 'none' })
|
||
},
|
||
complete: () => {
|
||
this._generating = false
|
||
uni.hideLoading()
|
||
}
|
||
}, this)
|
||
// #endif
|
||
})
|
||
} catch (e) {
|
||
this._generating = false
|
||
uni.hideLoading()
|
||
console.error('[drawShareImage error]', e)
|
||
uni.showToast({ title: '生成失败', icon: 'none' })
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 加载图片信息(本地可绘制路径 + 原始宽高),供旧版 CanvasContext.drawImage 使用:
|
||
* - 微信小程序:uni.getImageInfo,远程图先下载为本地临时路径
|
||
* - H5:跨域远程图转 blob 同源地址,避免画布被污染导致无法导出
|
||
*/
|
||
loadImageInfo(src) {
|
||
return new Promise((resolve) => {
|
||
if (!src) return resolve(null)
|
||
// #ifdef MP-WEIXIN
|
||
uni.getImageInfo({
|
||
src: src,
|
||
success: (info) => resolve(info),
|
||
fail: () => resolve(null)
|
||
})
|
||
// #endif
|
||
// #ifdef H5
|
||
const isSameOrigin = src.indexOf('//') === -1 || src.indexOf(location.host) !== -1
|
||
const probe = (url) => {
|
||
const img = new Image()
|
||
img.onload = () => resolve({ path: url, width: img.naturalWidth, height: img.naturalHeight })
|
||
img.onerror = () => resolve(null)
|
||
img.src = url
|
||
}
|
||
if (isSameOrigin) return probe(src)
|
||
const xhr = new XMLHttpRequest()
|
||
xhr.open('GET', src)
|
||
xhr.responseType = 'blob'
|
||
xhr.onload = () => {
|
||
if (xhr.response) probe(URL.createObjectURL(xhr.response))
|
||
else resolve(null)
|
||
}
|
||
xhr.onerror = () => resolve(null)
|
||
xhr.send()
|
||
// #endif
|
||
})
|
||
},
|
||
|
||
/**
|
||
* H5 导出:取真实 canvas 元素 toDataURL 预览
|
||
* (画布 CSS 尺寸 375x600 与内容一致,整图即所得,已垫白)
|
||
*/
|
||
exportShareImageH5() {
|
||
try {
|
||
const el =
|
||
document.querySelector('.share-canvas canvas') || document.querySelector('.share-canvas')
|
||
if (!el || !el.toDataURL) throw new Error('canvas not found')
|
||
uni.previewImage({ urls: [el.toDataURL('image/jpeg', 0.9)] })
|
||
} catch (e) {
|
||
console.error('[exportShareImageH5 error]', e)
|
||
uni.showToast({ title: '生成失败', icon: 'none' })
|
||
} finally {
|
||
this._generating = false
|
||
uni.hideLoading()
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 屏幕像素密度(导出图片放大倍数)
|
||
*/
|
||
getPixelRatio() {
|
||
try {
|
||
return uni.getSystemInfoSync().pixelRatio || 2
|
||
} catch (e) {
|
||
return 2
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 圆角矩形路径(用 arc 画四角,兼容旧版 CanvasContext,无 arcTo 依赖)
|
||
*/
|
||
pathRoundRect(ctx, x, y, w, h, r) {
|
||
ctx.beginPath()
|
||
ctx.moveTo(x + r, y)
|
||
ctx.lineTo(x + w - r, y)
|
||
ctx.arc(x + w - r, y + r, r, -Math.PI / 2, 0)
|
||
ctx.lineTo(x + w, y + h - r)
|
||
ctx.arc(x + w - r, y + h - r, r, 0, Math.PI / 2)
|
||
ctx.lineTo(x + r, y + h)
|
||
ctx.arc(x + r, y + h - r, r, Math.PI / 2, Math.PI)
|
||
ctx.lineTo(x, y + r)
|
||
ctx.arc(x + r, y + r, r, Math.PI, Math.PI * 1.5)
|
||
ctx.closePath()
|
||
},
|
||
|
||
/**
|
||
* 中文按字符折行:最多 maxLines 行,溢出时行尾补 "..."(保证不超宽)
|
||
*/
|
||
wrapNameLines(ctx, text, maxWidth, maxLines) {
|
||
const chars = String(text).split('')
|
||
const lines = []
|
||
let line = ''
|
||
let overflow = false
|
||
for (let i = 0; i < chars.length; i++) {
|
||
const test = line + chars[i]
|
||
if (line && ctx.measureText(test).width > maxWidth) {
|
||
if (lines.length === maxLines - 1) {
|
||
overflow = true
|
||
break
|
||
}
|
||
lines.push(line)
|
||
line = chars[i]
|
||
} else {
|
||
line = test
|
||
}
|
||
}
|
||
if (line && lines.length < maxLines) lines.push(line)
|
||
if (overflow && lines.length === maxLines) {
|
||
let last = lines[maxLines - 1]
|
||
while (last && ctx.measureText(last + '...').width > maxWidth) {
|
||
last = last.slice(0, -1)
|
||
}
|
||
lines[maxLines - 1] = last + '...'
|
||
}
|
||
return lines
|
||
}
|
||
},
|
||
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>
|