mirror of
https://gitee.com/beijing_hongye_huicheng/lilishop-uniapp.git
synced 2026-09-20 20:02:02 +08:00
Merge branch 'feature/order-delivery-fulfillment'
This commit is contained in:
190
utils/distributionBind.js
Normal file
190
utils/distributionBind.js
Normal 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)
|
||||
}
|
||||
289
utils/distributionPosterCanvas.ts
Normal file
289
utils/distributionPosterCanvas.ts
Normal 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' })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -528,6 +528,48 @@ export function serviceStatusList (val) {
|
||||
return statusList[val];
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化商品规格展示(兼容 SKU JSON 与纯文本)
|
||||
*/
|
||||
export function formatGoodsSpecs(specs) {
|
||||
if (specs === null || specs === undefined) {
|
||||
return '默认'
|
||||
}
|
||||
const trimmed = String(specs).trim()
|
||||
if (!trimmed) {
|
||||
return '默认'
|
||||
}
|
||||
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) {
|
||||
return trimmed
|
||||
}
|
||||
try {
|
||||
const data = JSON.parse(trimmed)
|
||||
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
||||
return trimmed
|
||||
}
|
||||
const parts = []
|
||||
Object.keys(data).forEach((key) => {
|
||||
if (key === 'images') {
|
||||
return
|
||||
}
|
||||
const value = data[key]
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
return
|
||||
}
|
||||
const text = String(value).trim()
|
||||
if (text) {
|
||||
parts.push(text)
|
||||
}
|
||||
})
|
||||
return parts.length ? parts.join(' ') : '默认'
|
||||
} catch (e) {
|
||||
return '默认'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 订单状态列表
|
||||
*/
|
||||
|
||||
47
utils/orderDelivery.js
Normal file
47
utils/orderDelivery.js
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* 订单配送展示工具(整单 / 拆单 / 无需配送)
|
||||
*/
|
||||
|
||||
export function shouldLoadDelivery(order) {
|
||||
return (
|
||||
order?.deliveryMethod === 'LOGISTICS' &&
|
||||
order?.allowOperationVO?.showLogistics === true
|
||||
)
|
||||
}
|
||||
|
||||
export function isNoDelivery(packageItem) {
|
||||
return (
|
||||
packageItem?.fulfillmentMode === 'NO_DELIVERY' ||
|
||||
packageItem?.logisticsRequired === false
|
||||
)
|
||||
}
|
||||
|
||||
export function getPackageTitle(packageItem, index) {
|
||||
if (isNoDelivery(packageItem)) return '无需配送'
|
||||
if (packageItem?.fulfillmentMode === 'WHOLE') return '整单发货'
|
||||
if (packageItem?.fulfillmentMode === 'SPLIT') return `包裹 ${index + 1}`
|
||||
return `包裹 ${index + 1}`
|
||||
}
|
||||
|
||||
export function getDeliveryEntryText(packages, orderStatus, legacyTraces) {
|
||||
if (packages?.length) {
|
||||
const allNoDelivery = packages.every(isNoDelivery)
|
||||
if (allNoDelivery) return '查看配送说明'
|
||||
if (orderStatus === 'PARTS_DELIVERED') {
|
||||
return `已发出 ${packages.length} 个包裹,剩余商品待发货`
|
||||
}
|
||||
return `共 ${packages.length} 个配送包裹`
|
||||
}
|
||||
|
||||
const traces = legacyTraces?.traces
|
||||
if (Array.isArray(traces) && traces.length) {
|
||||
const latest = traces[0]
|
||||
return latest?.AcceptStation || '商家已发货,物流信息同步中'
|
||||
}
|
||||
|
||||
return '商家已发货,物流信息同步中'
|
||||
}
|
||||
|
||||
export function getOrderAllowOperation(order, orderDetail) {
|
||||
return order?.allowOperationVO || orderDetail?.allowOperationVO || {}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user