Files
inkreach-uni/pages/product-detail/product-detail.vue
T
2026-09-04 16:44:05 +08:00

932 lines
33 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 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>