mirror of
https://gitee.com/beijing_hongye_huicheng/lilishop-uniapp.git
synced 2026-08-06 10:57:25 +08:00
feat(distribution): 实现分销员中心完整功能
- 新增分销业绩统计页面,包含时间范围筛选和自定义日期选择功能 - 重构分销认证页面,改为跳转至招募页面申请分销员身份 - 添加分销商品访问记录功能,支持分享追踪和佣金计算 - 更新分销历史记录页面,优化数据结构和显示字段 - 完全重写分销员首页,包含用户信息、收益统计、等级系统等功能
This commit is contained in:
539
components/distribution-goods-share/poster-panel.vue
Normal file
539
components/distribution-goods-share/poster-panel.vue
Normal file
@@ -0,0 +1,539 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user