feat(distribution): 增强分销功能与界面优化

- 新增银行卡管理页面,支持用户查看与管理银行卡。
- 实现添加、删除及设置默认银行卡功能。
- 优化邀请与加入页面,展示邀请人信息并改进用户提示。
- 更新海报与二维码生成逻辑,提升用户体验。
- 重构多个组件,提高可读性与可维护性。
This commit is contained in:
pikachu1995@126.com
2026-08-11 15:43:21 +08:00
parent 7d3daf2f02
commit 4b1a09cbc3
18 changed files with 2481 additions and 527 deletions

190
utils/distributionBind.js Normal file
View File

@@ -0,0 +1,190 @@
/**
* 分销海报 / 商品分享扫码绑客(统一入口)
*/
import storage from '@/utils/storage.js'
import store from '@/store'
import { getGoodsDistribution } from '@/api/distribution.js'
import { getMpScene } from '@/api/goods.js'
const PENDING_KEY = 'pending_distribution_id'
/** 本次冷启动内已尝试绑定的分销员 ID避免重复请求 */
const launchBoundIds = new Set()
let flushing = false
export function clearPendingDistributionId() {
try {
uni.removeStorageSync(PENDING_KEY)
} catch (e) {
// ignore
}
}
export function setPendingDistributionId(distributionId) {
if (!distributionId) return
try {
uni.setStorageSync(PENDING_KEY, String(distributionId))
} catch (e) {
console.warn('set pending distributionId failed', e)
}
}
export function getPendingDistributionId() {
try {
return uni.getStorageSync(PENDING_KEY) || ''
} catch (e) {
return ''
}
}
export function resetLaunchBindCache() {
launchBoundIds.clear()
}
/**
* 解析葵花码 scene支持 shortLinkId 或明文 bind,{distributionId}
*/
export async function resolveDistributionIdFromScene(scene) {
if (!scene) return ''
const raw = decodeURIComponent(String(scene)).trim()
if (!raw) return ''
if (raw.startsWith('bind,')) {
const id = raw.split(',')[1]
return id ? String(id).trim() : ''
}
try {
const res = await getMpScene(raw)
const params = res?.data?.success ? String(res.data.result || '').trim() : ''
if (params.startsWith('bind,')) {
const id = params.split(',')[1]
return id ? String(id).trim() : ''
}
} catch (error) {
console.warn('resolve distribution scene failed', error)
}
return ''
}
function parseRecruitIdFromParams(params) {
if (!params) return ''
const text = String(params).trim()
if (text.startsWith('recruit_')) {
const id = text.substring('recruit_'.length)
return id ? String(id).trim() : ''
}
return ''
}
/**
* 解析图文邀请卡 scene支持 shortLinkId 或明文 recruit_{distributionId}
*/
export async function resolveRecruitDistributionIdFromScene(scene) {
if (!scene) return ''
const raw = decodeURIComponent(String(scene)).trim()
if (!raw) return ''
const direct = parseRecruitIdFromParams(raw)
if (direct) return direct
try {
const res = await getMpScene(raw)
const params = res?.data?.success ? String(res.data.result || '').trim() : ''
return parseRecruitIdFromParams(params)
} catch (error) {
console.warn('resolve recruit scene failed', error)
}
return ''
}
const CLEAR_PENDING_CODES = new Set([22000, 22001])
function shouldClearPendingOnFailure(res) {
const code = res?.data?.code
if (code != null && CLEAR_PENDING_CODES.has(Number(code))) {
return true
}
const message = String(res?.data?.message || '')
if (message.includes('分销员不存在') || message.includes('分销功能关闭')) {
return true
}
return false
}
/**
* 尝试绑定分销员:已登录直接请求;未登录写入 pending
*/
export async function tryBindDistribution(distributionId, options = {}) {
const id = String(distributionId || '').trim()
if (!id) return
const force = options.force === true
if (!force && launchBoundIds.has(id)) {
return
}
launchBoundIds.add(id)
if (!storage.getAccessToken()) {
setPendingDistributionId(id)
store.state.distributionId = id
return
}
try {
const res = await getGoodsDistribution(id)
if (res?.data?.success) {
clearPendingDistributionId()
store.state.distributionId = id
return
}
if (shouldClearPendingOnFailure(res)) {
clearPendingDistributionId()
}
} catch (error) {
console.warn('bind distribution failed', error)
const res = error?.data ? error : error?.response?.data ? { data: error.response.data } : null
if (res && shouldClearPendingOnFailure(res)) {
clearPendingDistributionId()
}
}
}
/**
* 登录成功后补绑 pending
*/
export async function flushPendingBind() {
if (flushing) return
const pendingId = getPendingDistributionId()
if (!pendingId || !storage.getAccessToken()) {
return
}
flushing = true
try {
launchBoundIds.delete(pendingId)
await tryBindDistribution(pendingId, { force: true })
} finally {
flushing = false
}
}
/**
* App onLaunch有 scene 则解析绑客;无 scene 清除 pending
*/
export async function handleAppLaunchScene(options) {
resetLaunchBindCache()
const scene = options?.query?.scene
if (!scene) {
clearPendingDistributionId()
return
}
const distributionId = await resolveDistributionIdFromScene(scene)
if (!distributionId) {
return
}
await tryBindDistribution(distributionId)
}

View File

@@ -0,0 +1,289 @@
import { nextTick } from 'vue'
import DrawPoster from '@/js_sdk/u-draw-poster'
export const POSTER_WIDTH = 630
export const POSTER_HEIGHT = 1000
const QR_SIZE = 180
const AVATAR_SIZE = 86
const PADDING_H = 24
const MEMBER_LEFT = PADDING_H + 16
const BOTTOM_RATIO = 0.06
const MEMBER_ROW_GAP = 12
const NAME_FONT_SIZE = 30
const SLOGAN_FONT_SIZE = 24
const NAME_LINE_HEIGHT = 38
const SLOGAN_LINE_HEIGHT = 32
// #ifdef MP-WEIXIN
const st2 = (size: number) => size * 2
// #endif
// #ifndef MP-WEIXIN
const st2 = (size: number) => size
// #endif
export interface DistributionPosterOptions {
backgroundImage?: string
memberAvatar?: string
memberName?: string
slogan?: string
qrImage: string
textColor?: string
showMemberInfo?: boolean
}
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
export 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/') ||
url.startsWith('file://')
) {
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 calcCoverBox(imgW: number, imgH: number, boxW: number, boxH: number) {
const scale = Math.max(boxW / imgW, boxH / imgH)
const width = imgW * scale
const height = imgH * scale
return {
x: (boxW - width) / 2,
y: (boxH - height) / 2,
width,
height,
}
}
export function clearDrawPosterCache(selector: string) {
const pages = getCurrentPages()
const page = pages[pages.length - 1] as any
if (!page) return
delete page[`#${selector}__dp`]
delete page[`${selector}__dp`]
}
async function waitCanvasReady() {
await nextTick()
await sleep(150)
}
async function drawCircleImage(ctx: any, src: string, x: number, y: number, size: number) {
const radius = size / 2
const cx = x + radius
const cy = y + radius
ctx.save()
ctx.beginPath()
ctx.arc(st2(cx), st2(cy), st2(radius), 0, Math.PI * 2)
ctx.clip()
await ctx.drawImage(src, st2(x), st2(y), st2(size), st2(size))
ctx.restore()
ctx.save()
ctx.strokeStyle = 'rgba(255, 255, 255, 0.9)'
ctx.lineWidth = st2(2)
ctx.beginPath()
ctx.arc(st2(cx), st2(cy), st2(radius - 1), 0, Math.PI * 2)
ctx.stroke()
ctx.restore()
}
export async function composeDistributionPoster(
selector: string,
componentThis: any,
options: DistributionPosterOptions
): Promise<string> {
const {
backgroundImage = '',
memberAvatar = '',
memberName = '分销员',
slogan = '',
qrImage,
textColor = '#1f2a44',
showMemberInfo = true,
} = options
if (!qrImage) {
throw new Error('葵花码未生成')
}
clearDrawPosterCache(selector)
await waitCanvasReady()
const dp = await DrawPoster.build({
selector,
componentThis,
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 localQr = await resolveImagePath(qrImage)
let localBg = ''
if (backgroundImage) {
try {
localBg = await resolveImagePath(backgroundImage)
} catch (error) {
console.warn('poster background download failed', error)
}
}
let localAvatar = ''
if (showMemberInfo && memberAvatar) {
try {
localAvatar = await resolveImagePath(memberAvatar)
} catch (error) {
console.warn('poster avatar download failed', error)
}
}
const bottomOffset = POSTER_HEIGHT * BOTTOM_RATIO
const rowBottom = POSTER_HEIGHT - bottomOffset
const qrX = POSTER_WIDTH - PADDING_H - QR_SIZE
const qrY = rowBottom - QR_SIZE
await dp.draw((ctx: any) => {
ctx.fillStyle = '#ffffff'
ctx.fillRect(st2(0), st2(0), st2(POSTER_WIDTH), st2(POSTER_HEIGHT))
})
if (localBg) {
await dp.draw(async (ctx: any) => {
const { width: imgW, height: imgH } = await getImageInfo(localBg)
const fit = calcCoverBox(imgW, imgH, POSTER_WIDTH, POSTER_HEIGHT)
await ctx.drawImage(
localBg,
st2(fit.x),
st2(fit.y),
st2(fit.width),
st2(fit.height)
)
})
}
await dp.draw(async (ctx: any) => {
ctx.fillStyle = '#ffffff'
ctx.fillRoundRect(st2(qrX), st2(qrY), st2(QR_SIZE), st2(QR_SIZE), st2(12))
await ctx.drawImage(localQr, st2(qrX), st2(qrY), st2(QR_SIZE), st2(QR_SIZE))
})
if (showMemberInfo) {
await dp.draw(async (ctx: any) => {
const textMaxWidth = qrX - MEMBER_LEFT - 16
const memberBlockHeight =
AVATAR_SIZE +
MEMBER_ROW_GAP +
NAME_LINE_HEIGHT +
(slogan ? MEMBER_ROW_GAP + SLOGAN_LINE_HEIGHT : 0)
const blockHeight = Math.max(memberBlockHeight, QR_SIZE)
const blockTop = rowBottom - blockHeight
const avatarX = MEMBER_LEFT
const avatarY = blockTop
const nameY = avatarY + AVATAR_SIZE + MEMBER_ROW_GAP
const sloganY = nameY + NAME_LINE_HEIGHT + MEMBER_ROW_GAP
if (localAvatar) {
await drawCircleImage(ctx, localAvatar, avatarX, avatarY, AVATAR_SIZE)
} else {
const radius = AVATAR_SIZE / 2
ctx.save()
ctx.fillStyle = 'rgba(255, 255, 255, 0.3)'
ctx.beginPath()
ctx.arc(st2(avatarX + radius), st2(avatarY + radius), st2(radius), 0, Math.PI * 2)
ctx.fill()
ctx.strokeStyle = 'rgba(255, 255, 255, 0.9)'
ctx.lineWidth = st2(2)
ctx.stroke()
ctx.restore()
}
ctx.textAlign = 'left'
ctx.textBaseline = 'top'
ctx.fillStyle = textColor
ctx.font = `bold ${st2(NAME_FONT_SIZE)}px PingFang SC`
ctx.fillWarpText({
text: memberName,
maxWidth: st2(textMaxWidth),
x: st2(avatarX),
y: st2(nameY),
layer: 1,
lineHeight: st2(NAME_LINE_HEIGHT),
})
if (slogan) {
ctx.font = `${st2(SLOGAN_FONT_SIZE)}px PingFang SC`
ctx.fillWarpText({
text: slogan,
maxWidth: st2(textMaxWidth),
x: st2(avatarX),
y: st2(sloganY),
layer: 1,
lineHeight: st2(SLOGAN_LINE_HEIGHT),
})
}
})
}
const path = await dp.createImagePath()
if (!path || path === '---stop createImagePath---') {
throw new Error('海报图片导出失败')
}
return path
}
export function savePosterToAlbum(filePath: string) {
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' })
},
})
}

View File

@@ -9,13 +9,13 @@ const FACE_LOGIN = isDev ? "face_login_dev" : "face_login";
const FINGER_LOGIN = isDev ? "finger_login_dev" : "finger_login";
const CART_BACKBTN = isDev ? "cart_backbtn_dev" : "cart_backbtn";
const AFTERSALE_DATA = isDev ? "aftersale_data_dev" : "aftersale_data";
export default {
setInviter(val){
uni.setStorageSync('inviter', val)
},
getInviter(){
return uni.getStorageSync('inviter');
},
export default {
setInviter(val){
uni.setStorageSync('inviter', val)
},
getInviter(){
return uni.getStorageSync('inviter');
},
//写入自动发券
setAutoCp(val){
@@ -84,6 +84,14 @@ export default {
// 写入登录
setHasLogin(val) {
uni.setStorageSync(HAS_LOGIN, val);
if (val) {
try {
const { flushPendingBind } = require("@/utils/distributionBind.js");
flushPendingBind();
} catch (e) {
// ignore
}
}
},
// 获取是否登录
getHasLogin() {