mirror of
https://gitee.com/beijing_hongye_huicheng/lilishop-uniapp.git
synced 2026-08-06 10:57:25 +08:00
- 新增分销业绩统计页面,包含时间范围筛选和自定义日期选择功能 - 重构分销认证页面,改为跳转至招募页面申请分销员身份 - 添加分销商品访问记录功能,支持分享追踪和佣金计算 - 更新分销历史记录页面,优化数据结构和显示字段 - 完全重写分销员首页,包含用户信息、收益统计、等级系统等功能
540 lines
13 KiB
Vue
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>
|