Files
pikachu1995@126.com 7d3daf2f02 feat(distribution): 实现分销员中心完整功能
- 新增分销业绩统计页面,包含时间范围筛选和自定义日期选择功能
- 重构分销认证页面,改为跳转至招募页面申请分销员身份
- 添加分销商品访问记录功能,支持分享追踪和佣金计算
- 更新分销历史记录页面,优化数据结构和显示字段
- 完全重写分销员首页,包含用户信息、收益统计、等级系统等功能
2026-08-05 18:07:27 +08:00

540 lines
13 KiB
Vue

<template>
<view class="poster-panel">
<view class="poster-hint">点击海报预览后可保存到相册</view>
<view v-if="loading" class="loading-wrap">
<text class="loading-text">海报生成中...</text>
</view>
<view v-else-if="loadError" class="loading-wrap">
<text class="loading-text">{{ loadError }}</text>
<view class="retry-btn" @click="generatePosters">重新生成</view>
</view>
<view v-else-if="!images.length" class="loading-wrap">
<text class="loading-text">暂无商品图片</text>
</view>
<template v-else-if="posterList.length">
<view class="poster-content">
<swiper
class="poster-swiper"
:current="current"
circular
@change="onSwiperChange"
>
<swiper-item v-for="(item, index) in posterList" :key="index">
<view class="poster-card-wrap" @click="previewPoster(index)">
<image class="poster-image" :src="item" mode="widthFix" />
</view>
</swiper-item>
</swiper>
<view v-if="posterList.length > 1" class="poster-dots">
<view
v-for="(_, index) in posterList"
:key="index"
:class="['dot', current === index && 'active']"
/>
</view>
<view class="save-btn-wrap">
<view class="save-btn" @click="saveCurrentPoster">保存海报</view>
</view>
</view>
</template>
<view class="canvas-hide">
<!-- #ifdef MP-WEIXIN -->
<canvas id="distributionPosterCanvas" type="2d" style="width: 600px; height: 840px" />
<!-- #endif -->
<!-- #ifndef MP-WEIXIN -->
<canvas
canvas-id="distributionPosterCanvas"
id="distributionPosterCanvas"
style="width: 600px; height: 840px"
/>
<!-- #endif -->
</view>
</view>
</template>
<script setup lang="ts">
import { ref, watch, getCurrentInstance, nextTick } from 'vue'
import DrawPoster from '@/js_sdk/u-draw-poster'
import { getDistributionGoodsPosterContext } from '@/api/distribution'
import { unitPrice } from '@/utils/filters.js'
const CANVAS_SELECTOR = 'distributionPosterCanvas'
const POSTER_WIDTH = 600
const IMAGE_HEIGHT = POSTER_WIDTH
const BOTTOM_HEIGHT = 240
const POSTER_HEIGHT = IMAGE_HEIGHT + BOTTOM_HEIGHT
const QR_SIZE = 120
const QR_MARGIN_RIGHT = 28
const QR_MARGIN_BOTTOM = 28
const GENERATE_TIMEOUT = 45000
const props = defineProps<{
images?: string[]
goodsName?: string
goodsDesc?: string
price?: number | null
skuId?: string
goodsId?: string
distributionId?: string
active?: boolean
}>()
const instance = getCurrentInstance()
const posterList = ref<string[]>([])
const current = ref(0)
const loading = ref(false)
const loadError = ref('')
const hasGenerated = ref(false)
let dp: any = null
// #ifdef MP-WEIXIN
const st2 = (size: number) => size * 2
// #endif
// #ifndef MP-WEIXIN
const st2 = (size: number) => size
// #endif
watch(
() => [props.images?.length, props.skuId, props.goodsId, props.distributionId],
() => {
resetPosters()
if (props.active && props.images?.length) {
generatePosters()
}
}
)
watch(
() => props.active,
(active) => {
if (active && !hasGenerated.value && props.images?.length && !loading.value) {
generatePosters()
}
},
{ immediate: true }
)
function resetPosters() {
hasGenerated.value = false
posterList.value = []
loadError.value = ''
clearDrawPosterCache()
}
function clearDrawPosterCache() {
const pages = getCurrentPages()
const page = pages[pages.length - 1] as any
if (!page) return
delete page[`#${CANVAS_SELECTOR}__dp`]
delete page[`${CANVAS_SELECTOR}__dp`]
}
function onSwiperChange(e: any) {
current.value = e.detail?.current ?? 0
}
function previewPoster(index: number) {
if (!posterList.value.length) return
uni.previewImage({
current: posterList.value[index],
urls: posterList.value,
})
}
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function withTimeout<T>(promise: Promise<T>, ms: number, message: string) {
return Promise.race([
promise,
new Promise<T>((_, reject) => {
setTimeout(() => reject(new Error(message)), ms)
}),
])
}
function getComponentThis() {
return instance?.proxy || instance
}
async function waitCanvasReady() {
await nextTick()
await sleep(150)
}
async function resolveImagePath(url: string) {
if (!url) {
throw new Error('商品图片地址无效')
}
if (
url.startsWith('data:') ||
url.startsWith('wxfile://') ||
url.startsWith('http://tmp/') ||
url.startsWith('https://tmp/')
) {
return url
}
const res = await uni.downloadFile({ url })
if (res.statusCode === 200 && res.tempFilePath) {
return res.tempFilePath
}
throw new Error('商品图片下载失败,请检查图片域名配置')
}
function getImageInfo(src: string): Promise<{ width: number; height: number }> {
return new Promise((resolve, reject) => {
uni.getImageInfo({
src,
success: (res) => resolve({ width: res.width, height: res.height }),
fail: reject,
})
})
}
function calcContainBox(
imgW: number,
imgH: number,
boxX: number,
boxY: number,
boxW: number,
boxH: number
) {
const scale = Math.min(boxW / imgW, boxH / imgH)
const width = imgW * scale
const height = imgH * scale
return {
x: boxX + (boxW - width) / 2,
y: boxY + (boxH - height) / 2,
width,
height,
}
}
async function generatePosters() {
const { skuId, goodsId, distributionId, images } = props
if (!skuId || !goodsId || !distributionId || !images?.length) {
return
}
if (loading.value) {
return
}
// #ifndef MP-WEIXIN
loadError.value = '请在微信小程序中生成海报'
return
// #endif
loading.value = true
loadError.value = ''
posterList.value = []
clearDrawPosterCache()
try {
await withTimeout(runGeneratePosters(images, skuId, goodsId, distributionId), GENERATE_TIMEOUT, '海报生成超时,请重试')
hasGenerated.value = true
} catch (error: any) {
console.error('generate posters failed', error)
loadError.value = error?.message || '海报生成失败,请稍后重试'
clearDrawPosterCache()
} finally {
loading.value = false
}
}
async function runGeneratePosters(
images: string[],
skuId: string,
goodsId: string,
distributionId: string
) {
const posterRes = await getDistributionGoodsPosterContext({ skuId, goodsId, distributionId })
if (!posterRes.data?.success || !posterRes.data?.result?.qrCode) {
throw new Error(posterRes.data?.message || '葵花码生成失败')
}
const qrCode = `data:image/png;base64,${posterRes.data.result.qrCode}`
await waitCanvasReady()
dp = await DrawPoster.build({
selector: CANVAS_SELECTOR,
componentThis: getComponentThis(),
loading: false,
debugging: false,
})
if (!dp?.canvas || !dp?.ctx) {
throw new Error('画布初始化失败,请重试')
}
// #ifdef MP-WEIXIN
dp.canvas.width = st2(POSTER_WIDTH)
dp.canvas.height = st2(POSTER_HEIGHT)
// #endif
const results: string[] = []
for (const image of images) {
const localImage = await resolveImagePath(image)
results.push(await drawPoster(dp, localImage, qrCode))
}
posterList.value = results
}
async function drawPoster(dpInst: any, productImg: string, qrCode: string) {
const goodsName = props.goodsName || '商品推荐'
const goodsDesc = resolveGoodsDesc()
const priceText =
props.price !== null && props.price !== undefined ? unitPrice(props.price, '¥') : ''
await dpInst.draw((ctx: any) => {
ctx.clearRect(st2(0), st2(0), st2(POSTER_WIDTH), st2(POSTER_HEIGHT))
ctx.fillStyle = '#ffffff'
ctx.fillRoundRect(st2(0), st2(0), st2(POSTER_WIDTH), st2(POSTER_HEIGHT), st2(20))
ctx.clip()
})
await dpInst.draw(async (ctx: any) => {
ctx.fillStyle = '#f7f7f7'
ctx.fillRect(st2(0), st2(0), st2(POSTER_WIDTH), st2(IMAGE_HEIGHT))
const { width: imgW, height: imgH } = await getImageInfo(productImg)
const fit = calcContainBox(imgW, imgH, 0, 0, POSTER_WIDTH, IMAGE_HEIGHT)
await ctx.drawImage(
productImg,
st2(fit.x),
st2(fit.y),
st2(fit.width),
st2(fit.height)
)
})
await dpInst.draw(async (ctx: any) => {
ctx.fillStyle = '#ffffff'
ctx.fillRect(st2(0), st2(IMAGE_HEIGHT), st2(POSTER_WIDTH), st2(POSTER_HEIGHT - IMAGE_HEIGHT))
const qrX = POSTER_WIDTH - QR_SIZE - QR_MARGIN_RIGHT
const qrY = POSTER_HEIGHT - QR_SIZE - QR_MARGIN_BOTTOM
ctx.save()
ctx.beginPath()
ctx.arc(
st2(qrX + QR_SIZE / 2),
st2(qrY + QR_SIZE / 2),
st2(QR_SIZE / 2),
0,
Math.PI * 2
)
ctx.clip()
await ctx.drawImage(qrCode, st2(qrX), st2(qrY), st2(QR_SIZE), st2(QR_SIZE))
ctx.restore()
})
await dpInst.draw((ctx: any) => {
const textLeft = 24
const textMaxWidth = POSTER_WIDTH - QR_SIZE - QR_MARGIN_RIGHT - textLeft - 20
const bottomTop = IMAGE_HEIGHT
const bottomBottom = POSTER_HEIGHT - 16
const qrY = POSTER_HEIGHT - QR_SIZE - QR_MARGIN_BOTTOM
const nameLineHeight = 36
const descLineHeight = 28
const priceLineHeight = 36
const textGap = 16
const nameLineCount = Math.min(2, Math.max(1, Math.ceil(goodsName.length / 16)))
const descLineCount = goodsDesc ? Math.min(2, Math.max(1, Math.ceil(goodsDesc.length / 14))) : 0
let textBlockHeight = nameLineCount * nameLineHeight
if (goodsDesc) {
textBlockHeight += textGap + descLineCount * descLineHeight
}
if (priceText) {
textBlockHeight += textGap + priceLineHeight
}
const maxTextHeight = bottomBottom - bottomTop - 12
if (textBlockHeight > maxTextHeight) {
textBlockHeight = maxTextHeight
}
let textY = qrY + (QR_SIZE - textBlockHeight) / 2
textY = Math.max(bottomTop + 12, textY)
if (textY + textBlockHeight > bottomBottom) {
textY = bottomBottom - textBlockHeight
}
ctx.textAlign = 'left'
ctx.textBaseline = 'top'
ctx.fillStyle = '#333333'
ctx.font = `${st2(28)}px PingFang SC`
ctx.fillWarpText({
text: goodsName,
maxWidth: st2(textMaxWidth),
x: st2(textLeft),
y: st2(textY),
layer: 2,
lineHeight: st2(nameLineHeight),
})
textY += nameLineCount * nameLineHeight
if (goodsDesc) {
textY += textGap
ctx.fillStyle = '#999999'
ctx.font = `${st2(22)}px PingFang SC`
ctx.fillWarpText({
text: goodsDesc,
maxWidth: st2(textMaxWidth),
x: st2(textLeft),
y: st2(textY),
layer: 2,
lineHeight: st2(descLineHeight),
})
textY += descLineCount * descLineHeight
}
if (priceText) {
textY += textGap
const priceY = Math.min(textY, bottomBottom - priceLineHeight)
ctx.fillStyle = '#ff3c2a'
ctx.font = `bold ${st2(36)}px PingFang SC`
ctx.fillText(priceText, st2(textLeft), st2(priceY))
}
})
const path = await dpInst.createImagePath()
if (!path || path === '---stop createImagePath---') {
throw new Error('海报图片导出失败')
}
return path
}
function resolveGoodsDesc() {
const desc = (props.goodsDesc || '').trim()
const name = (props.goodsName || '').trim()
if (!desc || desc === name) return ''
return desc.length > 28 ? `${desc.slice(0, 28)}...` : desc
}
function saveCurrentPoster() {
const filePath = posterList.value[current.value]
if (!filePath) return
uni.saveImageToPhotosAlbum({
filePath,
success: () => {
uni.showToast({ title: '已保存到相册', icon: 'none' })
},
fail: (err: any) => {
const errMsg = err?.errMsg || ''
if (errMsg.includes('auth deny') || errMsg.includes('authorize')) {
uni.showModal({
title: '提示',
content: '需要您授权保存图片到相册',
confirmText: '去设置',
success: (modalRes) => {
if (modalRes.confirm) {
uni.openSetting({})
}
},
})
return
}
uni.showToast({ title: '保存失败', icon: 'none' })
},
})
}
</script>
<style lang="scss" scoped>
.poster-panel {
padding: 8rpx 0 48rpx;
}
.poster-hint {
text-align: center;
font-size: 26rpx;
color: #b3b3b3;
margin-bottom: 40rpx;
}
.poster-content {
margin-top: 8rpx;
}
.loading-wrap {
padding: 120rpx 0;
text-align: center;
}
.loading-text {
color: #999;
font-size: 28rpx;
}
.retry-btn,
.save-btn {
display: inline-block;
padding: 12rpx 40rpx;
border-radius: 30rpx;
background: linear-gradient(90deg, #ff9f3f, #ff6b35);
color: #fff;
font-size: 26rpx;
}
.poster-swiper {
height: 1000rpx;
}
.poster-card-wrap {
display: flex;
justify-content: center;
align-items: flex-start;
padding: 0 48rpx;
}
.poster-image {
width: 100%;
max-width: 620rpx;
border-radius: 20rpx;
box-shadow: 0 12rpx 40rpx rgba(0, 0, 0, 0.08);
}
.poster-dots {
display: flex;
justify-content: center;
align-items: center;
gap: 12rpx;
margin-top: 24rpx;
}
.dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
background: #e0e0e0;
&.active {
width: 24rpx;
border-radius: 8rpx;
background: #ff8f3f;
}
}
.save-btn-wrap {
display: flex;
justify-content: center;
margin-top: 28rpx;
}
.canvas-hide {
position: fixed;
left: -9999px;
top: 0;
width: 600px;
height: 840px;
opacity: 0;
pointer-events: none;
}
</style>