feat(distribution): 实现分销员中心完整功能

- 新增分销业绩统计页面,包含时间范围筛选和自定义日期选择功能
- 重构分销认证页面,改为跳转至招募页面申请分销员身份
- 添加分销商品访问记录功能,支持分享追踪和佣金计算
- 更新分销历史记录页面,优化数据结构和显示字段
- 完全重写分销员首页,包含用户信息、收益统计、等级系统等功能
This commit is contained in:
pikachu1995@126.com
2026-08-05 18:07:27 +08:00
parent 6754a54f1b
commit 7d3daf2f02
34 changed files with 10053 additions and 2530 deletions

215
api/distribution.js Normal file
View File

@@ -0,0 +1,215 @@
/**
* 分销员相关 API
*/
import { http, Method } from '@/utils/request.js'
/**
* 绑定分销员
* @param distributionId 分销员ID
*/
export function getGoodsDistribution(distributionId) {
return http.request({
url: `/distribution/distribution/bindingDistribution/${distributionId}`,
method: Method.GET,
})
}
/**
* 获取当前会员的分销员信息(含待提现、冻结金额等)
*/
export function distribution() {
return http.request({
url: `/distribution/distribution`,
method: Method.GET,
})
}
/** 推广海报上下文(邀请好友锁客) */
export function getPromotionPoster() {
return http.request({
url: `/distribution/distribution/promotion-poster`,
method: Method.GET,
})
}
/** 邀请卡上下文(招募下级分销员) */
export function getInviteCard() {
return http.request({
url: `/distribution/distribution/invite-card`,
method: Method.GET,
})
}
/** 我邀请的下级分销员列表 */
export function getDistributionInvitees(params) {
return http.request({
url: `/distribution/distribution/invite/invitees`,
method: Method.GET,
params,
})
}
/** 分销员业绩统计 */
export function getDistributionPerformance(params) {
return http.request({
url: `/distribution/distribution/performance`,
method: Method.GET,
params,
})
}
/** 我的客户列表 */
export function getDistributionCustomers(params) {
return http.request({
url: `/distribution/distribution/customers`,
method: Method.GET,
params,
})
}
/** 客户详情 */
export function getDistributionCustomerDetail(id) {
return http.request({
url: `/distribution/distribution/customers/${id}`,
method: Method.GET,
})
}
/** 设置/取消客户星标 */
export function updateDistributionCustomerStarred(id, starred) {
return http.request({
url: `/distribution/distribution/customers/${id}/starred`,
method: Method.PUT,
params: { starred },
})
}
/** 分销等级列表(买家端可见时) */
export function getDistributionGrades() {
return http.request({
url: `/distribution/distribution/grades`,
method: Method.GET,
})
}
/** 分销员提现 */
export function cash(params) {
return http.request({
url: `/distribution/cash`,
method: Method.POST,
params,
})
}
/** 分销员提现历史 */
export function cashLog(params) {
return http.request({
url: `/distribution/cash`,
method: Method.GET,
params,
})
}
/** 推广/邀请订单分页列表 */
export function getDistributionOrders(params) {
return http.request({
url: `/distribution/order/orders`,
method: Method.GET,
params,
})
}
/** 推广/邀请订单详情 */
export function getDistributionOrderDetail(params) {
return http.request({
url: `/distribution/order/orders/detail`,
method: Method.GET,
params,
})
}
/** 获取分销商品设置 */
export function getDistributionGoodsSetting() {
return http.request({
url: `/distribution/goods/setting`,
method: Method.GET,
})
}
/** 获取分销员商品列表 */
export function distributionGoods(params) {
return http.request({
url: `/distribution/goods`,
method: Method.GET,
params,
})
}
/** 获取分销员中心推荐商品 */
export function getDistributionFeaturedGoods() {
return http.request({
url: `/distribution/goods/featured`,
method: Method.GET,
})
}
/** 获取分销商品分享上下文(推广图文) */
export function getDistributionGoodsShareContext(params) {
return http.request({
url: `/distribution/goods/share-context`,
method: Method.GET,
params,
})
}
/** 获取分销商品海报上下文(生成葵花码,服务端记录分享) */
export function getDistributionGoodsPosterContext(params) {
return http.request({
url: `/distribution/goods/poster-context`,
method: Method.GET,
params,
})
}
/** 分享商品访问(记录访问并绑定客户关系) */
export function recordDistributionGoodsVisit(params) {
return http.request({
url: `/distribution/distribution/share-visit`,
method: Method.GET,
params,
})
}
/** 招募页信息 */
export function getRecruitPage() {
return http.request({
url: `/distribution/distribution/recruit/page`,
method: Method.GET,
})
}
/** 提交招募申请 */
export function submitRecruitApplication(data) {
return http.request({
url: `/distribution/distribution/recruit/apply`,
method: Method.POST,
data,
})
}
/** 招募条件进度 */
export function getRecruitProgress() {
return http.request({
url: `/distribution/distribution/recruit/progress`,
method: Method.GET,
})
}
/** 上级分销员信息 */
export function getDistributionParent() {
return http.request({
url: `/distribution/distribution/parent`,
method: Method.GET,
})
}

View File

@@ -37,18 +37,6 @@ export function getGoodsRelated(params) {
}); });
} }
/**
* 获取商品分销
* @param distributionId 商品分销ID
*/
export function getGoodsDistribution(distributionId) {
return http.request({
url: `/distribution/distribution/bindingDistribution/${distributionId}`,
method: Method.GET,
});
}
/** /**
* 获取商品列表 * 获取商品列表
* @param params * @param params
@@ -118,85 +106,6 @@ export function getCategoryList(id) {
}); });
} }
/**
* 获取当前会员的分销商信息 可根据分销商信息查询待提现金额以及冻结金额等信息
*/
export function distribution() {
return http.request({
url: `/distribution/distribution`,
method: Method.GET,
});
}
/**
* 申请分销商
*/
export function applyDistribution(params) {
return http.request({
url: `/distribution/distribution`,
method: Method.POST,
params,
});
}
/**
* 分销商提现
*/
export function cash(params) {
return http.request({
url: `/distribution/cash`,
method: Method.POST,
params,
});
}
/**
* 分销商提现历史
*/
export function cashLog(params) {
return http.request({
url: `/distribution/cash`,
method: Method.GET,
params
});
}
/**
* 获取分销商分页订单列表
*/
export function distributionOrderList(params) {
return http.request({
url: `/distribution/distribution/distributionOrder`,
method: Method.GET,
params
});
}
/**
* 获取分销商商品列表
*/
export function distributionGoods(params) {
return http.request({
url: `/distribution/goods`,
method: Method.GET,
params,
});
}
/**
* 选择分销商品 分销商品id
*/
export function checkedDistributionGoods(params) {
return http.request({
url: `/distribution/goods/checked/${params.id}`,
method: Method.GET,
params
});
}
/** /**
* 获取 小程序码 * 获取 小程序码
*/ */

View File

@@ -0,0 +1,459 @@
<template>
<view class="share-panel" :style="themeStyle">
<view class="tabs">
<view
class="tab-item"
:class="{ active: activeTab === 'text' }"
@click="activeTab = 'text'"
>
<text>推广图文</text>
<view v-if="activeTab === 'text'" class="tab-line"></view>
</view>
<view
class="tab-item"
:class="{ active: activeTab === 'poster' }"
@click="activeTab = 'poster'"
>
<text>生成海报</text>
<view v-if="activeTab === 'poster'" class="tab-line"></view>
</view>
</view>
<view v-if="loading" class="loading-wrap">
<text class="loading-text">加载中...</text>
</view>
<view v-else-if="activeTab === 'text'" class="content">
<view class="text-card">
<text v-if="shareContext.defaultShareText" class="share-text" :user-select="true">
{{ shareContext.defaultShareText }}
</text>
<view class="share-divider"></view>
<text class="share-text" :user-select="true">{{ shareBodyText }}</text>
<view class="share-divider"></view>
</view>
<view class="media-section">
<scroll-view scroll-x class="image-scroll" v-if="images.length">
<view class="image-list">
<image
v-for="(img, index) in images"
:key="index"
class="goods-image"
:src="img"
mode="aspectFill"
@click="previewImage(index)"
/>
</view>
</scroll-view>
<view class="save-btn-wrap">
<view class="save-btn" @click="saveShareContent">保存图文</view>
</view>
</view>
</view>
<view v-else class="content">
<poster-panel
:images="images"
:goods-name="shareContext.goodsName"
:goods-desc="posterGoodsDesc"
:price="shareContext.price"
:sku-id="skuId"
:goods-id="goodsId"
:distribution-id="distributionId"
:active="activeTab === 'poster'"
/>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useStore } from '@/store'
import config from '@/config/config'
import { getThemeStyle } from '@/utils/theme'
import { getDistributionGoodsShareContext } from '@/api/distribution'
import { unitPrice, parseGoodsImageUrl } from '@/utils/filters.js'
import PosterPanel from './poster-panel.vue'
// #ifdef H5
import { h5Copy } from '@/js_sdk/h5-copy/h5-copy.js'
// #endif
const props = defineProps<{
skuId?: string
goodsId?: string
distributionId?: string
}>()
const store = useStore()
const themeStyle = computed(() => getThemeStyle(store.state.theme))
const activeTab = ref<'text' | 'poster'>('text')
const loading = ref(true)
const shareContext = ref<Record<string, any>>({})
const posterGoodsDesc = computed(() => {
const ctx = shareContext.value
return ctx.goodsSellingPoint || ctx.defaultShareText || ''
})
const images = computed(() => {
const raw: string[] = []
const list = shareContext.value.images
if (Array.isArray(list) && list.length) {
raw.push(...list)
} else if (shareContext.value.thumbnail) {
raw.push(shareContext.value.thumbnail)
}
const parsed = raw
.map((item) => parseGoodsImageUrl(item))
.filter((url) => !!url)
return [...new Set(parsed)]
})
const shareLink = computed(() => {
const link = shareContext.value.miniProgramLink || shareContext.value.sharePath || ''
if (!link) return ''
if (link.startsWith('http')) return link
const base = (config.shareLink || '').replace(/\/$/, '')
return base + link
})
const SHARE_DIVIDER = '- '.repeat(36).trim()
const shareBodyText = computed(() => {
const ctx = shareContext.value
const lines: string[] = []
if (ctx.goodsName) {
lines.push(`🔴 ${ctx.goodsName}`)
}
if (ctx.price !== null && ctx.price !== undefined) {
lines.push(`【到手价】💰 ${unitPrice(ctx.price, '¥')}`)
}
if (shareLink.value) {
lines.push(`点击立即参与:${shareLink.value}`)
}
return lines.join('\n')
})
const shareText = computed(() => {
const ctx = shareContext.value
const lines: string[] = []
if (ctx.defaultShareText) {
lines.push(ctx.defaultShareText)
lines.push('')
}
lines.push(SHARE_DIVIDER)
if (shareBodyText.value) {
lines.push(shareBodyText.value)
}
lines.push(SHARE_DIVIDER)
return lines.join('\n')
})
watch(
() => [props.skuId, props.goodsId, props.distributionId],
() => {
loadShareContext()
},
{ immediate: true }
)
function loadShareContext() {
const { skuId, goodsId, distributionId } = props
if (!skuId || !goodsId || !distributionId) {
loading.value = false
shareContext.value = {}
return
}
loading.value = true
getDistributionGoodsShareContext({ skuId, goodsId, distributionId })
.then((res) => {
if (res.data?.success) {
shareContext.value = res.data.result || {}
} else {
uni.showToast({ title: res.data?.message || '加载失败', icon: 'none' })
}
})
.catch(() => {
uni.showToast({ title: '加载分享内容失败', icon: 'none' })
})
.finally(() => {
loading.value = false
})
}
function previewImage(index: number) {
if (!images.value.length) return
uni.previewImage({
current: images.value[index],
urls: images.value,
})
}
function isClipboardBlocked(errMsg = '') {
return (
errMsg.includes('privacy agreement') ||
errMsg.includes('no permission') ||
errMsg.includes('auth deny')
)
}
function saveShareContent() {
const text = shareText.value
if (!text.trim()) {
uni.showToast({ title: '暂无可保存内容', icon: 'none' })
return
}
// #ifdef H5
if (h5Copy(text) === false) {
uni.showToast({ title: '复制失败,请重试', icon: 'none' })
return
}
continueAfterCopy()
// #endif
// #ifndef H5
const runCopy = () => {
uni.setClipboardData({
data: text,
showToast: false,
success: () => continueAfterCopy(),
fail: (err: any) => {
console.error('setClipboardData fail', err)
const errMsg = err?.errMsg || ''
if (isClipboardBlocked(errMsg)) {
continueAfterCopy()
return
}
uni.showToast({ title: '复制失败,请重试', icon: 'none' })
},
})
}
// #ifdef MP-WEIXIN
const wxApi = (globalThis as any).wx
if (wxApi?.requirePrivacyAuthorize) {
wxApi.requirePrivacyAuthorize({
success: () => runCopy(),
fail: () => continueAfterCopy(),
})
return
}
// #endif
runCopy()
// #endif
}
function continueAfterCopy() {
const imageUrls = images.value
if (!imageUrls.length) {
uni.showToast({ title: '内容已复制', icon: 'none' })
return
}
uni.showLoading({ title: '保存中', mask: true })
saveImages(imageUrls)
.then(() => {
uni.hideLoading()
uni.showToast({ title: '内容已复制', icon: 'none' })
})
.catch((error) => {
uni.hideLoading()
uni.showToast({ title: '图片保存失败', icon: 'none' })
console.error('save images failed', error)
})
}
function saveToAlbum(filePath: string): Promise<void> {
return new Promise((resolve, reject) => {
uni.saveImageToPhotosAlbum({
filePath,
success: () => resolve(),
fail: (err: any) => {
const errMsg = err?.errMsg || ''
const needAuth =
errMsg.includes('auth deny') ||
errMsg.includes('authorize') ||
errMsg.includes('permission')
// #ifdef MP-WEIXIN
if (!needAuth) {
reject(new Error('save failed'))
return
}
uni.showModal({
title: '提示',
content: '需要您授权保存图片到相册',
confirmText: '去设置',
success: (modalRes) => {
if (!modalRes.confirm) {
reject(new Error('auth denied'))
return
}
uni.openSetting({
success: (settingRes) => {
if (settingRes.authSetting['scope.writePhotosAlbum']) {
saveToAlbum(filePath).then(resolve).catch(reject)
} else {
reject(new Error('auth denied'))
}
},
fail: () => reject(new Error('auth denied')),
})
},
fail: () => reject(new Error('auth denied')),
})
// #endif
// #ifndef MP-WEIXIN
reject(new Error('save failed'))
// #endif
},
})
})
}
function saveImages(urls: string[]) {
return urls.reduce<Promise<void>>((chain, url) => {
return chain.then(() => downloadAndSaveImage(url))
}, Promise.resolve())
}
function downloadAndSaveImage(url: string) {
if (!url || !/^https?:\/\//.test(url)) {
return Promise.reject(new Error('invalid url'))
}
return new Promise<void>((resolve, reject) => {
uni.downloadFile({
url,
success: (res) => {
if (res.statusCode !== 200 || !res.tempFilePath) {
reject(new Error('download failed'))
return
}
saveToAlbum(res.tempFilePath).then(resolve).catch(reject)
},
fail: () => reject(new Error('download failed')),
})
})
}
</script>
<style lang="scss" scoped>
.share-panel {
min-height: 100%;
background: #fff;
}
.tabs {
display: flex;
align-items: center;
justify-content: center;
padding: 24rpx 32rpx 0;
border-bottom: 1rpx solid #f0f0f0;
}
.tab-item {
position: relative;
flex: 1;
text-align: center;
padding-bottom: 20rpx;
font-size: 30rpx;
color: #999;
&.active {
color: #333;
font-weight: 600;
}
}
.tab-line {
position: absolute;
left: 50%;
bottom: 0;
transform: translateX(-50%);
width: 56rpx;
height: 6rpx;
border-radius: 6rpx;
background: #ff8f3f;
}
.loading-wrap {
padding: 120rpx 0;
text-align: center;
}
.loading-text {
color: #999;
font-size: 28rpx;
}
.content {
padding: 24rpx 24rpx 48rpx;
}
.text-card {
background: #f7f7f7;
border-radius: 16rpx;
padding: 28rpx;
}
.share-divider {
width: 100%;
height: 0;
margin: 20rpx 0;
border-top: 2rpx dashed #c8c8c8;
}
.share-text {
font-size: 28rpx;
line-height: 1.7;
color: #333;
white-space: pre-wrap;
word-break: break-all;
user-select: text;
-webkit-user-select: text;
}
.media-section {
margin-top: 24rpx;
}
.image-scroll {
width: 100%;
}
.image-list {
display: flex;
flex-direction: row;
white-space: nowrap;
}
.goods-image {
width: 200rpx;
height: 200rpx;
border-radius: 12rpx;
margin-right: 16rpx;
flex-shrink: 0;
background: #f5f5f5;
}
.save-btn-wrap {
display: flex;
justify-content: flex-end;
margin-top: 24rpx;
}
.save-btn {
min-width: 140rpx;
height: 60rpx;
line-height: 60rpx;
text-align: center;
padding: 0 20rpx;
border-radius: 30rpx;
background: linear-gradient(90deg, #ff9f3f, #ff6b35);
color: #fff;
font-size: 26rpx;
font-weight: 400;
box-shadow: 0 6rpx 16rpx rgba(255, 107, 53, 0.2);
}
</style>

View 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>

View File

@@ -3,13 +3,13 @@
*/ */
// 开发环境 // 开发环境
const dev = { const dev = {
buyer: "https://buyer-api.pickmall.cn", buyer: "http://127.0.0.1:8888",
im: "https://im-api.pickmall.cn", im: "https://im-api.pickmall.cn",
mqtt: "wss://lilishop-mqtt-pull.dllll.xyz/mqtt", mqtt: "wss://lilishop-mqtt-pull.dllll.xyz/mqtt",
}; };
// 生产环境 // 生产环境
const prod = { const prod = {
buyer: "https://buyer-api.pickmall.cn", buyer: "http://127.0.0.1:8888",
im: "https://im-api.pickmall.cn", im: "https://im-api.pickmall.cn",
mqtt: "wss://lilishop-mqtt-pull.dllll.xyz/mqtt", mqtt: "wss://lilishop-mqtt-pull.dllll.xyz/mqtt",
}; };

View File

@@ -1,5 +1,20 @@
var _a; var _a;
export const PLATFORM = typeof process !== 'undefined' ? (_a = process === null || process === void 0 ? void 0 : process.env) === null || _a === void 0 ? void 0 : _a.VUE_APP_PLATFORM : undefined; function detectPlatform() {
if (typeof process !== 'undefined' && (_a = process === null || process === void 0 ? void 0 : process.env) !== null && _a !== void 0 && _a.VUE_APP_PLATFORM) {
return process.env.VUE_APP_PLATFORM;
}
if (typeof wx !== 'undefined' && typeof wx.getSystemInfoSync === 'function') {
return 'mp-weixin';
}
if (typeof uni !== 'undefined' && typeof uni.getSystemInfoSync === 'function') {
const info = uni.getSystemInfoSync();
if (info && info.uniPlatform === 'mp-weixin') {
return 'mp-weixin';
}
}
return undefined;
}
export const PLATFORM = detectPlatform();
/** 全局对象 */ /** 全局对象 */
const _uni = (function () { const _uni = (function () {
if (typeof uni != "undefined") if (typeof uni != "undefined")

View File

@@ -37,7 +37,9 @@ export const handleBuildOpts = (options) => {
} }
const oldSelector = defaultOpts.selector; const oldSelector = defaultOpts.selector;
if (PLATFORM === 'mp-weixin' && defaultOpts.type2d) { if (PLATFORM === 'mp-weixin' && defaultOpts.type2d) {
defaultOpts.selector = '#' + defaultOpts.selector; defaultOpts.selector = oldSelector.startsWith('#')
? oldSelector
: '#' + oldSelector;
} }
if (!PLATFORM) { if (!PLATFORM) {
console.error('注意! draw-poster未开启uni条件编译! 当环境是微信小程序将不会动态切换为type2d模式'); console.error('注意! draw-poster未开启uni条件编译! 当环境是微信小程序将不会动态切换为type2d模式');

View File

@@ -210,6 +210,7 @@
}, },
"plugins" : {}, "plugins" : {},
"libVersion" : "3.5.8", "libVersion" : "3.5.8",
"__usePrivacyCheck__" : true,
"requiredPrivateInfos" : [ "chooseLocation", "getLocation" ] "requiredPrivateInfos" : [ "chooseLocation", "getLocation" ]
}, },
"h5" : { "h5" : {

View File

@@ -136,7 +136,7 @@
{ {
"path": "distribution/list", "path": "distribution/list",
"style": { "style": {
"navigationBarTitleText": "推广分佣", "navigationBarTitleText": "推广商品",
"app-plus": { "app-plus": {
//app页面不显示滚动条 //app页面不显示滚动条
"scrollIndicator": "none" "scrollIndicator": "none"
@@ -181,15 +181,104 @@
{ {
"path": "distribution/achievement", "path": "distribution/achievement",
"style": { "style": {
"navigationBarTitleText": "我的分销业绩" "navigationBarTitleText": "业绩统计",
"navigationBarBackgroundColor": "#ffffff",
"navigationBarTextStyle": "black",
"backgroundColor": "#f5f6f8"
}
},
{
"path": "distribution/grade",
"style": {
"navigationBarTitleText": "分销等级"
} }
}, },
{ {
"path": "distribution/home", "path": "distribution/home",
"style": { "style": {
"navigationBarTitleText": "推广分佣" "navigationStyle": "custom",
"navigationBarTitleText": "分销员中心"
}
},
{
"path": "distribution/invite-list",
"style": {
"navigationBarTitleText": "我的邀请",
"navigationBarBackgroundColor": "#ffffff",
"navigationBarTextStyle": "black",
"backgroundColor": "#f5f6f8"
}
},
{
"path": "distribution/customer-list",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "客户列表",
"navigationBarBackgroundColor": "#ffffff",
"navigationBarTextStyle": "black",
"backgroundColor": "#f5f6f8"
}
},
{
"path": "distribution/invite-friends",
"style": {
"navigationBarTitleText": "邀请卡",
"navigationBarBackgroundColor": "#ffffff",
"navigationBarTextStyle": "black",
"backgroundColor": "#fff7f0"
}
},
{
"path": "distribution/invite",
"style": {
"navigationBarTitleText": "图文邀请卡",
"navigationBarBackgroundColor": "#ffffff",
"navigationBarTextStyle": "black",
"backgroundColor": "#f5f5f5"
}
},
{
"path": "distribution/order-list",
"style": {
"navigationBarTitleText": "推广订单",
"navigationBarBackgroundColor": "#ffffff",
"navigationBarTextStyle": "black",
"backgroundColor": "#f5f6f8"
}
},
{
"path": "distribution/order-detail",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "分销订单详情",
"navigationBarBackgroundColor": "#ffffff",
"navigationBarTextStyle": "black",
"backgroundColor": "#f5f6f8"
}
},
{
"path": "distribution/poster",
"style": {
"navigationBarTitleText": "平台海报",
"navigationBarBackgroundColor": "#ffffff",
"navigationBarTextStyle": "black",
"backgroundColor": "#f5f5f5"
}
},
{
"path": "distribution/bind",
"style": {
"navigationBarTitleText": "加载中",
"navigationStyle": "custom"
}
},
{
"path": "distribution/share",
"style": {
"navigationBarTitleText": "分享给好友",
"navigationBarBackgroundColor": "#ffffff",
"navigationBarTextStyle": "black",
"backgroundColor": "#ffffff"
} }
}, },
{ {

View File

@@ -45,6 +45,7 @@
maxlength="100" maxlength="100"
height="150" height="150"
border="none" border="none"
input-align="right"
placeholder="街道楼牌号等" placeholder="街道楼牌号等"
/> />
</up-form-item> </up-form-item>
@@ -348,9 +349,19 @@ page {
} }
:deep(.u-form-item__body__right__content__slot) { :deep(.u-form-item__body__right__content__slot) {
justify-content: flex-start; justify-content: flex-end;
align-items: flex-start; align-items: flex-start;
} }
:deep(.u-textarea__field),
:deep(.u-input__content__field-wrapper__field) {
text-align: right !important;
}
:deep(.uni-textarea-placeholder),
:deep(.input-placeholder) {
text-align: right;
}
} }
.default-row { .default-row {

View File

@@ -1,10 +1,555 @@
<template> <template>
<view></view> <view class="page" :style="themeStyle">
<view class="filter-bar">
<scroll-view scroll-x class="filter-scroll" :show-scrollbar="false">
<view class="filter-tabs">
<view
v-for="item in rangeTabs"
:key="item.value"
class="filter-tab"
:class="{ active: activeRange === item.value }"
@click="changeRange(item.value)"
>
{{ item.label }}
</view>
</view>
</scroll-view>
<view class="custom-time" :class="{ active: activeRange === 'CUSTOM' }" @click="openCustomPicker">
<text>自定义时间</text>
<u-icon name="arrow-down" size="12" :color="activeRange === 'CUSTOM' ? '#ff8f3f' : '#999'" />
</view>
</view>
<view v-if="loading" class="state-wrap">
<text class="state-text">加载中...</text>
</view>
<view v-else class="content">
<view class="summary-card">
<view class="summary-label-row">
<text class="summary-label">累计收益</text>
<view class="info-icon" @click="showGuide = true">
<u-icon name="info-circle" size="14" color="#999" />
</view>
</view>
<view class="summary-amount">
<text class="amount-value">{{ formatMoney(stats.totalEarnings) }}</text>
<text class="amount-unit"></text>
</view>
<view class="summary-pending">含待结算 {{ formatMoney(stats.pendingEarnings) }}</view>
</view>
<view class="earn-grid">
<view class="earn-item">
<view class="earn-title">已结算收益</view>
<view class="earn-value">{{ formatMoney(stats.settledEarnings) }}<text class="unit"></text></view>
</view>
<view class="earn-item">
<view class="earn-title">商品佣金</view>
<view class="earn-value">{{ formatMoney(stats.directCommission) }}<text class="unit"></text></view>
<view class="earn-sub">含待结算: {{ formatMoney(stats.directPending) }}</view>
</view>
<view class="earn-item">
<view class="earn-title">邀请奖励</view>
<view class="earn-value">{{ formatMoney(stats.inviteReward) }}<text class="unit"></text></view>
<view class="earn-sub">含待结算: {{ formatMoney(stats.invitePending) }}</view>
</view>
</view>
<view class="stats-grid">
<view class="stats-item">
<view class="stats-label">累计销售额()</view>
<view class="stats-value">{{ formatMoney(stats.totalSales) }}</view>
</view>
<view class="stats-item">
<view class="stats-label">累计订单()</view>
<view class="stats-value">{{ stats.totalOrders || 0 }}</view>
</view>
<view class="stats-item">
<view class="stats-label">累计客户()</view>
<view class="stats-value">{{ stats.totalCustomers || 0 }}</view>
</view>
<view class="stats-item">
<view class="stats-label">累计邀请()</view>
<view class="stats-value">{{ stats.totalInvites || 0 }}</view>
</view>
</view>
<view class="guide-link" @click="showGuide = true">查看业绩指标说明</view>
</view>
<u-popup v-model:show="customVisible" mode="bottom" round="16">
<view class="custom-popup">
<view class="popup-title">自定义时间</view>
<view class="popup-row">
<text class="popup-label">开始日期</text>
<picker mode="date" :value="customStartDate" @change="onStartDateChange">
<view class="picker-value">{{ customStartDate || '请选择' }}</view>
</picker>
</view>
<view class="popup-row">
<text class="popup-label">结束日期</text>
<picker mode="date" :value="customEndDate" @change="onEndDateChange">
<view class="picker-value">{{ customEndDate || '请选择' }}</view>
</picker>
</view>
<view class="popup-actions">
<view class="popup-btn ghost" @click="customVisible = false">取消</view>
<view class="popup-btn primary" @click="applyCustomRange">确定</view>
</view>
</view>
</u-popup>
<u-popup v-model:show="showGuide" mode="center" round="16" :safe-area-inset-bottom="false">
<view class="guide-popup">
<view class="guide-title">业绩指标说明</view>
<view class="guide-item">
<view class="guide-term">销售额</view>
<view class="guide-desc">仅统计推广订单的销售金额总和</view>
</view>
<view class="guide-item">
<view class="guide-term">客户</view>
<view class="guide-desc">与分销员建立绑定关系的买家</view>
</view>
<view class="guide-item">
<view class="guide-term">邀请</view>
<view class="guide-desc">分销员成功邀请的下级分销员</view>
</view>
<view class="guide-item">
<view class="guide-term">商品佣金</view>
<view class="guide-desc">分销员推荐客户购买商品后获得的佣金</view>
</view>
<view class="guide-item">
<view class="guide-term">邀请奖励</view>
<view class="guide-desc">下级分销员推广商品后上级获得的奖励</view>
</view>
<view class="guide-btn" @click="showGuide = false">知道了</view>
</view>
</u-popup>
</view>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
// 占位页,暂无业务逻辑 import { ref, computed } from 'vue'
import { onShow, onLoad } from '@dcloudio/uni-app'
import { useStore } from '@/store'
import { getThemeStyle } from '@/utils/theme'
import { getDistributionPerformance } from '@/api/distribution'
const store = useStore()
const themeStyle = computed(() => getThemeStyle(store.state.theme))
const rangeTabs = [
{ label: '全部', value: 'ALL' },
{ label: '今日', value: 'TODAY' },
{ label: '昨日', value: 'YESTERDAY' },
{ label: '近七日', value: 'LAST_7_DAYS' },
]
const activeRange = ref('ALL')
const loading = ref(false)
const customVisible = ref(false)
const showGuide = ref(false)
const customStartDate = ref('')
const customEndDate = ref('')
const stats = ref({
totalEarnings: 0,
pendingEarnings: 0,
settledEarnings: 0,
directCommission: 0,
directPending: 0,
inviteReward: 0,
invitePending: 0,
totalSales: 0,
totalOrders: 0,
totalCustomers: 0,
totalInvites: 0,
})
const RANGE_VALUES = ['ALL', 'TODAY', 'YESTERDAY', 'LAST_7_DAYS', 'CUSTOM']
onLoad((options: Record<string, string>) => {
if (options?.rangeType && RANGE_VALUES.includes(options.rangeType)) {
activeRange.value = options.rangeType
}
})
onShow(() => {
loadPerformance()
})
function formatMoney(val: number | string) {
return Number(val || 0).toFixed(2)
}
function buildParams() {
const params: Record<string, string> = { rangeType: activeRange.value }
if (activeRange.value === 'CUSTOM') {
if (customStartDate.value) {
params.startTime = `${customStartDate.value} 00:00:00`
}
if (customEndDate.value) {
params.endTime = `${customEndDate.value} 23:59:59`
}
}
return params
}
function loadPerformance() {
loading.value = true
getDistributionPerformance(buildParams())
.then((res) => {
const data = res.data?.result || {}
stats.value = {
totalEarnings: Number(data.totalEarnings || 0),
pendingEarnings: Number(data.pendingEarnings || 0),
settledEarnings: Number(data.settledEarnings || 0),
directCommission: Number(data.directCommission || 0),
directPending: Number(data.directPending || 0),
inviteReward: Number(data.inviteReward || 0),
invitePending: Number(data.invitePending || 0),
totalSales: Number(data.totalSales || 0),
totalOrders: Number(data.totalOrders || 0),
totalCustomers: Number(data.totalCustomers || 0),
totalInvites: Number(data.totalInvites || 0),
}
})
.finally(() => {
loading.value = false
})
}
function changeRange(value: string) {
if (activeRange.value === value) return
activeRange.value = value
loadPerformance()
}
function openCustomPicker() {
customVisible.value = true
}
function onStartDateChange(e: { detail: { value: string } }) {
customStartDate.value = e.detail.value
}
function onEndDateChange(e: { detail: { value: string } }) {
customEndDate.value = e.detail.value
}
function applyCustomRange() {
if (!customStartDate.value || !customEndDate.value) {
uni.showToast({ title: '请选择开始和结束日期', icon: 'none' })
return
}
activeRange.value = 'CUSTOM'
customVisible.value = false
loadPerformance()
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.page {
min-height: 100vh;
background: #f5f6f8;
}
.filter-bar {
display: flex;
align-items: center;
padding: 20rpx 24rpx;
background: #fff;
border-bottom: 1rpx solid #f0f0f0;
}
.filter-scroll {
flex: 1;
white-space: nowrap;
}
.filter-tabs {
display: inline-flex;
align-items: center;
gap: 12rpx;
}
.filter-tab {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 88rpx;
height: 56rpx;
padding: 0 20rpx;
border-radius: 28rpx;
font-size: 26rpx;
color: #666;
background: #f5f6f8;
}
.filter-tab.active {
color: #ff8f3f;
background: #fff3ea;
font-weight: 600;
}
.custom-time {
display: flex;
align-items: center;
gap: 6rpx;
margin-left: 16rpx;
font-size: 24rpx;
color: #666;
white-space: nowrap;
}
.custom-time.active {
color: #ff8f3f;
font-weight: 600;
}
.state-wrap {
padding: 120rpx 0;
text-align: center;
}
.state-text {
font-size: 28rpx;
color: #999;
}
.content {
padding: 24rpx;
}
.summary-card,
.earn-grid,
.stats-grid {
background: #fff;
border-radius: 16rpx;
}
.summary-card {
padding: 32rpx 28rpx 28rpx;
margin-bottom: 20rpx;
}
.summary-label-row {
display: flex;
align-items: center;
gap: 8rpx;
}
.summary-label {
font-size: 28rpx;
color: #666;
}
.info-icon {
display: flex;
align-items: center;
}
.summary-amount {
display: flex;
align-items: baseline;
margin-top: 16rpx;
}
.amount-value {
font-size: 64rpx;
font-weight: 700;
color: #222;
line-height: 1;
}
.amount-unit {
margin-left: 8rpx;
font-size: 28rpx;
color: #222;
}
.summary-pending {
margin-top: 12rpx;
font-size: 24rpx;
color: #999;
}
.earn-grid {
display: flex;
padding: 28rpx 0;
margin-bottom: 20rpx;
}
.earn-item {
flex: 1;
padding: 0 20rpx;
text-align: center;
border-right: 1rpx solid #f0f0f0;
}
.earn-item:last-child {
border-right: none;
}
.earn-title {
font-size: 24rpx;
color: #666;
}
.earn-value {
margin-top: 12rpx;
font-size: 34rpx;
font-weight: 700;
color: #222;
}
.earn-value .unit {
font-size: 22rpx;
font-weight: 400;
}
.earn-sub {
margin-top: 8rpx;
font-size: 20rpx;
color: #999;
line-height: 1.4;
}
.stats-grid {
display: grid;
grid-template-columns: 1fr 1fr;
}
.stats-item {
padding: 32rpx 28rpx;
border-right: 1rpx solid #f0f0f0;
border-bottom: 1rpx solid #f0f0f0;
}
.stats-item:nth-child(2n) {
border-right: none;
}
.stats-item:nth-last-child(-n + 2) {
border-bottom: none;
}
.stats-label {
font-size: 24rpx;
color: #666;
}
.stats-value {
margin-top: 16rpx;
font-size: 40rpx;
font-weight: 700;
color: #222;
}
.guide-link {
margin-top: 40rpx;
text-align: center;
font-size: 28rpx;
color: #3b82f6;
}
.custom-popup {
padding: 32rpx 32rpx calc(32rpx + env(safe-area-inset-bottom));
}
.popup-title {
font-size: 32rpx;
font-weight: 600;
text-align: center;
margin-bottom: 24rpx;
}
.popup-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx 0;
border-bottom: 1rpx solid #f0f0f0;
}
.popup-label {
font-size: 28rpx;
color: #666;
}
.picker-value {
font-size: 28rpx;
color: #222;
}
.popup-actions {
display: flex;
gap: 20rpx;
margin-top: 32rpx;
}
.popup-btn {
flex: 1;
height: 80rpx;
border-radius: 40rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 28rpx;
}
.popup-btn.ghost {
background: #f5f6f8;
color: #666;
}
.popup-btn.primary {
background: linear-gradient(135deg, #ff9f43, #ff7f27);
color: #fff;
}
.guide-popup {
width: 620rpx;
padding: 40rpx 36rpx 32rpx;
box-sizing: border-box;
}
.guide-title {
font-size: 34rpx;
font-weight: 700;
text-align: center;
color: #222;
margin-bottom: 28rpx;
}
.guide-item {
margin-bottom: 24rpx;
}
.guide-term {
font-size: 28rpx;
font-weight: 600;
color: #222;
margin-bottom: 8rpx;
}
.guide-desc {
font-size: 26rpx;
color: #666;
line-height: 1.6;
}
.guide-btn {
margin-top: 12rpx;
height: 84rpx;
border-radius: 42rpx;
background: linear-gradient(135deg, #ff9f43, #ff7f27);
color: #fff;
font-size: 30rpx;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
}
</style> </style>

View File

@@ -1,99 +1,24 @@
<template> <template>
<view class="wrapper"> <view class="wrapper">
<view>
<h4>实名认证请上传真实的个人信息认证通过后将无法修改</h4>
<view>
<up-form
:model="formData"
label-position="left"
label-width="180rpx"
ref="uFormRef"
>
<up-form-item label="姓名" prop="name">
<u-input v-model="formData.name" placeholder="请输入您的真实姓名" />
</up-form-item>
<up-form-item label="身份证" prop="idNumber">
<u-input v-model="formData.idNumber" placeholder="请输入身份证号码" />
</up-form-item>
<up-form-item label="银行开户行" prop="settlementBankBranchName">
<u-input v-model="formData.settlementBankBranchName" placeholder="请输入银行开户行" />
</up-form-item>
<up-form-item label="银行开户名" prop="settlementBankAccountName">
<u-input v-model="formData.settlementBankAccountName" placeholder="请输入银行开户名" />
</up-form-item>
<up-form-item label="银行账号" prop="settlementBankAccountNum">
<u-input v-model="formData.settlementBankAccountNum" placeholder="请输入银行账号" />
</up-form-item>
</up-form>
<u-button :customStyle="{ background: lightColor, color: '#fff' }" @click="submitForm">提交</u-button>
</view>
</view>
<view class="tips"> <view class="tips">
<view>您提交的信息正在审核</view> <view>分销员申请已改为招募流程</view>
<view>提交认证申请后工作人员将在三个工作日进行核对完成审核</view> <view>请前往招募页面提交申请</view>
</view> </view>
<u-button :customStyle="{ background: lightColor, color: '#fff', marginTop: '40rpx' }" @click="goJoin">
前往招募页
</u-button>
</view> </view>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { reactive, computed, ref, getCurrentInstance } from 'vue' import { computed } from 'vue'
import { onReady } from '@dcloudio/uni-app'
import { useStore } from '@/store' import { useStore } from '@/store'
import { applyDistribution } from '@/api/goods'
const store = useStore() const store = useStore()
const { proxy } = getCurrentInstance()!
const lightColor = computed(() => store.getters.lightColor) const lightColor = computed(() => store.getters.lightColor)
const uFormRef = ref<any>(null)
const formData = reactive({ function goJoin() {
name: '', uni.redirectTo({ url: '/pages/mine/distribution/join' })
idNumber: '',
settlementBankBranchName: '',
settlementBankAccountName: '',
settlementBankAccountNum: '',
})
const rules = {
name: [
{ required: true, message: '请输入姓名', trigger: 'blur' },
{
validator: (_rule: unknown, value: string) => proxy.$u.test.chinese(value),
message: '姓名输入不正确',
trigger: ['change', 'blur'],
},
],
settlementBankBranchName: [{ required: true, message: '请输入银行开户行', trigger: 'blur' }],
settlementBankAccountName: [{ required: true, message: '银行开户名', trigger: 'blur' }],
settlementBankAccountNum: [{ required: true, message: '请输入银行账号', trigger: 'blur' }],
idNumber: [
{ required: true, message: '请输入身份证', trigger: 'blur' },
{
validator: (_rule: unknown, value: string) => proxy.$u.test.idCard(value),
message: '身份证号码不正确',
trigger: ['change', 'blur'],
},
],
}
onReady(() => {
uFormRef.value?.setRules(rules)
})
function submitForm() {
uFormRef.value?.validate().then(() => {
applyDistribution(formData).then((res) => {
if (res.data.success) {
uni.showToast({ title: '认证提交成功!', duration: 2000, icon: 'none' })
setTimeout(() => uni.navigateBack(), 500)
} else {
uni.showToast({ title: res.data.message, duration: 2000, icon: 'none' })
}
})
}).catch(() => {
uni.showToast({ title: '请填写有效信息', duration: 2000, icon: 'none' })
})
} }
</script> </script>
@@ -102,8 +27,8 @@ function submitForm() {
padding: 32rpx; padding: 32rpx;
} }
.tips { .tips {
margin-top: 20rpx; font-size: 28rpx;
font-size: 24rpx; color: #666;
color: #999; line-height: 1.6;
} }
</style> </style>

View File

@@ -0,0 +1,44 @@
<template>
<view class="bind-page"></view>
</template>
<script setup lang="ts">
import { onLoad } from '@dcloudio/uni-app'
import { useStore } from '@/store'
import storage from '@/utils/storage'
import { getGoodsDistribution } from '@/api/distribution'
const store = useStore()
onLoad(async (options) => {
let distributionId = (options?.distributionId as string) || ''
if (options?.scene) {
const scene = decodeURIComponent(String(options.scene))
const parts = scene.split(',')
if (parts[0] === 'bind' && parts[1]) {
distributionId = parts[1]
}
}
if (distributionId) {
store.state.distributionId = distributionId
if (storage.getAccessToken()) {
try {
await getGoodsDistribution(distributionId)
} catch (error) {
console.warn('bind distribution failed', error)
}
}
}
uni.switchTab({ url: '/pages/tabbar/home/index' })
})
</script>
<style lang="scss" scoped>
.bind-page {
min-height: 100vh;
background: #fff;
}
</style>

View File

@@ -0,0 +1,833 @@
<template>
<view class="page" :style="themeStyle">
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
<view class="nav-bar">
<view class="nav-back" @click="goBack">
<u-icon name="arrow-left" color="#333" size="20" />
</view>
<view v-if="detailId" class="nav-title">客户详情</view>
<view v-else class="search-box">
<u-icon name="search" color="#bbb" size="16" />
<input
class="search-input"
v-model="keyword"
confirm-type="search"
placeholder="请输入手机号或有效客户昵称搜索"
placeholder-class="search-placeholder"
@confirm="onSearch"
/>
</view>
</view>
<template v-if="detailId">
<view v-if="detailLoading" class="state-wrap">
<text class="state-text">加载中...</text>
</view>
<view v-else-if="!detail.id" class="state-wrap">
<text class="state-text">客户不存在</text>
</view>
<view v-else class="detail-wrap">
<view class="detail-card">
<view class="profile-row">
<image class="detail-avatar" :src="resolveAvatar(detail.memberAvatar)" mode="aspectFill" />
<view class="profile-info">
<view class="detail-name-row">
<text class="detail-name">{{ detail.memberNickname || '客户' }}</text>
<text class="star-icon" :class="{ active: detail.starred }"></text>
</view>
<view class="star-action" @click="toggleStar">
{{ detail.starred ? '取消星标' : '设为星标' }}
</view>
</view>
</view>
<view class="stats-grid">
<view class="stats-item">
<view class="stats-value">{{ detail.orderCount || 0 }}</view>
<view class="stats-label">订单数量</view>
</view>
<view class="stats-item">
<view class="stats-value">{{ formatMoney(detail.tradeAmount) }}</view>
<view class="stats-label">成交金额</view>
</view>
<view class="stats-item">
<view class="stats-value">{{ formatMoney(detail.averageOrderAmount) }}</view>
<view class="stats-label">客单价</view>
</view>
<view class="stats-item">
<view class="stats-value stats-time">{{ formatTime(detail.lastTradeTime) }}</view>
<view class="stats-label">最后成交时间</view>
</view>
</view>
</view>
</view>
</template>
<template v-else>
<view class="main-tabs">
<view
v-for="item in mainTabs"
:key="item.value"
class="main-tab"
:class="{ active: activeFilter === item.value }"
@click="changeFilter(item.value)"
>
<text>{{ item.label }}</text>
<view v-if="activeFilter === item.value" class="tab-line"></view>
</view>
</view>
<view class="filter-bar">
<scroll-view scroll-x class="filter-scroll" :show-scrollbar="false">
<view class="filter-tabs">
<view
v-for="item in rangeTabs"
:key="item.value"
class="filter-tab"
:class="{ active: activeRange === item.value }"
@click="changeRange(item.value)"
>
{{ item.label }}
</view>
<view
class="filter-tab custom-time"
:class="{ active: activeRange === 'CUSTOM' }"
@click="openCustomPicker"
>
<text>自定义时间</text>
<u-icon name="arrow-down" size="12" :color="activeRange === 'CUSTOM' ? '#ff8f3f' : '#999'" />
</view>
</view>
</scroll-view>
</view>
<view class="summary-row">
<text>{{ customerTotal }}个客户</text>
</view>
<view v-if="loading && !customerList.length" class="state-wrap">
<text class="state-text">加载中...</text>
</view>
<view v-else-if="!customerList.length" class="state-wrap">
<text class="state-text">暂无客户</text>
</view>
<view v-else class="list-wrap">
<view class="customer-card" v-for="item in customerList" :key="item.id" @click="openDetail(item)">
<image class="avatar" :src="resolveAvatar(item.memberAvatar)" mode="aspectFill" />
<view class="info">
<view class="name-row">
<text class="name">{{ item.memberNickname || '客户' }}</text>
<u-icon
v-if="item.starred"
name="star-fill"
color="#ff8f3f"
size="16"
/>
</view>
<view class="meta-line phone-status-row">
<view class="phone-wrap">
<text class="meta-label">手机号</text>
<text class="meta-value">{{ item.memberMobile || '-' }}</text>
</view>
<view class="status-wrap">
<text class="meta-label">状态</text>
<text class="status-text" :class="{ effective: item.relationEffective }">
{{ item.relationStatusText || '-' }}
</text>
</view>
</view>
<view class="bind-time">绑定时间{{ formatTime(item.bindTime) }}</view>
</view>
</view>
</view>
<view v-if="customerList.length" class="list-footer">
<text v-if="finished" class="footer-text">没有更多数据了</text>
<text v-else-if="loadingMore" class="footer-text">加载中...</text>
</view>
</template>
<u-popup v-model:show="customVisible" mode="bottom" round="16">
<view class="custom-popup">
<view class="popup-title">自定义时间</view>
<view class="popup-row">
<text class="popup-label">开始日期</text>
<picker mode="date" :value="customStartDate" @change="onStartDateChange">
<view class="picker-value">{{ customStartDate || '请选择' }}</view>
</picker>
</view>
<view class="popup-row">
<text class="popup-label">结束日期</text>
<picker mode="date" :value="customEndDate" @change="onEndDateChange">
<view class="picker-value">{{ customEndDate || '请选择' }}</view>
</picker>
</view>
<view class="popup-actions">
<view class="popup-btn ghost" @click="customVisible = false">取消</view>
<view class="popup-btn primary" @click="applyCustomRange">确定</view>
</view>
</view>
</u-popup>
</view>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { onReachBottom, onShow, onLoad } from '@dcloudio/uni-app'
import { useStore } from '@/store'
import config from '@/config/config'
import { getThemeStyle } from '@/utils/theme'
import {
getDistributionCustomers,
getDistributionCustomerDetail,
updateDistributionCustomerStarred,
} from '@/api/distribution'
import { parseGoodsImageUrl } from '@/utils/filters.js'
const store = useStore()
const themeStyle = computed(() => getThemeStyle(store.state.theme))
const defaultAvatar = config.defaultUserPhoto
const statusBarHeight = ref(20)
const mainTabs = [
{ label: '全部客户', value: 'ALL' },
{ label: '星标客户', value: 'STARRED' },
{ label: '下单客户', value: 'ORDERED' },
{ label: '即将失效客户', value: 'EXPIRING_SOON' },
]
const rangeTabs = [
{ label: '全部', value: 'ALL' },
{ label: '今日', value: 'TODAY' },
{ label: '昨日', value: 'YESTERDAY' },
{ label: '近七日', value: 'LAST_7_DAYS' },
]
const activeFilter = ref('ALL')
const activeRange = ref('ALL')
const keyword = ref('')
const loading = ref(false)
const loadingMore = ref(false)
const finished = ref(false)
const customerTotal = ref(0)
const customerList = ref<any[]>([])
const pageNumber = ref(1)
const pageSize = 10
const detailId = ref('')
const detailLoading = ref(false)
const detail = ref<Record<string, any>>({})
const customVisible = ref(false)
const customStartDate = ref('')
const customEndDate = ref('')
const RANGE_VALUES = ['ALL', 'TODAY', 'YESTERDAY', 'LAST_7_DAYS', 'CUSTOM']
function applyRouteOptions(options: Record<string, string | undefined> = {}) {
const rangeType = options.rangeType
if (rangeType && RANGE_VALUES.includes(rangeType)) {
activeRange.value = rangeType
return
}
activeRange.value = 'ALL'
}
onLoad((options) => {
applyRouteOptions(options as Record<string, string>)
})
onShow(() => {
statusBarHeight.value = uni.getWindowInfo?.()?.statusBarHeight || 20
const pages = getCurrentPages()
const current = pages[pages.length - 1] as { options?: Record<string, string> }
if (current?.options) {
applyRouteOptions(current.options)
}
if (!detailId.value) {
resetAndLoad()
}
})
onReachBottom(() => {
if (!detailId.value) {
loadCustomers(false)
}
})
function buildQueryParams() {
const params: Record<string, any> = {
pageNumber: pageNumber.value,
pageSize,
filterType: activeFilter.value,
rangeType: activeRange.value,
}
const searchKeyword = keyword.value.trim()
if (searchKeyword) {
params.keyword = searchKeyword
}
if (activeRange.value === 'CUSTOM') {
if (customStartDate.value) {
params.startTime = `${customStartDate.value} 00:00:00`
}
if (customEndDate.value) {
params.endTime = `${customEndDate.value} 23:59:59`
}
}
return params
}
function resetAndLoad() {
pageNumber.value = 1
finished.value = false
customerList.value = []
loadCustomers(true)
}
function loadCustomers(reset = false) {
if (reset) {
loading.value = true
pageNumber.value = 1
finished.value = false
} else if (loading.value || loadingMore.value || finished.value) {
return
} else {
loadingMore.value = true
}
getDistributionCustomers(buildQueryParams())
.then((res) => {
const result = res.data?.result || {}
const records = result.records || []
customerTotal.value = Number(result.total || 0)
if (reset) {
customerList.value = records
} else {
customerList.value = customerList.value.concat(records)
}
if (records.length < pageSize || customerList.value.length >= customerTotal.value) {
finished.value = true
} else {
pageNumber.value += 1
}
})
.finally(() => {
loading.value = false
loadingMore.value = false
})
}
function loadDetail() {
if (!detailId.value) return
detailLoading.value = true
getDistributionCustomerDetail(detailId.value)
.then((res) => {
detail.value = res.data?.result || {}
})
.catch(() => {
detail.value = {}
})
.finally(() => {
detailLoading.value = false
})
}
function openDetail(item: any) {
if (!item?.id) return
detailId.value = item.id
detail.value = {}
loadDetail()
}
function closeDetail() {
const currentId = detailId.value
const starred = detail.value?.starred
detailId.value = ''
detail.value = {}
if (currentId) {
const target = customerList.value.find((item) => item.id === currentId)
if (target) {
target.starred = starred
}
}
}
function toggleStar() {
if (!detail.value?.id) return
const next = !detail.value.starred
updateDistributionCustomerStarred(detail.value.id, next).then(() => {
detail.value.starred = next
const target = customerList.value.find((item) => item.id === detail.value.id)
if (target) {
target.starred = next
}
uni.showToast({
title: next ? '已设为星标' : '已取消星标',
icon: 'none',
})
})
}
function changeFilter(value: string) {
if (activeFilter.value === value) return
activeFilter.value = value
resetAndLoad()
}
function changeRange(value: string) {
if (activeRange.value === value) return
activeRange.value = value
resetAndLoad()
}
function onSearch() {
resetAndLoad()
}
function openCustomPicker() {
customVisible.value = true
}
function onStartDateChange(event: any) {
customStartDate.value = event.detail.value
}
function onEndDateChange(event: any) {
customEndDate.value = event.detail.value
}
function applyCustomRange() {
if (!customStartDate.value || !customEndDate.value) {
uni.showToast({ title: '请选择开始和结束日期', icon: 'none' })
return
}
if (customStartDate.value > customEndDate.value) {
uni.showToast({ title: '开始日期不能晚于结束日期', icon: 'none' })
return
}
activeRange.value = 'CUSTOM'
customVisible.value = false
resetAndLoad()
}
function resolveAvatar(avatar?: string) {
return parseGoodsImageUrl(avatar) || defaultAvatar
}
function formatMoney(val: number | string) {
return Number(val || 0).toFixed(2)
}
function formatTime(value?: string) {
if (!value) return '-'
return String(value).replace('T', ' ').slice(0, 19)
}
function goBack() {
if (detailId.value) {
closeDetail()
return
}
if (getCurrentPages().length > 1) {
uni.navigateBack({ delta: 1 })
} else {
uni.navigateTo({ url: '/pages/mine/distribution/home' })
}
}
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background: #f5f6f8;
}
.status-bar,
.nav-bar {
background: #fff;
}
.nav-bar {
display: flex;
align-items: center;
padding: 12rpx 24rpx 16rpx;
gap: 16rpx;
}
.nav-back {
width: 56rpx;
height: 56rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.nav-title {
flex: 1;
text-align: center;
margin-right: 56rpx;
color: #222;
font-size: 32rpx;
font-weight: 600;
}
.search-box {
flex: 1;
height: 64rpx;
border-radius: 999rpx;
background: #f5f5f5;
display: flex;
align-items: center;
padding: 0 24rpx;
gap: 12rpx;
}
.search-input {
flex: 1;
height: 64rpx;
font-size: 26rpx;
color: #333;
}
.search-placeholder {
color: #bbb;
font-size: 26rpx;
}
.main-tabs {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8rpx 12rpx 12rpx;
background: #fff;
border-bottom: 1rpx solid #f0f0f0;
}
.main-tab {
position: relative;
flex: 1;
text-align: center;
padding: 20rpx 0 24rpx;
color: #666;
font-size: 28rpx;
}
.main-tab.active {
color: #ff8f3f;
font-weight: 600;
}
.tab-line {
position: absolute;
left: 50%;
bottom: 8rpx;
width: 48rpx;
height: 6rpx;
margin-left: -24rpx;
border-radius: 999rpx;
background: #ff8f3f;
}
.filter-bar {
background: #fff;
padding: 28rpx 0 16rpx;
}
.filter-scroll {
white-space: nowrap;
width: 100%;
}
.filter-tabs {
display: inline-flex;
align-items: center;
padding: 0 24rpx;
gap: 28rpx;
}
.filter-tab {
color: #666;
font-size: 26rpx;
flex-shrink: 0;
}
.filter-tab.active {
color: #ff8f3f;
font-weight: 600;
}
.custom-time {
display: inline-flex;
align-items: center;
gap: 4rpx;
}
.summary-row {
padding: 20rpx 24rpx;
color: #999;
font-size: 24rpx;
}
.list-wrap,
.detail-wrap {
padding: 0 24rpx 24rpx;
}
.customer-card,
.detail-card {
border-radius: 16rpx;
background: #fff;
}
.customer-card {
display: flex;
align-items: flex-start;
margin-bottom: 20rpx;
padding: 28rpx 24rpx;
}
.detail-card {
padding: 32rpx 28rpx 24rpx;
}
.avatar,
.detail-avatar {
border-radius: 50%;
background: #f2f2f2;
flex-shrink: 0;
}
.avatar {
width: 96rpx;
height: 96rpx;
}
.detail-avatar {
width: 104rpx;
height: 104rpx;
}
.info,
.profile-info {
flex: 1;
min-width: 0;
margin-left: 20rpx;
}
.profile-info {
padding-top: 4rpx;
}
.name-row,
.detail-name-row {
display: flex;
align-items: center;
gap: 10rpx;
}
.name,
.detail-name {
color: #222;
font-weight: 600;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.name {
max-width: 420rpx;
font-size: 32rpx;
}
.detail-name {
max-width: 420rpx;
margin-right: 10rpx;
font-size: 34rpx;
}
.star-icon {
color: #ccc;
font-size: 30rpx;
line-height: 1;
}
.star-icon.active {
color: #ff8f3f;
}
.star-action {
display: inline-flex;
margin-top: 16rpx;
padding: 8rpx 22rpx;
border: 1rpx solid #ddd;
border-radius: 999rpx;
color: #666;
font-size: 24rpx;
line-height: 1.2;
}
.profile-row {
display: flex;
align-items: flex-start;
padding-bottom: 32rpx;
}
.stats-grid {
display: flex;
flex-wrap: wrap;
border-top: 1rpx solid #f2f2f2;
padding-top: 8rpx;
}
.stats-item {
width: 50%;
box-sizing: border-box;
padding: 28rpx 8rpx 20rpx;
}
.stats-value {
color: #222;
font-size: 34rpx;
font-weight: 700;
line-height: 1.3;
word-break: break-all;
}
.stats-time {
font-size: 28rpx;
font-weight: 600;
}
.stats-label {
margin-top: 10rpx;
color: #999;
font-size: 24rpx;
line-height: 1.3;
}
.meta-line {
margin-top: 10rpx;
font-size: 26rpx;
line-height: 1.4;
}
.phone-status-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
}
.phone-wrap,
.status-wrap {
display: flex;
align-items: center;
flex-wrap: nowrap;
}
.status-wrap {
flex-shrink: 0;
}
.meta-label {
color: #666;
}
.meta-value {
color: #333;
}
.status-text {
color: #999;
font-weight: 400;
}
.status-text.effective {
color: #22c55e;
font-weight: 400;
}
.bind-time {
margin-top: 12rpx;
color: #999;
font-size: 24rpx;
}
.state-wrap {
padding: 120rpx 0;
text-align: center;
}
.state-text {
color: #999;
font-size: 26rpx;
}
.list-footer {
padding: 12rpx 0 40rpx;
text-align: center;
}
.footer-text {
color: #ccc;
font-size: 24rpx;
}
.custom-popup {
padding: 32rpx 28rpx 40rpx;
}
.popup-title {
margin-bottom: 24rpx;
text-align: center;
color: #333;
font-size: 30rpx;
font-weight: 600;
}
.popup-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx 0;
border-bottom: 1rpx solid #f5f5f5;
}
.popup-label {
color: #666;
font-size: 28rpx;
}
.picker-value {
color: #333;
font-size: 28rpx;
}
.popup-actions {
display: flex;
gap: 20rpx;
margin-top: 32rpx;
}
.popup-btn {
flex: 1;
height: 80rpx;
line-height: 80rpx;
text-align: center;
border-radius: 999rpx;
font-size: 28rpx;
}
.popup-btn.ghost {
color: #666;
background: #f5f5f5;
}
.popup-btn.primary {
color: #fff;
background: linear-gradient(90deg, #ff9f3f, #ff6b35);
}
</style>

View File

@@ -0,0 +1,611 @@
<template>
<view class="page" :style="themeStyle">
<view v-if="loading" class="loading-wrap">
<text class="loading-text">加载中...</text>
</view>
<view v-else-if="!gradeList.length" class="empty-wrap">
<text class="empty-text">暂无等级信息</text>
</view>
<view v-else class="content">
<!-- 当前等级头部 -->
<view class="hero-card">
<view class="hero-top">
<view class="hero-user">
<image class="hero-avatar" :src="userInfo.face || defaultAvatar" mode="aspectFill" />
<view class="hero-name">{{ currentGradeName }}</view>
</view>
</view>
<view class="progress-wrap">
<view class="progress-line" v-if="gradeList.length > 1"></view>
<view class="progress-steps">
<view
v-for="grade in gradeList"
:key="grade.id || grade.sortOrder"
class="progress-step"
:class="{ active: Number(grade.sortOrder) === currentGradeValue }"
>
<view class="step-dot"></view>
<view class="step-name">V{{ grade.sortOrder }}</view>
</view>
</view>
</view>
</view>
<!-- 佣金比例 -->
<view class="card commission-card" v-if="currentGrade">
<view class="commission-row">
<view class="commission-item">
<view class="commission-icon"></view>
<view class="commission-text">
<text class="commission-rate">{{ formatRate(currentGrade.goodsCommissionRate) }}%</text>
<text class="commission-label">商品佣金比</text>
</view>
</view>
<view class="commission-divider"></view>
<view class="commission-item">
<view class="commission-icon"></view>
<view class="commission-text">
<text class="commission-rate">{{ formatRate(currentGrade.inviteCommissionRate) }}%</text>
<text class="commission-label">邀请佣金比</text>
</view>
</view>
</view>
</view>
<!-- 下一等级升级要求 -->
<view class="card upgrade-card" v-if="distributionData.id && nextGrade">
<view class="upgrade-title">
满足以下规则可升级为<text class="upgrade-target">{{ nextGrade.gradeName }}</text>
</view>
<view class="upgrade-list" v-if="upgradeConditions.length">
<view
v-for="(item, index) in upgradeConditions"
:key="index"
class="upgrade-item"
>
<view class="upgrade-status" :class="item.achieved ? 'achieved' : 'pending'">
{{ item.achieved ? '已达标' : '未达标' }}
</view>
<view class="upgrade-text">
<text>{{ item.label }}</text>
<text v-if="!item.achieved && item.gapText" class="upgrade-gap">{{ item.gapText }}</text>
</view>
</view>
</view>
<view v-else class="upgrade-empty">暂无升级条件配置</view>
</view>
<view class="card upgrade-card" v-else-if="distributionData.id">
<view class="upgrade-title">当前已是最高等级</view>
<view class="upgrade-empty">继续保持优秀业绩吧</view>
</view>
<!-- 等级规则 -->
<view class="card rules-card">
<view class="rules-title">等级规则</view>
<view class="rules-timeline">
<view
v-for="(grade, index) in gradeList"
:key="'rule-' + (grade.id || grade.sortOrder)"
class="timeline-item"
>
<view class="timeline-axis">
<view class="timeline-dot"></view>
<view v-if="index < gradeList.length - 1" class="timeline-line"></view>
</view>
<view class="timeline-content">
<view class="grade-tag">{{ grade.gradeName }}</view>
<view class="rule-section">
<view class="rule-section-title">规则介绍</view>
<view class="rule-section-text">{{ buildGradeRule(grade) }}</view>
</view>
<view class="rule-section">
<view class="rule-section-title">权益介绍</view>
<view class="rule-section-text">{{ buildGradeBenefit(grade) }}</view>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { useStore } from '@/store'
import { getThemeStyle } from '@/utils/theme'
import config from '@/config/config'
import { distribution, getDistributionGrades } from '@/api/distribution'
import { getUserInfo } from '@/api/members'
const store = useStore()
const themeStyle = computed(() => getThemeStyle(store.state.theme))
const defaultAvatar = config.defaultUserPhoto
const loading = ref(true)
const distributionData = ref<Record<string, any>>({})
const gradeList = ref<any[]>([])
const userInfo = computed(() => store.state.userInfo || {})
const currentGradeValue = computed(() => {
const val = Number(distributionData.value.gradeId)
return Number.isFinite(val) && val > 0 ? val : 1
})
const currentGrade = computed(() => {
return gradeList.value.find((g) => Number(g.sortOrder) === currentGradeValue.value) || null
})
const currentGradeName = computed(() => currentGrade.value?.gradeName || '普通分销员')
const nextGrade = computed(() => {
return gradeList.value.find((g) => Number(g.sortOrder) === currentGradeValue.value + 1) || null
})
const upgradeConditions = computed(() => {
if (!nextGrade.value) return []
return buildUpgradeConditions(nextGrade.value, distributionData.value)
})
onShow(() => {
refreshUserInfo()
loadPageData()
})
function refreshUserInfo() {
getUserInfo().then((res) => {
if (res.data?.result) {
store.commit('login', res.data.result)
}
})
}
function loadPageData() {
loading.value = true
Promise.all([fetchGrades(), fetchDistribution()]).finally(() => {
loading.value = false
})
}
function fetchDistribution() {
return distribution().then((res) => {
if (!res.data?.result) {
return
}
distributionData.value = res.data.result
})
}
function fetchGrades() {
return getDistributionGrades()
.then((res) => {
const result = res.data?.result
gradeList.value = (Array.isArray(result) ? result : []).sort(
(a: any, b: any) => Number(a.sortOrder) - Number(b.sortOrder)
)
})
.catch(() => {
gradeList.value = []
})
}
function formatMoney(val: number | string) {
return Number(val || 0).toFixed(2)
}
function formatRate(val: number | string | undefined) {
const num = Number(val || 0)
return Number.isFinite(num) ? num.toFixed(2).replace(/\.?0+$/, '') || '0' : '0'
}
function buildGradeRule(grade: any) {
if (Number(grade.sortOrder) === 1) {
return '成为分销员后即是该等级'
}
const parts: string[] = []
if (grade.enableSalesCondition && Number(grade.salesThreshold || 0) > 0) {
parts.push(`推广金额达 ${formatMoney(grade.salesThreshold)}`)
}
if (grade.enableCommissionCondition && Number(grade.commissionThreshold || 0) > 0) {
parts.push(`收益额达 ${formatMoney(grade.commissionThreshold)}`)
}
if (grade.enableInviteCondition && Number(grade.inviteThreshold || 0) > 0) {
parts.push(`邀请人数达 ${grade.inviteThreshold}`)
}
if (!parts.length) return '达到指定条件即可升级'
return parts.join(',且')
}
function buildGradeBenefit(grade: any) {
const parts: string[] = []
const goods = Number(grade.goodsCommissionRate || 0)
const invite = Number(grade.inviteCommissionRate || 0)
if (goods > 0) parts.push(`商品佣金比为 ${formatRate(goods)}%`)
if (invite > 0) parts.push(`邀请佣金比为 ${formatRate(invite)}%`)
return parts.length ? parts.join('') : '暂无权益说明'
}
function buildUpgradeConditions(grade: any, data: Record<string, any>) {
const conditions: Array<{ label: string; achieved: boolean; gapText?: string }> = []
const sales = Number(data.validSalesAmount || 0)
const commission = Number(data.validCommissionAmount || data.rebateTotal || 0)
const invites = Number(data.inviteCount || 0)
if (grade.enableSalesCondition) {
const threshold = Number(grade.salesThreshold || 0)
const achieved = sales >= threshold
conditions.push({
label: `推广金额达${formatMoney(threshold)}`,
achieved,
gapText: achieved ? undefined : `(还差 ${formatMoney(Math.max(threshold - sales, 0))} 元)`,
})
}
if (grade.enableCommissionCondition) {
const threshold = Number(grade.commissionThreshold || 0)
const achieved = commission >= threshold
conditions.push({
label: `收益额达${formatMoney(threshold)}`,
achieved,
gapText: achieved ? undefined : `(还差 ${formatMoney(Math.max(threshold - commission, 0))} 元)`,
})
}
if (grade.enableInviteCondition) {
const threshold = Number(grade.inviteThreshold || 0)
const achieved = invites >= threshold
conditions.push({
label: `邀请人数达${threshold}`,
achieved,
gapText: achieved ? undefined : `(还差 ${Math.max(threshold - invites, 0)} 人)`,
})
}
return conditions
}
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background: #f5f6f8;
}
.loading-wrap,
.empty-wrap {
display: flex;
align-items: center;
justify-content: center;
min-height: 60vh;
}
.loading-text,
.empty-text {
color: #999;
font-size: 28rpx;
}
.content {
padding: 24rpx;
}
.hero-card {
margin-bottom: 24rpx;
padding: 40rpx 32rpx 48rpx;
border-radius: 20rpx;
background: linear-gradient(135deg, #d4a574 0%, #c9956a 45%, #b8845a 100%);
box-shadow: 0 12rpx 32rpx rgba(184, 132, 90, 0.25);
}
.hero-top {
margin-bottom: 40rpx;
}
.hero-user {
display: flex;
align-items: center;
}
.hero-avatar {
width: 88rpx;
height: 88rpx;
border-radius: 50%;
border: 4rpx solid rgba(255, 255, 255, 0.5);
margin-right: 24rpx;
background: #eee;
}
.hero-name {
color: #fff;
font-size: 40rpx;
font-weight: 700;
}
.progress-wrap {
position: relative;
}
.progress-line {
position: absolute;
left: 12rpx;
right: 12rpx;
top: 12rpx;
height: 4rpx;
background: rgba(255, 255, 255, 0.35);
}
.progress-steps {
display: flex;
justify-content: space-between;
align-items: flex-start;
position: relative;
z-index: 1;
}
.progress-step {
display: flex;
flex-direction: column;
flex: 0 0 auto;
align-items: center;
min-width: 0;
}
.progress-step:first-child {
align-items: flex-start;
}
.progress-step:last-child {
align-items: flex-end;
}
.progress-step:first-child:last-child {
align-items: center;
width: 100%;
}
.step-dot {
width: 24rpx;
height: 24rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.45);
margin-bottom: 16rpx;
}
.progress-step.active .step-dot {
background: #fff;
box-shadow: 0 0 0 6rpx rgba(255, 255, 255, 0.25);
}
.step-name {
color: rgba(255, 255, 255, 0.75);
font-size: 22rpx;
text-align: center;
line-height: 1.4;
padding: 0 6rpx;
}
.progress-step.active .step-name {
color: #fff;
font-weight: 600;
}
.card {
margin-bottom: 24rpx;
padding: 32rpx 28rpx;
border-radius: 20rpx;
background: #fff;
}
.card:last-child {
margin-bottom: 0;
}
.commission-card {
padding: 36rpx 28rpx 28rpx;
}
.commission-row {
display: flex;
align-items: center;
}
.commission-item {
flex: 1;
display: flex;
align-items: center;
min-width: 0;
}
.commission-divider {
flex-shrink: 0;
width: 1rpx;
height: 72rpx;
margin: 0 16rpx;
background: #f0f0f0;
}
.commission-icon {
width: 72rpx;
height: 72rpx;
border-radius: 50%;
background: #f8efe6;
color: #b8845a;
font-size: 30rpx;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
margin-right: 20rpx;
flex-shrink: 0;
}
.commission-text {
display: flex;
flex-direction: column;
min-width: 0;
}
.commission-rate {
color: #222;
font-size: 36rpx;
font-weight: 700;
line-height: 1.2;
}
.commission-label {
margin-top: 8rpx;
color: #999;
font-size: 24rpx;
}
.upgrade-title {
color: #5c3b00;
font-size: 30rpx;
font-weight: 600;
line-height: 1.6;
}
.upgrade-target {
color: #ff6b35;
font-weight: 700;
}
.upgrade-list {
display: flex;
flex-direction: column;
gap: 20rpx;
margin-top: 24rpx;
}
.upgrade-item {
display: flex;
align-items: flex-start;
gap: 16rpx;
}
.upgrade-status {
flex-shrink: 0;
padding: 4rpx 12rpx;
border-radius: 8rpx;
font-size: 22rpx;
line-height: 1.4;
}
.upgrade-status.achieved {
color: #52c41a;
background: #f6ffed;
}
.upgrade-status.pending {
color: #999;
background: #f5f5f5;
}
.upgrade-text {
flex: 1;
color: #333;
font-size: 28rpx;
line-height: 1.6;
}
.upgrade-gap {
color: #ff6b35;
}
.upgrade-empty {
margin-top: 16rpx;
color: #999;
font-size: 26rpx;
}
.rules-title {
margin-bottom: 28rpx;
color: #5c3b00;
font-size: 32rpx;
font-weight: 600;
}
.rules-timeline {
display: flex;
flex-direction: column;
}
.timeline-item {
display: flex;
align-items: stretch;
}
.timeline-axis {
display: flex;
flex-direction: column;
align-items: center;
flex-shrink: 0;
width: 32rpx;
margin-right: 20rpx;
}
.timeline-dot {
flex-shrink: 0;
width: 16rpx;
height: 16rpx;
margin-top: 18rpx;
border-radius: 50%;
background: #ff8f3f;
}
.timeline-line {
flex: 1;
width: 2rpx;
min-height: 40rpx;
margin: 8rpx 0;
background: #f0d4b8;
}
.timeline-content {
flex: 1;
min-width: 0;
padding-bottom: 36rpx;
}
.timeline-item:last-child .timeline-content {
padding-bottom: 0;
}
.grade-tag {
display: inline-block;
margin-bottom: 20rpx;
padding: 10rpx 24rpx;
border-radius: 999rpx;
background: #f8efe6;
color: #5c3b00;
font-size: 28rpx;
font-weight: 600;
line-height: 1.3;
}
.rule-section {
margin-bottom: 20rpx;
}
.rule-section:last-child {
margin-bottom: 0;
}
.rule-section-title {
margin-bottom: 8rpx;
color: #5c3b00;
font-size: 28rpx;
font-weight: 600;
line-height: 1.4;
}
.rule-section-text {
color: #666;
font-size: 26rpx;
line-height: 1.6;
}
</style>

View File

@@ -32,11 +32,10 @@
<view class="log-item"> <view class="log-item">
<view class="log-item-view"> <view class="log-item-view">
<view class="title">{{ item.goodsName }}</view> <view class="title">{{ item.goodsName }}</view>
<view class="price">提成金额+{{ unitPrice(item.rebate) }}</view> <view class="price">提成金额+{{ unitPrice(item.commissionAmount) }}</view>
</view> </view>
<view class="log-item-view"> <view class="log-item-view">
<view>创建时间{{ item.createTime }}</view> <view>创建时间{{ item.createTime }}</view>
<view>店铺{{ item.storeName }}</view>
</view> </view>
<view class="log-item-footer"> <view class="log-item-footer">
<view>会员名称{{ item.memberName }}</view> <view>会员名称{{ item.memberName }}</view>
@@ -57,7 +56,7 @@
import { ref } from 'vue' import { ref } from 'vue'
import { onLoad, onReachBottom } from '@dcloudio/uni-app' import { onLoad, onReachBottom } from '@dcloudio/uni-app'
import { useStore } from '@/store' import { useStore } from '@/store'
import { cashLog, distributionOrderList } from '@/api/goods' import { cashLog, getDistributionOrders } from '@/api/distribution'
import { unitPrice } from '@/utils/filters.js' import { unitPrice } from '@/utils/filters.js'
const store = useStore() const store = useStore()
@@ -71,7 +70,7 @@ const listType = ref(0)
const routeQuery = ref<Record<string, string>>({}) const routeQuery = ref<Record<string, string>>({})
const withdrawParams = ref({ pageNumber: 1, pageSize: 10 }) const withdrawParams = ref({ pageNumber: 1, pageSize: 10 })
const achievementParams = ref({ pageNumber: 1, pageSize: 10 }) const achievementParams = ref({ pageNumber: 1, pageSize: 10, orderType: 'PROMOTION' })
onLoad((option) => { onLoad((option) => {
const type = Number(option.type != null ? option.type : 0) const type = Number(option.type != null ? option.type : 0)
@@ -100,12 +99,16 @@ function hideLoadingIfNeeded() {
function fetchAchievementList() { function fetchAchievementList() {
uni.showLoading({ title: '加载中' }) uni.showLoading({ title: '加载中' })
distributionOrderList(achievementParams.value).then((res) => { getDistributionOrders(achievementParams.value).then((res) => {
if (res.data.success && res.data.result.records.length >= 1) { const records = res.data.success ? res.data.result?.records || [] : []
achievementList.value.push(...res.data.result.records) if (records.length) {
achievementList.value.push(...records)
if (records.length < achievementParams.value.pageSize) {
loadStatus.value = 'nomore'
}
} else { } else {
loadStatus.value = 'nomore' loadStatus.value = 'nomore'
isEmpty.value = true isEmpty.value = achievementList.value.length === 0
} }
hideLoadingIfNeeded() hideLoadingIfNeeded()
}) })

View File

@@ -1,107 +1,908 @@
<template> <template>
<view> <view class="page" :style="themeStyle">
<view class="nav-list"> <view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
<view class="total">可提现金额</view>
<view class="price">{{ unitPrice(distributionData.canRebate) }}</view> <view class="nav-bar">
<view class="frozen">冻结金额{{ unitPrice(distributionData.commissionFrozen) }}</view> <view class="nav-back" @click="goBack">
<u-icon name="arrow-left" color="#fff" size="20" />
</view>
<view class="nav-title">分销员中心</view>
<view class="nav-placeholder"></view>
</view> </view>
<view class="nav">
<view class="nav-item"> <!-- 顶部用户信息 -->
<u-icon <view class="header">
size="50" <view class="user-row" @click="goGrade">
@click="navigateTo(`/pages/mine/distribution/list?id=${distributionData.id}&name=${distributionData.memberName}`)" <view class="avatar-wrap">
color="#ff6b35" <image class="avatar" :src="userInfo.face || defaultAvatar" mode="aspectFill" />
name="bag-fill" <view class="level-badge">V{{ currentGradeValue }}</view>
></u-icon> </view>
<view>分销商品</view> <view class="user-meta">
<view class="user-name">{{ displayName }}</view>
<view class="user-sub" v-if="userInfo.mobile">手机号{{ userInfo.mobile }}</view>
</view>
<u-icon name="arrow-right" color="rgba(255,255,255,0.7)" size="16"></u-icon>
</view> </view>
<view </view>
class="nav-item"
@click="navigateTo(`/pages/mine/distribution/history?type=0&id=${distributionData.id}&name=${distributionData.memberName}`)" <!-- 等级升级条 -->
> <view class="level-bar" v-if="gradeVisible" @click="goGrade">
<u-icon size="50" color="#ff6b35" name="order"></u-icon> <view class="level-bar-inner">
<view>分销业绩</view> <view class="level-info">
<view class="level-title">
<text>{{ currentGradeName }}</text>
<text class="level-tag">V{{ currentGradeValue }}</text>
<u-icon name="arrow-right" color="#8a5a28" size="12"></u-icon>
</view>
<view class="level-tip">{{ upgradeTip }}</view>
</view>
</view> </view>
<view class="nav-item" @click="navigateTo('/pages/mine/distribution/history?type=1')"> </view>
<u-icon size="50" color="#ff6b35" name="red-packet-fill"></u-icon>
<view>提现记录</view> <view class="body">
<!-- 收益卡片 -->
<view class="earn-card" @click="goAchievement">
<view class="earn-row">
<view class="earn-item">
<view class="earn-label">今日收益</view>
<view class="earn-value">{{ formatMoney(todayEarnings) }}<text class="unit"></text></view>
</view>
<view class="earn-item earn-item-right">
<view class="earn-label">总收益</view>
<view class="earn-value">{{ formatMoney(totalEarnings) }}<text class="unit"></text></view>
<view class="earn-sub" @click.stop="goAchievement">
含待结算{{ formatMoney(pendingAmount) }}
<u-icon name="arrow-right" color="rgba(255,255,255,0.85)" size="12"></u-icon>
</view>
</view>
</view>
</view> </view>
<view class="nav-item" @click="navigateTo('/pages/mine/distribution/withdrawal')">
<u-icon size="50" color="#ffc71c" name="rmb-circle-fill"></u-icon> <!-- 可提现 -->
<view>提现</view> <view class="withdraw-card" @click="goWithdraw">
<view class="withdraw-left">
<text>可提现金额()</text>
</view>
<view class="withdraw-right">
<text class="withdraw-amount">{{ formatMoney(withdrawableAmount) }}</text>
<u-icon name="arrow-right" color="#ccc" size="14"></u-icon>
</view>
</view>
<!-- 展开更多 -->
<view class="expand-toggle" v-if="!expanded" @click="expanded = true">
<text>展开更多</text>
<u-icon name="arrow-down" color="#999" size="14"></u-icon>
</view>
<view class="stats-card" v-if="expanded">
<view class="stats-row">
<view class="stats-item" @click="goTodayOrders">
<view class="stats-label">今日推广订单</view>
<view class="stats-value">{{ todayOrderCount }}</view>
</view>
<view class="stats-item" @click="goOrders">
<view class="stats-label">累计推广订单</view>
<view class="stats-value">{{ totalOrderCount }}</view>
</view>
</view>
<view class="stats-row">
<view class="stats-item" @click="goTodayCustomers">
<view class="stats-label">今日新增客户</view>
<view class="stats-value">{{ todayCustomerCount }}</view>
</view>
<view class="stats-item" @click="goCustomers">
<view class="stats-label">累计客户</view>
<view class="stats-value">{{ totalCustomerCount }}</view>
</view>
</view>
<view class="stats-row stats-row-last">
<view class="stats-item" @click="goTodayInvites">
<view class="stats-label">今日新增邀请</view>
<view class="stats-value">{{ todayInviteCount }}</view>
</view>
<view class="stats-item" @click="goInvites">
<view class="stats-label">累计邀请</view>
<view class="stats-value">{{ totalInviteCount }}</view>
</view>
</view>
</view>
<!-- 收起 -->
<view class="expand-toggle" v-if="expanded" @click="expanded = false">
<text>收起</text>
<u-icon name="arrow-up" color="#999" size="14"></u-icon>
</view>
<!-- 主推商品由后台分销商品设置决定是否展示及商品规则 -->
<view class="featured-section" v-if="featuredGoods.length">
<view class="section-title featured-section-title">主推商品</view>
<view class="featured-list">
<view
v-for="item in featuredGoods"
:key="item.skuId || item.id"
class="featured-item"
>
<image
class="featured-image"
:src="item.thumbnail"
mode="aspectFill"
@click="goFeaturedGoods(item)"
></image>
<view class="featured-body">
<view class="featured-name" @click="goFeaturedGoods(item)">{{ item.goodsName }}</view>
<view class="featured-detail-row">
<view class="featured-info" @click="goFeaturedGoods(item)">
<view class="featured-earn"> ¥{{ formatMoney(item.commission) }}</view>
<view class="featured-price">¥{{ formatMoney(item.price) }}</view>
</view>
<view class="featured-share-btn" @click.stop="shareFeaturedGoods(item)">
立即分享
</view>
</view>
<view
class="featured-sales"
v-if="item.salesCount !== null && item.salesCount !== undefined"
@click="goFeaturedGoods(item)"
>
已售 {{ item.salesCount }}
</view>
</view>
</view>
</view>
</view>
<!-- 推广卖货 -->
<view class="promote-section">
<view class="promote-title">推广卖货</view>
<view class="tool-grid">
<view class="tool-row">
<view class="tool-item" @click="goGoodsList">
<view class="tool-text">
<view class="tool-name">推广商品</view>
<view class="tool-desc">佣金赚不够</view>
</view>
<view class="tool-icon">
<u-icon name="bag-fill" color="#ff8f3f" size="28"></u-icon>
</view>
</view>
<view class="tool-item" @click="goPoster">
<view class="tool-text">
<view class="tool-name">推广海报</view>
<view class="tool-desc">发圈快速获客</view>
</view>
<view class="tool-icon">
<u-icon name="photo-fill" color="#ff6b6b" size="28"></u-icon>
</view>
</view>
</view>
<view class="tool-row">
<view class="tool-item" @click="goInvite">
<view class="tool-text">
<view class="tool-name">邀请好友</view>
<view class="tool-desc">可获得邀请佣金</view>
</view>
<view class="tool-icon">
<u-icon name="email-fill" color="#9b7bff" size="28"></u-icon>
</view>
</view>
<view class="tool-item" @click="goOrder">
<view class="tool-text">
<view class="tool-name">推广订单</view>
<view class="tool-desc">查看推广订单</view>
</view>
<view class="tool-icon">
<u-icon name="order" color="#ff8f3f" size="28"></u-icon>
</view>
</view>
</view>
</view>
</view> </view>
</view> </view>
</view> </view>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref, computed } from 'vue'
import { onShow } from '@dcloudio/uni-app' import { onShow } from '@dcloudio/uni-app'
import { useStore } from '@/store' import { useStore } from '@/store'
import { distribution } from '@/api/goods' import config from '@/config/config'
import { unitPrice } from '@/utils/filters.js' import { getThemeStyle } from '@/utils/theme'
import {
distribution,
getDistributionFeaturedGoods,
getDistributionGrades,
getDistributionCustomers,
getDistributionInvitees,
getDistributionOrders,
getDistributionPerformance,
} from '@/api/distribution'
import { getUserInfo } from '@/api/members'
const store = useStore() const store = useStore()
const distributionData = ref<Record<string, any>>({}) const themeStyle = computed(() => getThemeStyle(store.state.theme))
const defaultAvatar = config.defaultUserPhoto
const statusBarHeight = ref(20)
const expanded = ref(false)
onShow(() => { const distributionData = ref<Record<string, any>>({})
fetchDistributionInfo() const gradeList = ref<any[]>([])
const featuredGoods = ref<any[]>([])
const todayOrderCount = ref(0)
const todayEarnings = ref(0)
const todayCustomerCount = ref(0)
const todayInviteCount = ref(0)
const userInfo = computed(() => store.state.userInfo || {})
const displayName = computed(() => userInfo.value.nickName || distributionData.value.memberName || '分销员')
const withdrawableAmount = computed(() => Number(distributionData.value.canRebate || 0))
const pendingAmount = computed(() => Number(distributionData.value.commissionFrozen || 0))
const totalEarnings = computed(() => Number(distributionData.value.rebateTotal || 0))
const totalOrderCount = computed(() => Number(distributionData.value.distributionOrderCount || 0))
const totalCustomerCount = computed(() => Number(distributionData.value.validCustomerCount || 0))
const totalInviteCount = computed(() => Number(distributionData.value.inviteCount || 0))
const currentGradeValue = computed(() => {
const val = Number(distributionData.value.gradeId)
return Number.isFinite(val) && val > 0 ? val : 1
}) })
function hideLoadingIfNeeded() { const currentGrade = computed(() => {
if (store.state.isShowToast) uni.hideLoading() return gradeList.value.find((g) => Number(g.sortOrder) === currentGradeValue.value) || null
})
const currentGradeName = computed(() => currentGrade.value?.gradeName || '普通分销员')
const nextGrade = computed(() => {
return gradeList.value.find((g) => Number(g.sortOrder) === currentGradeValue.value + 1) || null
})
const gradeVisible = computed(() => gradeList.value.length > 0)
const upgradeTip = computed(() => {
if (!nextGrade.value) {
return '当前已是最高等级,继续保持优秀业绩'
}
const sales = Number(distributionData.value.validSalesAmount || 0)
const threshold = Number(nextGrade.value.salesThreshold || 0)
if (threshold > 0 && sales < threshold) {
const gap = (threshold - sales).toFixed(2)
return `推广金额再增加${gap}元即可升级为${nextGrade.value.gradeName || '下一等级'}`
}
return `继续提升业绩即可升级为${nextGrade.value.gradeName || '下一等级'}`
})
onShow(() => {
statusBarHeight.value = uni.getWindowInfo?.()?.statusBarHeight || 20
refreshUserInfo()
loadPageData()
})
function refreshUserInfo() {
getUserInfo().then((res) => {
if (res.data?.result) {
store.commit('login', res.data.result)
}
})
}
function loadPageData() {
Promise.all([
fetchDistribution(),
fetchGrades(),
fetchTodayOrders(),
fetchTodayEarnings(),
fetchTodayCustomers(),
fetchTodayInvites(),
fetchFeaturedGoods(),
])
}
function fetchDistribution() {
return distribution().then((res) => {
if (!res.data?.result) {
uni.showToast({ title: '您还不是分销员', icon: 'none' })
setTimeout(() => goBack(), 1500)
return
}
distributionData.value = res.data.result
})
}
function fetchGrades() {
return getDistributionGrades().then((res) => {
gradeList.value = res.data?.result || []
})
}
function fetchTodayOrders() {
return getDistributionOrders({
pageNumber: 1,
pageSize: 1,
orderType: 'PROMOTION',
rangeType: 'TODAY',
}).then((res) => {
todayOrderCount.value = Number(res.data?.result?.total || 0)
})
}
function fetchTodayEarnings() {
return getDistributionPerformance({ rangeType: 'TODAY' }).then((res) => {
todayEarnings.value = Number(res.data?.result?.totalEarnings || 0)
})
}
function fetchTodayCustomers() {
return getDistributionCustomers({
pageNumber: 1,
pageSize: 1,
filterType: 'ALL',
rangeType: 'TODAY',
}).then((res) => {
todayCustomerCount.value = Number(res.data?.result?.total || 0)
})
}
function fetchTodayInvites() {
return getDistributionInvitees({
pageNumber: 1,
pageSize: 1,
rangeType: 'TODAY',
}).then((res) => {
todayInviteCount.value = Number(res.data?.result?.total || 0)
})
}
function fetchFeaturedGoods() {
featuredGoods.value = []
return getDistributionFeaturedGoods().then((res) => {
const result = res.data?.result
featuredGoods.value = Array.isArray(result) ? result : []
})
}
function formatMoney(val: number | string) {
const num = Number(val || 0)
return num.toFixed(2)
} }
function navigateTo(url: string) { function navigateTo(url: string) {
uni.navigateTo({ url }) uni.navigateTo({ url })
} }
function fetchDistributionInfo() { function goBack() {
uni.showLoading({ title: '加载中' }) if (getCurrentPages().length > 1) {
distribution().then((res) => { uni.navigateBack({ delta: 1 })
if (res.data.result) { } else {
distributionData.value = res.data.result uni.switchTab({ url: '/pages/tabbar/user/my' })
} }
hideLoadingIfNeeded()
})
} }
function distQuery() {
const d = distributionData.value
return `id=${d.id || ''}&name=${encodeURIComponent(d.memberName || '')}`
}
function goGrade() {
navigateTo('/pages/mine/distribution/grade')
}
function goAchievement() {
navigateTo('/pages/mine/distribution/achievement')
}
function goHistory() {
navigateTo(`/pages/mine/distribution/history?type=0&${distQuery()}`)
}
function goWithdraw() {
navigateTo('/pages/mine/distribution/withdrawal')
}
function goGoodsList() {
navigateTo(`/pages/mine/distribution/list?${distQuery()}`)
}
function goFeaturedGoods(item: any) {
if (!item?.skuId || !item?.goodsId) return
navigateTo(`/pages/product/goods?id=${item.skuId}&goodsId=${item.goodsId}`)
}
function shareFeaturedGoods(item: any) {
const distributionId = distributionData.value.id
if (!item?.skuId || !item?.goodsId || !distributionId) {
uni.showToast({ title: '分享信息不完整', icon: 'none' })
return
}
navigateTo(
`/pages/mine/distribution/share?skuId=${item.skuId}&goodsId=${item.goodsId}&distributionId=${distributionId}`
)
}
function goPoster() {
navigateTo('/pages/mine/distribution/poster')
}
function goInvite() {
navigateTo('/pages/mine/distribution/invite-friends')
}
function goCustomers() {
navigateTo('/pages/mine/distribution/customer-list')
}
function goTodayCustomers() {
navigateTo('/pages/mine/distribution/customer-list?rangeType=TODAY')
}
function goInvites() {
navigateTo('/pages/mine/distribution/invite-list')
}
function goTodayInvites() {
navigateTo('/pages/mine/distribution/invite-list?rangeType=TODAY')
}
function goOrders() {
navigateTo('/pages/mine/distribution/order-list?orderType=PROMOTION')
}
function goTodayOrders() {
navigateTo('/pages/mine/distribution/order-list?orderType=PROMOTION&rangeType=TODAY')
}
function goOrder() {
goOrders()
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.nav { .page {
background: #fff; min-height: 100vh;
background: #f5f6f8;
}
.status-bar {
background: #1f2228;
}
.nav-bar {
display: flex;
align-items: center; align-items: center;
justify-content: space-between;
height: 88rpx;
padding: 0 16rpx;
background: #1f2228;
}
.nav-back {
width: 72rpx;
height: 72rpx;
display: flex; display: flex;
flex-wrap: wrap;
}
.nav-list {
color: #fff;
padding: 40rpx 0;
background: linear-gradient(91deg, $light-color 1%, $aider-light-color 99%);
}
.total {
padding: 10rpx 0;
text-align: center;
font-size: 28rpx;
opacity: 0.8;
}
.frozen {
text-align: center;
font-size: 24rpx;
opacity: 0.8;
}
.price {
text-align: center;
color: #fff;
font-size: 50rpx;
}
.nav-item {
height: 240rpx;
display: flex;
flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 20rpx;
width: 33%;
} }
.nav-title {
flex: 1;
text-align: center;
color: #fff;
font-size: 34rpx;
font-weight: 600;
}
.nav-placeholder {
width: 72rpx;
}
.header {
background: linear-gradient(180deg, #1f2228 0%, #2a2e36 100%);
padding: 24rpx 32rpx 40rpx;
}
.user-row {
display: flex;
align-items: center;
}
.avatar-wrap {
position: relative;
margin-right: 24rpx;
}
.avatar {
width: 112rpx;
height: 112rpx;
border-radius: 50%;
background: #eee;
}
.level-badge {
position: absolute;
right: -4rpx;
bottom: -4rpx;
min-width: 40rpx;
height: 32rpx;
padding: 0 8rpx;
border-radius: 16rpx;
background: linear-gradient(135deg, #f6d365, #fda085);
color: #5c3b00;
font-size: 18rpx;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
}
.user-meta {
flex: 1;
min-width: 0;
}
.user-name {
color: #fff;
font-size: 36rpx;
font-weight: 600;
margin-bottom: 10rpx;
}
.user-sub {
color: rgba(255, 255, 255, 0.72);
font-size: 24rpx;
line-height: 1.6;
}
.level-bar {
margin: -24rpx 24rpx 0;
position: relative;
z-index: 2;
}
.level-bar-inner {
padding: 24rpx 28rpx;
border-radius: 16rpx;
background: linear-gradient(135deg, #fff7ed 0%, #fdebd3 100%);
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.06);
}
.level-info {
min-width: 0;
}
.level-title {
display: flex;
align-items: center;
gap: 10rpx;
color: #5c3b12;
font-size: 30rpx;
font-weight: 600;
margin-bottom: 8rpx;
}
.level-tag {
padding: 2rpx 12rpx;
border-radius: 8rpx;
background: rgba(201, 122, 43, 0.12);
font-size: 22rpx;
}
.level-tip {
color: #8a6a45;
font-size: 24rpx;
line-height: 1.5;
}
.body {
padding: 24rpx;
}
.earn-card {
margin-top: 16rpx;
padding: 36rpx 32rpx;
border-radius: 20rpx;
background: linear-gradient(135deg, #ff8f3f 0%, #ff6b35 55%, #ff5a2f 100%);
box-shadow: 0 12rpx 32rpx rgba(255, 107, 53, 0.28);
}
.earn-row {
display: flex;
}
.earn-item {
flex: 1;
}
.earn-item-right {
text-align: right;
}
.earn-label {
color: rgba(255, 255, 255, 0.88);
font-size: 26rpx;
margin-bottom: 12rpx;
}
.earn-value {
color: #fff;
font-size: 52rpx;
font-weight: 700;
line-height: 1.2;
}
.unit {
font-size: 28rpx;
font-weight: 500;
margin-left: 4rpx;
}
.earn-sub {
margin-top: 12rpx;
color: rgba(255, 255, 255, 0.9);
font-size: 24rpx;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4rpx;
}
.expand-toggle {
display: flex;
align-items: center;
justify-content: center;
gap: 8rpx;
padding: 24rpx 0 8rpx;
color: #999;
font-size: 26rpx;
}
.stats-card {
background: #fff;
border-radius: 16rpx;
padding: 8rpx 28rpx;
margin-bottom: 0;
}
.stats-row {
display: flex;
padding: 24rpx 0;
border-bottom: 1rpx solid #f2f2f2;
}
.stats-row-last {
border-bottom: none;
}
.stats-item {
flex: 1;
text-align: left;
}
.stats-label {
color: #999;
font-size: 24rpx;
margin-bottom: 12rpx;
}
.stats-value {
color: #222;
font-size: 40rpx;
font-weight: 600;
}
.withdraw-card {
display: flex;
align-items: center;
justify-content: space-between;
background: #fff;
border-radius: 16rpx;
padding: 32rpx 28rpx;
margin-top: 8rpx;
}
.withdraw-left {
display: flex;
align-items: center;
gap: 8rpx;
color: #333;
font-size: 28rpx;
}
.withdraw-right {
display: flex;
align-items: center;
gap: 8rpx;
}
.withdraw-amount {
color: #ff6b35;
font-size: 36rpx;
font-weight: 700;
}
.section-title {
margin: 36rpx 0 20rpx;
color: #222;
font-size: 32rpx;
font-weight: 600;
}
.promote-section {
margin-top: 24rpx;
margin-bottom: 40rpx;
padding: 24rpx;
border-radius: 16rpx;
background: #fff;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.04);
}
.promote-title {
margin-bottom: 20rpx;
color: #222;
font-size: 32rpx;
font-weight: 600;
}
.tool-grid {
display: flex;
flex-direction: column;
}
.tool-row {
display: flex;
justify-content: space-between;
margin-bottom: 16rpx;
}
.tool-row:last-child {
margin-bottom: 0;
}
.tool-item {
display: flex;
align-items: center;
justify-content: space-between;
width: 48%;
padding: 24rpx 20rpx;
box-sizing: border-box;
border-radius: 12rpx;
background: #f7f8fa;
}
.tool-text {
flex: 1;
min-width: 0;
padding-right: 12rpx;
}
.tool-icon {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 56rpx;
height: 56rpx;
}
.tool-name {
color: #222;
font-size: 28rpx;
font-weight: 600;
line-height: 1.3;
margin-bottom: 8rpx;
}
.tool-desc {
color: #999;
font-size: 22rpx;
line-height: 1.3;
}
.featured-section {
margin-top: 36rpx;
padding-bottom: 8rpx;
}
.featured-section-title {
margin-top: 0;
margin-bottom: 16rpx;
}
.featured-list {
overflow: hidden;
border-radius: 16rpx;
background: #fff;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.04);
}
.featured-item {
display: flex;
align-items: flex-start;
width: 100%;
padding: 20rpx;
box-sizing: border-box;
border-bottom: 1rpx solid #f0f0f0;
}
.featured-item:last-child {
border-bottom: none;
}
.featured-image {
display: block;
flex-shrink: 0;
width: 176rpx;
height: 176rpx;
border-radius: 12rpx;
background: #f4f4f4;
}
.featured-body {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
padding-left: 16rpx;
}
.featured-name {
overflow: hidden;
color: #333;
font-size: 28rpx;
line-height: 40rpx;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.featured-detail-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 10rpx;
}
.featured-info {
flex: 1;
min-width: 0;
}
.featured-earn {
display: inline-block;
padding: 4rpx 12rpx;
border-radius: 6rpx;
background: #fff0f0;
color: #ff4d4f;
font-size: 22rpx;
line-height: 1.4;
}
.featured-price {
margin-top: 10rpx;
color: #222;
font-size: 34rpx;
font-weight: 700;
line-height: 1.2;
}
.featured-sales {
margin-top: 8rpx;
color: #b3b3b3;
font-size: 22rpx;
line-height: 1.2;
}
.featured-share-btn {
flex-shrink: 0;
margin-left: 12rpx;
padding: 14rpx 24rpx;
border-radius: 999rpx;
background: linear-gradient(135deg, #ff8f3f 0%, #ff6b35 100%);
color: #fff;
font-size: 24rpx;
line-height: 1;
white-space: nowrap;
}
</style> </style>

View File

@@ -0,0 +1,329 @@
<template>
<view class="page" :style="themeStyle">
<view class="hero">
<view class="hero-title">邀好友 赚奖励</view>
</view>
<view class="flow-card">
<view class="flow-list">
<view class="flow-item" v-for="(item, index) in flowSteps" :key="item.title">
<view class="flow-icon-wrap" :style="{ background: item.bg }">
<u-icon :name="item.icon" color="#fff" size="22" />
</view>
<view class="flow-text">
<text class="flow-title">{{ item.title }}</text>
<text class="flow-desc">{{ item.desc }}</text>
</view>
<view v-if="index < flowSteps.length - 1" class="flow-line"></view>
</view>
</view>
<view class="card-btn" @click="goInviteCard">图文邀请卡</view>
</view>
<view class="invite-card">
<view class="invite-title">
<text class="invite-title-line"></text>
<text class="invite-title-text">我已邀请{{ inviteTotal }}</text>
<text class="invite-title-line"></text>
</view>
<view v-if="loading && !inviteList.length" class="state-wrap">
<text class="state-text">加载中...</text>
</view>
<view v-else-if="!inviteList.length" class="state-wrap">
<text class="state-text">还没有邀请好友快去分享邀请卡吧</text>
</view>
<view v-else class="invite-list">
<view class="invite-item" v-for="item in inviteList" :key="item.childDistributionId">
<image
class="invite-avatar"
:src="resolveAvatar(item.memberAvatar)"
mode="aspectFill"
/>
<view class="invite-name">{{ displayInviteName(item) }}</view>
<view class="invite-reward">
获得
<text class="reward-value">{{ formatMoney(item.inviteRewardAmount) }}</text>
</view>
</view>
</view>
<view class="view-all" @click="goInviteList">查看我的邀请</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { useStore } from '@/store'
import config from '@/config/config'
import { getThemeStyle } from '@/utils/theme'
import { getDistributionInvitees } from '@/api/distribution'
import { parseGoodsImageUrl } from '@/utils/filters.js'
const store = useStore()
const themeStyle = computed(() => getThemeStyle(store.state.theme))
const defaultAvatar = config.defaultUserPhoto
const flowSteps = [
{
icon: 'account-fill',
bg: 'linear-gradient(135deg, #ff9a8b, #ff6a88)',
title: '邀请好友',
desc: '成为分销员',
},
{
icon: 'bag-fill',
bg: 'linear-gradient(135deg, #84fab0, #8fd3f4)',
title: '好友客户',
desc: '下单购物',
},
{
icon: 'red-packet-fill',
bg: 'linear-gradient(135deg, #fccb90, #ff8f3f)',
title: '邀请者获得',
desc: '邀请奖励',
},
]
const loading = ref(false)
const inviteTotal = ref(0)
const inviteList = ref<any[]>([])
onShow(() => {
loadPreview()
})
function loadPreview() {
loading.value = true
getDistributionInvitees({
pageNumber: 1,
pageSize: 5,
rangeType: 'ALL',
})
.then((res) => {
const result = res.data?.result || {}
inviteTotal.value = Number(result.total || 0)
inviteList.value = result.records || []
})
.finally(() => {
loading.value = false
})
}
function resolveAvatar(avatar?: string) {
return parseGoodsImageUrl(avatar) || defaultAvatar
}
function displayInviteName(item: any) {
const gradeName = (item.gradeName || '').trim()
const memberName = (item.memberName || '分销员').trim()
if (gradeName && !memberName.startsWith(gradeName)) {
return `${gradeName}${memberName}`
}
return memberName
}
function formatMoney(val: number | string) {
return Number(val || 0).toFixed(2)
}
function goInviteCard() {
uni.navigateTo({ url: '/pages/mine/distribution/invite' })
}
function goInviteList() {
uni.navigateTo({ url: '/pages/mine/distribution/invite-list' })
}
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background: #fff7f0;
padding-bottom: 40rpx;
}
.hero {
padding: 56rpx 40rpx 96rpx;
background: linear-gradient(135deg, #ffb347 0%, #ff8f3f 45%, #ff6b35 100%);
box-sizing: border-box;
display: flex;
justify-content: center;
align-items: flex-start;
}
.hero-title {
color: #fff;
font-size: 56rpx;
font-weight: 700;
letter-spacing: 2rpx;
text-align: center;
line-height: 1.3;
position: relative;
z-index: 1;
}
.flow-card {
margin: -64rpx 24rpx 0;
padding: 36rpx 28rpx 32rpx;
border-radius: 20rpx;
background: #fff;
box-shadow: 0 10rpx 30rpx rgba(255, 107, 53, 0.12);
position: relative;
z-index: 2;
}
.flow-list {
display: flex;
justify-content: space-between;
position: relative;
}
.flow-item {
position: relative;
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
min-width: 0;
}
.flow-icon-wrap {
width: 88rpx;
height: 88rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.flow-text {
margin-top: 16rpx;
text-align: center;
}
.flow-title {
display: block;
color: #333;
font-size: 24rpx;
font-weight: 600;
line-height: 1.4;
}
.flow-desc {
display: block;
color: #999;
font-size: 22rpx;
line-height: 1.4;
}
.flow-line {
position: absolute;
top: 44rpx;
right: -20rpx;
width: 40rpx;
height: 2rpx;
background: #f0f0f0;
}
.card-btn {
margin-top: 36rpx;
height: 88rpx;
line-height: 88rpx;
text-align: center;
border-radius: 999rpx;
background: linear-gradient(90deg, #ff9f3f, #ff6b35);
color: #fff;
font-size: 30rpx;
font-weight: 600;
}
.invite-card {
margin: 24rpx;
padding: 28rpx 24rpx 32rpx;
border-radius: 20rpx;
background: #fff;
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.04);
}
.invite-title {
display: flex;
align-items: center;
justify-content: center;
gap: 16rpx;
margin-bottom: 24rpx;
}
.invite-title-line,
.invite-title-text {
color: #ff8f3f;
font-size: 28rpx;
font-weight: 600;
}
.state-wrap {
padding: 48rpx 0;
text-align: center;
}
.state-text {
color: #999;
font-size: 26rpx;
}
.invite-item {
display: flex;
align-items: center;
padding: 24rpx 0;
border-bottom: 1rpx solid #f5f5f5;
}
.invite-item:last-child {
border-bottom: none;
}
.invite-avatar {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
background: #f2f2f2;
flex-shrink: 0;
}
.invite-name {
flex: 1;
margin: 0 20rpx;
color: #333;
font-size: 28rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.invite-reward {
color: #666;
font-size: 24rpx;
flex-shrink: 0;
}
.reward-value {
color: #ff6b35;
font-size: 30rpx;
font-weight: 700;
margin: 0 4rpx;
}
.view-all {
margin-top: 28rpx;
text-align: center;
color: #ff8f3f;
font-size: 28rpx;
text-decoration: underline;
}
</style>

View File

@@ -0,0 +1,486 @@
<template>
<view class="page" :style="themeStyle">
<view class="filter-bar">
<scroll-view scroll-x class="filter-scroll" :show-scrollbar="false">
<view class="filter-tabs">
<view
v-for="item in rangeTabs"
:key="item.value"
class="filter-tab"
:class="{ active: activeRange === item.value }"
@click="changeRange(item.value)"
>
{{ item.label }}
</view>
</view>
</scroll-view>
<view class="custom-time" :class="{ active: activeRange === 'CUSTOM' }" @click="openCustomPicker">
<text>自定义时间</text>
<u-icon name="arrow-down" size="12" :color="activeRange === 'CUSTOM' ? '#ff8f3f' : '#999'" />
</view>
</view>
<view class="summary-row">
<text> {{ inviteTotal }} 个邀请</text>
<text>获得邀请奖励 {{ formatMoney(totalInviteReward) }}</text>
</view>
<view v-if="loading && !inviteList.length" class="state-wrap">
<text class="state-text">加载中...</text>
</view>
<view v-else-if="!inviteList.length" class="state-wrap">
<text class="state-text">暂无邀请记录</text>
</view>
<view v-else class="list-wrap">
<view class="invite-card" v-for="item in inviteList" :key="item.childDistributionId">
<view class="card-top">
<image class="avatar" :src="resolveAvatar(item.memberAvatar)" mode="aspectFill" />
<view class="info">
<view class="name">{{ displayInviteName(item) }}</view>
<view class="time">邀请时间{{ formatTime(item.effectiveTime) }}</view>
</view>
<view class="reward">
<text class="reward-label">奖励</text>
<text class="reward-value">{{ formatMoney(item.inviteRewardAmount) }}</text>
</view>
</view>
<view class="card-stats">
<text>销售额{{ formatMoney(item.salesAmount) }}</text>
<text>绑客数{{ item.customerCount || 0 }}</text>
<text>订单数{{ item.orderCount || 0 }}</text>
</view>
</view>
</view>
<view v-if="inviteList.length" class="list-footer">
<text v-if="finished" class="footer-text">没有更多数据了</text>
<text v-else-if="loadingMore" class="footer-text">加载中...</text>
</view>
<u-popup v-model:show="customVisible" mode="bottom" round="16">
<view class="custom-popup">
<view class="popup-title">自定义时间</view>
<view class="popup-row">
<text class="popup-label">开始日期</text>
<picker mode="date" :value="customStartDate" @change="onStartDateChange">
<view class="picker-value">{{ customStartDate || '请选择' }}</view>
</picker>
</view>
<view class="popup-row">
<text class="popup-label">结束日期</text>
<picker mode="date" :value="customEndDate" @change="onEndDateChange">
<view class="picker-value">{{ customEndDate || '请选择' }}</view>
</picker>
</view>
<view class="popup-actions">
<view class="popup-btn ghost" @click="customVisible = false">取消</view>
<view class="popup-btn primary" @click="applyCustomRange">确定</view>
</view>
</view>
</u-popup>
</view>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { onReachBottom, onShow, onLoad } from '@dcloudio/uni-app'
import { useStore } from '@/store'
import config from '@/config/config'
import { getThemeStyle } from '@/utils/theme'
import { getDistributionInvitees } from '@/api/distribution'
import { parseGoodsImageUrl } from '@/utils/filters.js'
const store = useStore()
const themeStyle = computed(() => getThemeStyle(store.state.theme))
const defaultAvatar = config.defaultUserPhoto
const rangeTabs = [
{ label: '全部', value: 'ALL' },
{ label: '今日', value: 'TODAY' },
{ label: '昨日', value: 'YESTERDAY' },
{ label: '近七日', value: 'LAST_7_DAYS' },
]
const activeRange = ref('ALL')
const loading = ref(false)
const loadingMore = ref(false)
const finished = ref(false)
const inviteTotal = ref(0)
const totalInviteReward = ref(0)
const inviteList = ref<any[]>([])
const pageNumber = ref(1)
const pageSize = 10
const customVisible = ref(false)
const customStartDate = ref('')
const customEndDate = ref('')
const RANGE_VALUES = ['ALL', 'TODAY', 'YESTERDAY', 'LAST_7_DAYS', 'CUSTOM']
function applyRouteOptions(options: Record<string, string | undefined> = {}) {
const rangeType = options.rangeType
if (rangeType && RANGE_VALUES.includes(rangeType)) {
activeRange.value = rangeType
return
}
activeRange.value = 'ALL'
}
onLoad((options) => {
applyRouteOptions(options as Record<string, string>)
})
onShow(() => {
const pages = getCurrentPages()
const current = pages[pages.length - 1] as { options?: Record<string, string> }
if (current?.options) {
applyRouteOptions(current.options)
}
resetAndLoad()
})
onReachBottom(() => {
loadInvitees(false)
})
function resetAndLoad() {
pageNumber.value = 1
finished.value = false
inviteList.value = []
loadInvitees(true)
}
function buildQueryParams() {
const params: Record<string, any> = {
pageNumber: pageNumber.value,
pageSize,
rangeType: activeRange.value,
}
if (activeRange.value === 'CUSTOM') {
if (customStartDate.value) {
params.startTime = `${customStartDate.value} 00:00:00`
}
if (customEndDate.value) {
params.endTime = `${customEndDate.value} 23:59:59`
}
}
return params
}
function loadInvitees(reset = false) {
if (reset) {
loading.value = true
pageNumber.value = 1
finished.value = false
} else if (loading.value || loadingMore.value || finished.value) {
return
} else {
loadingMore.value = true
}
getDistributionInvitees(buildQueryParams())
.then((res) => {
const result = res.data?.result || {}
const records = result.records || []
inviteTotal.value = Number(result.total || 0)
totalInviteReward.value = Number(result.totalInviteReward || 0)
if (reset) {
inviteList.value = records
} else {
inviteList.value = inviteList.value.concat(records)
}
if (records.length < pageSize || inviteList.value.length >= inviteTotal.value) {
finished.value = true
} else {
pageNumber.value += 1
}
})
.finally(() => {
loading.value = false
loadingMore.value = false
})
}
function changeRange(value: string) {
if (activeRange.value === value) return
activeRange.value = value
resetAndLoad()
}
function openCustomPicker() {
customVisible.value = true
}
function onStartDateChange(event: any) {
customStartDate.value = event.detail.value
}
function onEndDateChange(event: any) {
customEndDate.value = event.detail.value
}
function applyCustomRange() {
if (!customStartDate.value || !customEndDate.value) {
uni.showToast({ title: '请选择开始和结束日期', icon: 'none' })
return
}
if (customStartDate.value > customEndDate.value) {
uni.showToast({ title: '开始日期不能晚于结束日期', icon: 'none' })
return
}
activeRange.value = 'CUSTOM'
customVisible.value = false
resetAndLoad()
}
function resolveAvatar(avatar?: string) {
return parseGoodsImageUrl(avatar) || defaultAvatar
}
function displayInviteName(item: any) {
const gradeName = (item.gradeName || '').trim()
const memberName = (item.memberName || '分销员').trim()
if (gradeName && !memberName.startsWith(gradeName)) {
return `${gradeName}${memberName}`
}
return memberName
}
function formatMoney(val: number | string) {
return Number(val || 0).toFixed(2)
}
function formatTime(value?: string) {
if (!value) return '-'
return String(value).replace('T', ' ').slice(0, 19)
}
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background: #f5f6f8;
}
.filter-bar {
display: flex;
align-items: center;
padding: 24rpx 24rpx 20rpx;
background: #fff;
}
.filter-scroll {
flex: 1;
min-width: 0;
white-space: nowrap;
}
.filter-tabs {
display: inline-flex;
align-items: center;
gap: 16rpx;
}
.filter-tab {
padding: 10rpx 24rpx;
border-radius: 999rpx;
color: #666;
font-size: 26rpx;
background: #f5f5f5;
flex-shrink: 0;
}
.filter-tab.active {
color: #ff8f3f;
background: #fff2e8;
font-weight: 600;
}
.custom-time {
display: flex;
align-items: center;
gap: 6rpx;
margin-left: 16rpx;
color: #666;
font-size: 24rpx;
flex-shrink: 0;
}
.custom-time.active {
color: #ff8f3f;
font-weight: 600;
}
.summary-row {
display: flex;
justify-content: space-between;
padding: 16rpx 24rpx 20rpx;
color: #999;
font-size: 24rpx;
background: #f5f6f8;
}
.list-wrap {
padding: 0 24rpx 24rpx;
}
.invite-card {
margin-bottom: 20rpx;
border-radius: 16rpx;
background: #fff;
overflow: hidden;
}
.card-top {
display: flex;
align-items: center;
padding: 24rpx;
}
.avatar {
width: 88rpx;
height: 88rpx;
border-radius: 50%;
background: #f2f2f2;
flex-shrink: 0;
}
.info {
flex: 1;
min-width: 0;
margin: 0 20rpx;
}
.name {
color: #333;
font-size: 30rpx;
font-weight: 600;
line-height: 1.4;
}
.time {
margin-top: 8rpx;
color: #999;
font-size: 22rpx;
}
.reward {
display: flex;
flex-direction: row;
align-items: baseline;
flex-shrink: 0;
}
.reward-label {
color: #666;
font-size: 24rpx;
line-height: 1.4;
margin-right: 6rpx;
}
.reward-value {
color: #ff6b35;
font-size: 36rpx;
font-weight: 700;
line-height: 1.2;
}
.card-stats {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20rpx 24rpx 24rpx;
border-top: 1rpx solid #f5f5f5;
color: #999;
font-size: 24rpx;
}
.card-stats text {
flex: 1;
}
.card-stats text:nth-child(2) {
text-align: center;
}
.card-stats text:nth-child(3) {
text-align: right;
}
.state-wrap {
padding: 120rpx 0;
text-align: center;
}
.state-text {
color: #999;
font-size: 26rpx;
}
.list-footer {
padding: 12rpx 0 32rpx;
text-align: center;
}
.footer-text {
color: #ccc;
font-size: 24rpx;
}
.custom-popup {
padding: 32rpx 28rpx 40rpx;
}
.popup-title {
margin-bottom: 24rpx;
text-align: center;
color: #333;
font-size: 30rpx;
font-weight: 600;
}
.popup-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx 0;
border-bottom: 1rpx solid #f5f5f5;
}
.popup-label {
color: #666;
font-size: 28rpx;
}
.picker-value {
color: #333;
font-size: 28rpx;
}
.popup-actions {
display: flex;
gap: 20rpx;
margin-top: 32rpx;
}
.popup-btn {
flex: 1;
height: 80rpx;
line-height: 80rpx;
text-align: center;
border-radius: 999rpx;
font-size: 28rpx;
}
.popup-btn.ghost {
color: #666;
background: #f5f5f5;
}
.popup-btn.primary {
color: #fff;
background: linear-gradient(90deg, #ff9f3f, #ff6b35);
}
</style>

View File

@@ -0,0 +1,425 @@
<template>
<view class="wrapper" :style="themeStyle">
<view v-if="pageLoading" class="state-wrap">
<text class="state-text">{{ pageLoadingText }}</text>
</view>
<view v-else-if="pageError" class="state-wrap">
<text class="state-text">{{ pageError }}</text>
<view class="retry-btn" @click="loadInviteCardSetting">重新加载</view>
</view>
<view v-else class="card" :class="{ 'card--has-bg': hasBackground }">
<image
v-if="hasBackground"
class="card-bg"
:src="backgroundUrl"
mode="aspectFill"
/>
<view class="card-content">
<view v-if="showMemberInfo" class="member-info">
<image
v-if="inviteContext.memberAvatar"
class="member-avatar"
:src="avatarUrl"
mode="aspectFill"
/>
<view class="member-name" :style="textStyle">{{ inviteContext.memberName || '分销员' }}</view>
</view>
<view v-if="inviteContext.slogan" class="slogan" :style="textStyle">{{ inviteContext.slogan }}</view>
<view class="qr-wrap" @click="previewQrCode">
<view v-if="qrLoading" class="qr-state">
<text class="qr-state-text">{{ qrLoadingText }}</text>
</view>
<view v-else-if="qrError" class="qr-state">
<text class="qr-state-text">{{ qrError }}</text>
<view class="retry-btn small" @click.stop="loadQrCode">重新生成</view>
</view>
<image
v-else-if="qrImage"
class="qr-image"
:src="qrImage"
mode="aspectFit"
show-menu-by-longpress
@error="onQrImageError"
/>
</view>
</view>
</view>
<view v-if="!pageLoading && !pageError" class="footer-actions">
<!-- #ifdef MP-WEIXIN -->
<button class="btn primary share-btn" open-type="share" :disabled="!qrImage">
<text class="btn-text">微信分享</text>
</button>
<!-- #endif -->
<view class="hint-text">长按图片保存海报</view>
</view>
<canvas canvas-id="inviteQrCanvas" class="qr-canvas" />
</view>
</template>
<script setup lang="ts">
import { ref, computed, getCurrentInstance } from 'vue'
import { onLoad, onShareAppMessage } from '@dcloudio/uni-app'
import { useStore } from '@/store'
import { getThemeStyle } from '@/utils/theme'
import { getInviteCard } from '@/api/distribution'
import { getMpCode } from '@/api/goods'
import { parseGoodsImageUrl } from '@/utils/filters.js'
const DEFAULT_JOIN_PAGE = 'pages/mine/distribution/join'
const QR_CANVAS_SIZE = 280
const instance = getCurrentInstance()
const store = useStore()
const themeStyle = computed(() => getThemeStyle(store.state.theme))
const pageLoading = ref(true)
const pageLoadingText = ref('加载邀请卡设置...')
const pageError = ref('')
const qrLoading = ref(false)
const qrLoadingText = ref('葵花码生成中...')
const qrError = ref('')
const qrImage = ref('')
const inviteContext = ref<Record<string, any>>({})
const showMemberInfo = computed(() => inviteContext.value.memberInfoVisible !== false)
const textStyle = computed(() => ({
color: inviteContext.value.textColor || '#1f2a44',
}))
const avatarUrl = computed(() => parseGoodsImageUrl(inviteContext.value.memberAvatar))
const backgroundUrl = computed(() => parseGoodsImageUrl(inviteContext.value.backgroundImage))
const hasBackground = computed(() => !!backgroundUrl.value)
onLoad(() => {
loadInviteCardSetting()
})
onShareAppMessage(() => {
const ctx = inviteContext.value
const sharePage = normalizeSharePage(ctx.sharePage)
const query = ctx.distributionId ? `?distributionId=${ctx.distributionId}` : ''
return {
title: ctx.slogan || '邀请您成为我的下级分销员',
path: `/${sharePage}${query}`,
imageUrl: qrImage.value || '',
}
})
function normalizeSharePage(page?: string) {
const value = String(page || DEFAULT_JOIN_PAGE).trim()
return value.replace(/^\//, '')
}
function writeBase64ToTemp(base64: string): Promise<string> {
return new Promise((resolve, reject) => {
const raw = String(base64).trim()
if (!raw || raw.startsWith('{')) {
reject(new Error('葵花码数据无效'))
return
}
const data = raw.replace(/^data:image\/\w+;base64,/, '')
const filePath = `${wx.env.USER_DATA_PATH}/invite_qr_${Date.now()}.png`
wx.getFileSystemManager().writeFile({
filePath,
data,
encoding: 'base64',
success: () => resolve(filePath),
fail: () => reject(new Error('葵花码写入失败')),
})
})
}
function exportQrByCanvas(qrPath: string): Promise<string> {
return new Promise((resolve, reject) => {
const ctx = uni.createCanvasContext('inviteQrCanvas', instance?.proxy)
ctx.setFillStyle('#ffffff')
ctx.fillRect(0, 0, QR_CANVAS_SIZE, QR_CANVAS_SIZE)
ctx.drawImage(qrPath, 0, 0, QR_CANVAS_SIZE, QR_CANVAS_SIZE)
ctx.draw(false, () => {
setTimeout(() => {
uni.canvasToTempFilePath(
{
canvasId: 'inviteQrCanvas',
width: QR_CANVAS_SIZE,
height: QR_CANVAS_SIZE,
destWidth: QR_CANVAS_SIZE,
destHeight: QR_CANVAS_SIZE,
success: (file) => resolve(file.tempFilePath),
fail: () => reject(new Error('葵花码导出失败')),
},
instance?.proxy
)
}, 300)
})
})
}
async function resolveQrDisplay(result: string) {
const tempPath = await writeBase64ToTemp(result)
qrImage.value = await exportQrByCanvas(tempPath)
}
async function loadInviteCardSetting() {
pageLoading.value = true
pageLoadingText.value = '加载邀请卡设置...'
pageError.value = ''
qrImage.value = ''
qrError.value = ''
try {
const res = await getInviteCard()
if (!res.data?.success || !res.data?.result) {
throw new Error(res.data?.message || '获取邀请卡设置失败')
}
const result = res.data.result
inviteContext.value = {
distributionId: result.distributionId || '',
memberName: result.memberName || '',
memberAvatar: result.memberAvatar || '',
backgroundImage: result.backgroundImage || '',
slogan: result.slogan || '邀请您成为我的下级分销员',
memberInfoVisible: result.memberInfoVisible !== false,
textColor: result.textColor || '#1f2a44',
sharePage: result.sharePage || DEFAULT_JOIN_PAGE,
shareScene: result.shareScene || '',
}
} catch (error: any) {
console.error('load invite card setting failed', error)
pageError.value = error?.message || '获取邀请卡设置失败,请稍后重试'
return
} finally {
pageLoading.value = false
}
loadQrCode()
}
async function loadQrCode() {
if (!inviteContext.value.distributionId && !inviteContext.value.shareScene) {
qrError.value = '分销员信息异常,无法生成葵花码'
return
}
qrLoading.value = true
qrLoadingText.value = '葵花码生成中...'
qrError.value = ''
qrImage.value = ''
try {
const ctx = inviteContext.value
const codeRes = await getMpCode({
page: normalizeSharePage(ctx.sharePage),
scene: ctx.shareScene || `recruit_${ctx.distributionId}`,
})
if (!codeRes.data?.success || !codeRes.data?.result) {
throw new Error(codeRes.data?.message || '葵花码生成失败')
}
await resolveQrDisplay(codeRes.data.result)
} catch (error: any) {
console.error('load invite qr code failed', error)
qrError.value = error?.message || '葵花码生成失败,请稍后重试'
} finally {
qrLoading.value = false
}
}
function onQrImageError() {
qrError.value = '葵花码图片加载失败'
qrImage.value = ''
}
function previewQrCode() {
if (!qrImage.value) return
uni.previewImage({
current: qrImage.value,
urls: [qrImage.value],
})
}
</script>
<style lang="scss" scoped>
.wrapper {
min-height: 100vh;
padding: 24rpx;
box-sizing: border-box;
background: #f5f5f5;
}
.state-wrap {
padding: 160rpx 0;
text-align: center;
}
.state-text {
color: #999;
font-size: 28rpx;
}
.retry-btn {
display: inline-block;
margin-top: 24rpx;
padding: 12rpx 40rpx;
border-radius: 999rpx;
background: linear-gradient(90deg, #ff9f3f, #ff6b35);
color: #fff;
font-size: 26rpx;
}
.retry-btn.small {
margin-top: 16rpx;
padding: 8rpx 28rpx;
font-size: 24rpx;
}
.card {
position: relative;
background: #fff;
border-radius: 16rpx;
overflow: hidden;
min-height: 720rpx;
}
.card--has-bg {
background: transparent;
}
.card-bg {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
z-index: 0;
}
.card-content {
position: relative;
z-index: 1;
padding: 32rpx 24rpx;
text-align: center;
}
.card--has-bg .card-content {
background: rgba(255, 255, 255, 0.12);
}
.member-info {
display: flex;
flex-direction: column;
align-items: center;
}
.member-avatar {
width: 96rpx;
height: 96rpx;
border-radius: 50%;
margin-bottom: 16rpx;
border: 2rpx solid rgba(255, 255, 255, 0.8);
}
.member-name {
font-size: 34rpx;
font-weight: bold;
}
.slogan {
margin-top: 12rpx;
font-size: 26rpx;
line-height: 1.6;
padding: 0 16rpx;
}
.qr-wrap {
display: flex;
justify-content: center;
margin-top: 32rpx;
}
.qr-state {
width: 420rpx;
height: 420rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.92);
border-radius: 16rpx;
}
.qr-state-text {
color: #999;
font-size: 26rpx;
padding: 0 24rpx;
text-align: center;
}
.qr-image {
width: 420rpx;
height: 420rpx;
background: #fff;
border-radius: 16rpx;
}
.qr-canvas {
position: fixed;
left: -9999px;
top: -9999px;
width: 280px;
height: 280px;
}
.footer-actions {
margin-top: 24rpx;
}
.btn {
width: 100%;
text-align: center;
padding: 20rpx 0;
border-radius: 40rpx;
border: 1px solid $light-color;
color: $light-color;
font-size: 28rpx;
box-sizing: border-box;
}
.btn.primary {
background: $light-color;
color: #fff;
border-color: $light-color;
}
.share-btn {
display: block;
width: 100%;
margin: 0;
padding: 0;
line-height: normal;
font-size: inherit;
}
.share-btn::after {
border: none;
}
.btn-text {
display: block;
padding: 20rpx 0;
color: #fff;
font-size: 28rpx;
}
.hint-text {
margin-top: 24rpx;
color: #b3b3b3;
font-size: 24rpx;
text-align: center;
}
</style>

View File

@@ -1,127 +1,756 @@
<template> <template>
<view class="wrapper" :style="themeStyle"> <view class="wrapper" :style="themeStyle">
<u-tabs <view v-if="loading" class="loading">加载中...</view>
:list="stepList" <view v-else-if="!plan">
:scrollable="false" <view class="empty">暂未开放线上招募</view>
v-model:current="currentStep" </view>
:lineColor="lightColor" <view v-else class="page-body" :class="{ 'with-footer': showManualApply || isApplyPending }">
:activeStyle="{ color: lightColor }" <!-- 招募海报 -->
></u-tabs> <view class="section poster-section" v-if="showPoster">
<image
class="poster-image"
:src="plan.posterBackground"
mode="widthFix"
@click="previewPoster"
/>
<view class="poster-text" v-if="posterTitle">{{ posterTitle }}</view>
<view class="poster-benefit" v-if="posterBenefitText">{{ posterBenefitText }}</view>
<view class="poster-rule" v-if="posterRuleText">{{ posterRuleText }}</view>
<view class="poster-contact" v-if="posterContact">联系方式{{ posterContact }}</view>
</view>
<view class="feedBack-box"> <!-- 加入条件 -->
<up-form <view class="section" v-if="hasJoinCondition">
:model="formData" <view class="title">加入条件</view>
label-position="top" <view class="condition-tip">满足以下条件后可申请成为分销员</view>
ref="uFormRef"
> <view class="condition-item" v-if="plan.requireSelfPurchaseAmount">
<up-form-item label="会员昵称" prop="name"> <view class="condition-head">
<u-input <text class="condition-label">自购金额</text>
border="none" <text class="condition-status" :class="selfPurchaseOk ? 'ok' : 'pending'">
class="field-input" {{ selfPurchaseOk ? '已达标' : '未达标' }}
v-model="formData.name" </text>
:custom-style="fieldInputStyle" </view>
/> <view class="condition-value">
</up-form-item> {{ formatMoney(progress?.currentSelfPurchaseAmount) }} / {{ formatMoney(plan.minSelfPurchaseAmount) }}
<up-form-item label="账户类型" prop="name"></up-form-item> </view>
<up-form-item label="收款人姓名" prop="name"> </view>
<u-input
border="none" <view class="condition-item" v-if="plan.requireConsumeCount">
class="field-input" <view class="condition-head">
v-model="formData.name" <text class="condition-label">消费笔数</text>
placeholder="请输入收款人姓名" <text class="condition-status" :class="consumeCountOk ? 'ok' : 'pending'">
:custom-style="fieldInputStyle" {{ consumeCountOk ? '已达标' : '未达标' }}
/> </text>
</up-form-item> </view>
<up-form-item label="收款账号" prop="name"> <view class="condition-value">
<u-input {{ progress?.currentConsumeCount || 0 }} / {{ plan.minConsumeCount || 0 }}
border="none" </view>
class="field-input" </view>
v-model="formData.name"
placeholder="请输入收款人账号" <view class="condition-item" v-if="plan.requirePurchaseGoods">
:custom-style="fieldInputStyle" <view class="condition-head">
/> <text class="condition-label">购买指定商品</text>
</up-form-item> <text class="condition-status" :class="progress?.purchaseGoodsSatisfied ? 'ok' : 'pending'">
<up-form-item label="银行名称" prop="name"> {{ progress?.purchaseGoodsSatisfied ? '已达标' : '未达标' }}
<u-input </text>
border="none" </view>
class="field-input" <view class="goods-list" v-if="recruitGoods.length">
v-model="formData.name" <view class="goods-item" v-for="item in recruitGoods" :key="item.skuId || item.goodsId">
placeholder="请输入开户银行支行名称" <image class="goods-thumb" :src="item.thumbnail" mode="aspectFill" />
:custom-style="fieldInputStyle" <view class="goods-meta">
/> <view class="goods-name">{{ item.goodsName }}</view>
</up-form-item> <view class="goods-spec" v-if="item.simpleSpecs">{{ item.simpleSpecs }}</view>
</up-form> <view class="goods-price" v-if="item.price != null">¥{{ formatMoney(item.price) }}</view>
</view>
</view>
</view>
<view class="condition-value" v-else>需购买指定商品中的任意一件</view>
</view>
<view class="summary-status" :class="progress?.satisfied ? 'ok' : 'pending'">
{{ progress?.satisfied ? '已满足全部加入条件' : '未满足加入条件' }}
</view>
</view>
<!-- 申请通过 -->
<view class="section result-section" v-if="isApplyApproved">
<view class="result-icon approved"></view>
<view class="result-title">已成为分销员</view>
<view class="result-desc">恭喜您已通过审核可进入分销员中心开始推广</view>
<view class="submit" @click="goDistributionHome">进入分销员中心</view>
</view>
<!-- 自动申请提示 -->
<view class="section auto-tip" v-else-if="showAutoApplyTip">
<view class="auto-tip-text">您已满足加入条件系统将自动为您申请成为分销员</view>
</view>
</view> </view>
<view class="submit" @click="submitForm">提交</view> <view class="apply-footer-fixed" v-if="showManualApply || isApplyPending">
<view class="apply-footer-card">
<view class="agree" v-if="showManualApply" @click="agreed = !agreed">
<checkbox :checked="agreed" @click.stop="agreed = !agreed" />
<text>我已阅读并同意</text>
<text class="agreement-link" @click.stop="openAgreement">分销员推广服务协议</text>
</view>
<view
class="submit"
:class="{ disabled: isApplyPending || !canApply }"
@click="submit"
>
{{ isApplyPending ? '等待审核' : '申请成为分销员' }}
</view>
</view>
</view>
<u-popup v-model:show="applyFormVisible" mode="bottom" round="16" :closeable="true">
<view class="apply-form-popup">
<view class="apply-form-header">
<view class="apply-form-title">填写审核信息</view>
<view class="apply-form-subtitle">商家设置了信息审核请填写真实信息</view>
</view>
<view
v-for="field in fields"
:key="field.id"
class="popup-field"
>
<view class="popup-label">
<text v-if="field.required" class="required">*</text>
<text>{{ field.fieldName }}</text>
</view>
<input
v-model="formItems[field.id]"
class="popup-input"
:placeholder="field.placeholder || `请输入${field.fieldName}`"
@input="clearFieldError(field.id)"
/>
<view v-if="fieldErrors[field.id]" class="field-error">{{ fieldErrors[field.id] }}</view>
</view>
<view class="popup-next-btn" @click="confirmApplyForm">下一步</view>
</view>
</u-popup>
<u-popup v-model:show="agreementVisible" mode="bottom" round="16" :closeable="true">
<view class="agreement-popup">
<view class="agreement-popup-title">分销员推广服务协议</view>
<scroll-view scroll-y class="agreement-popup-body">
<rich-text v-if="agreementContent" :nodes="agreementContent" />
<view v-else class="agreement-empty">暂无协议内容</view>
</scroll-view>
</view>
</u-popup>
</view> </view>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, computed } from 'vue' import { ref, reactive, computed } from 'vue'
import { onReady } from '@dcloudio/uni-app' import { onLoad, onShow } from '@dcloudio/uni-app'
import { useStore } from '@/store' import { useStore } from '@/store'
import { getThemeStyle } from '@/utils/theme' import { getThemeStyle } from '@/utils/theme'
import { fieldInputStyle } from '@/utils/form-style.js' import { getRecruitPage, getRecruitProgress, submitRecruitApplication } from '@/api/distribution'
import { tipsToLogin } from '@/utils/filters.js'
const store = useStore() const store = useStore()
const themeStyle = computed(() => getThemeStyle(store.state.theme)) const themeStyle = computed(() => getThemeStyle(store.state.theme))
const lightColor = computed(() => store.getters.lightColor) const loading = ref(true)
const currentStep = ref(0) const pageInfo = ref<any>({})
const uFormRef = ref<any>(null) const progress = ref<any>(null)
const formItems = reactive<Record<string, string>>({})
const agreed = ref(false)
const recruitToken = ref('')
const applySubmitted = ref(false)
const submitAuditMode = ref('')
const submitApplicationStatus = ref('')
const agreementVisible = ref(false)
const applyFormVisible = ref(false)
const formInfoCompleted = ref(false)
const fieldErrors = reactive<Record<string, string>>({})
const stepList = [ const plan = computed(() => pageInfo.value.plan || null)
{ name: '推广人资料' }, const fields = computed(() => pageInfo.value.fields || [])
{ name: '平台审核' }, const recruitGoods = computed(() => plan.value?.recruitGoods || [])
{ name: '完成' }, const agreementContent = computed(() => plan.value?.agreementContent || '')
]
const formData = reactive({ const showPoster = computed(() => {
name: '', const p = plan.value
radio: '', if (!p) return false
if (p.posterEnabled === false) return false
return !!(p.posterBackground && String(p.posterBackground).trim())
}) })
const rules = { const posterTitle = computed(() => plan.value?.posterTitle || '')
name: [{ required: true, message: '请输入姓名', trigger: 'blur' }], const posterBenefitText = computed(() => plan.value?.posterBenefitText || '')
const posterRuleText = computed(() => plan.value?.posterRuleText || '')
const posterContact = computed(() => plan.value?.posterContact || '')
const hasJoinCondition = computed(() => {
const p = plan.value
if (!p || p.joinConditionType !== 'CONDITIONAL') return false
return !!(p.requireSelfPurchaseAmount || p.requireConsumeCount || p.requirePurchaseGoods)
})
const conditionsMet = computed(() => {
if (!hasJoinCondition.value) return true
return !!progress.value?.satisfied
})
const distributionStatus = computed(() => pageInfo.value.distributionStatus || '')
const currentApplication = computed(() => pageInfo.value.currentApplication || null)
const isApplyApproved = computed(() => {
if (distributionStatus.value === 'PASS') return true
if (submitApplicationStatus.value === 'APPROVED') return true
if (applySubmitted.value && submitAuditMode.value === 'AUTO_PASS') return true
return currentApplication.value?.applicationStatus === 'APPROVED'
})
const isApplyPending = computed(() => {
if (isApplyApproved.value) return false
if (distributionStatus.value === 'APPLY') return true
if (submitApplicationStatus.value === 'PENDING') return true
if (applySubmitted.value && submitAuditMode.value === 'MANUAL') return true
return currentApplication.value?.applicationStatus === 'PENDING'
})
const showApplyResult = computed(() => isApplyApproved.value || isApplyPending.value)
const isManualApply = computed(() => {
return (plan.value?.applyMode || 'MANUAL') === 'MANUAL'
})
const showManualApply = computed(() => {
return !showApplyResult.value && conditionsMet.value && isManualApply.value
})
const showAutoApplyTip = computed(() => {
return !showApplyResult.value && conditionsMet.value && plan.value?.applyMode === 'AUTO'
})
const showApplyForm = computed(() => {
return !!plan.value?.requireApplyInfo && fields.value.length > 0
})
const canApply = computed(() => {
return showManualApply.value && agreed.value
})
const selfPurchaseOk = computed(() => {
if (!plan.value?.requireSelfPurchaseAmount) return true
const current = Number(progress.value?.currentSelfPurchaseAmount || 0)
const min = Number(plan.value?.minSelfPurchaseAmount || 0)
return current >= min
})
const consumeCountOk = computed(() => {
if (!plan.value?.requireConsumeCount) return true
const current = Number(progress.value?.currentConsumeCount || 0)
const min = Number(plan.value?.minConsumeCount || 0)
return current >= min
})
onLoad((query) => {
recruitToken.value = query?.token || query?.distributionId || ''
})
onShow(() => {
loadPageData()
})
function loadPageData() {
loading.value = true
applySubmitted.value = false
submitAuditMode.value = ''
submitApplicationStatus.value = ''
formInfoCompleted.value = false
Object.keys(fieldErrors).forEach((key) => delete fieldErrors[key])
Promise.all([getRecruitPage(), getRecruitProgress()])
.then(([pageRes, progressRes]) => {
pageInfo.value = pageRes.data?.result || {}
progress.value = progressRes.data?.result || null
})
.finally(() => {
loading.value = false
})
} }
onReady(() => { function formatMoney(val: number | string | undefined) {
uFormRef.value?.setRules(rules) return Number(val || 0).toFixed(2)
}) }
function submitForm() { function previewPoster() {
uFormRef.value?.validate().catch(() => { if (!plan.value?.posterBackground) return
uni.showToast({ title: '请填写有效信息', icon: 'none' }) uni.previewImage({
urls: [plan.value.posterBackground],
current: plan.value.posterBackground,
}) })
} }
function goDistributionHome() {
uni.redirectTo({
url: '/pages/mine/distribution/home',
})
}
function openAgreement() {
agreementVisible.value = true
}
function clearFieldError(fieldId: string) {
if (fieldErrors[fieldId]) {
delete fieldErrors[fieldId]
}
}
function validateFormFields() {
let valid = true
Object.keys(fieldErrors).forEach((key) => delete fieldErrors[key])
fields.value.forEach((field: any) => {
if (!field.required) return
const value = formItems[field.id]
if (value == null || String(value).trim() === '') {
fieldErrors[field.id] = `请填写${field.fieldName}`
valid = false
}
})
return valid
}
function confirmApplyForm() {
if (!validateFormFields()) {
return
}
formInfoCompleted.value = true
applyFormVisible.value = false
uni.showToast({ title: '请点击申请成为分销员', icon: 'none' })
}
function doSubmit() {
const formItemList = showApplyForm.value
? fields.value.map((field: any) => ({
fieldId: field.id,
fieldValue: formItems[field.id] || '',
}))
: []
submitRecruitApplication({
recruitToken: recruitToken.value,
agreementAccepted: agreed.value,
formItems: formItemList,
}).then((res) => {
if (res.data?.success) {
handleApplySuccess(res.data.result)
if (isApplyApproved.value) {
uni.showToast({ title: '申请成功', icon: 'success' })
} else {
uni.showToast({ title: '已提交,等待审核', icon: 'none' })
}
} else {
uni.showToast({ title: res.data?.message || '提交失败', icon: 'none' })
}
})
}
function handleApplySuccess(application: any) {
const auditMode = application?.auditMode || plan.value?.auditMode || 'MANUAL'
const status = application?.applicationStatus || 'PENDING'
applySubmitted.value = true
submitAuditMode.value = auditMode
submitApplicationStatus.value = status
if (auditMode === 'AUTO_PASS' || status === 'APPROVED') {
pageInfo.value.distributionStatus = 'PASS'
if (pageInfo.value.currentApplication) {
pageInfo.value.currentApplication.applicationStatus = 'APPROVED'
}
} else if (pageInfo.value.currentApplication) {
pageInfo.value.currentApplication.applicationStatus = 'PENDING'
}
}
function submit() {
if (!tipsToLogin('normal')) {
return
}
if (!showManualApply.value) {
return
}
if (!agreed.value) {
uni.showToast({ title: '请先同意分销推广服务协议', icon: 'none' })
return
}
if (showApplyForm.value && !formInfoCompleted.value) {
applyFormVisible.value = true
return
}
doSubmit()
}
</script> </script>
<style lang="scss">
page {
background: #f8f8f8;
}
</style>
<style lang="scss" scoped> <style lang="scss" scoped>
@import '@/pages/passport/entry/seller/entry-form.scss';
.wrapper { .wrapper {
box-sizing: border-box;
min-height: 100vh; min-height: 100vh;
padding: 20rpx 24rpx 40rpx; padding: 24rpx;
background: #f8f8f8; background: #f5f6f8;
@include seller-entry-form;
} }
.feedBack-box { .loading,
.empty {
text-align: center;
color: #999;
padding: 80rpx 0;
font-size: 28rpx;
}
.section {
background: #fff; background: #fff;
border-radius: 20rpx; border-radius: 16rpx;
padding: 32rpx; padding: 28rpx;
margin-bottom: 24rpx;
}
.poster-section {
padding: 0;
overflow: hidden;
}
.poster-image {
display: block;
width: 100%;
border-radius: 16rpx 16rpx 0 0;
background: #f5f5f5;
}
.poster-text {
padding: 24rpx 28rpx 0;
font-size: 34rpx;
font-weight: 600;
color: #222;
line-height: 1.4;
}
.poster-benefit {
padding: 12rpx 28rpx 0;
font-size: 28rpx;
color: #ff6b35;
line-height: 1.5;
}
.poster-rule {
padding: 12rpx 28rpx 0;
font-size: 26rpx;
color: #666;
line-height: 1.6;
white-space: pre-wrap;
}
.poster-contact {
padding: 12rpx 28rpx 24rpx;
font-size: 24rpx;
color: #999;
}
.title {
font-size: 32rpx;
font-weight: 600;
margin-bottom: 12rpx;
color: #222;
}
.condition-tip {
font-size: 24rpx;
color: #999;
margin-bottom: 20rpx;
}
.condition-item {
padding: 20rpx 0;
border-bottom: 1rpx solid #f2f2f2;
}
.condition-item:last-of-type {
border-bottom: none;
}
.condition-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10rpx;
}
.condition-label {
font-size: 28rpx;
color: #333;
}
.condition-status {
font-size: 22rpx;
padding: 4rpx 12rpx;
border-radius: 8rpx;
}
.condition-status.ok {
color: #52c41a;
background: #f6ffed;
}
.condition-status.pending {
color: #999;
background: #f5f5f5;
}
.condition-value {
font-size: 26rpx;
color: #666;
}
.summary-status {
margin-top: 20rpx; margin-top: 20rpx;
padding: 16rpx 20rpx;
border-radius: 12rpx;
font-size: 26rpx;
text-align: center;
}
.summary-status.ok {
color: #52c41a;
background: #f6ffed;
}
.summary-status.pending {
color: #ff6b35;
background: #fff7f0;
}
.goods-list {
margin-top: 12rpx;
}
.goods-item {
display: flex;
align-items: center;
padding: 16rpx 0;
}
.goods-thumb {
width: 96rpx;
height: 96rpx;
border-radius: 12rpx;
background: #f5f5f5;
margin-right: 16rpx;
}
.goods-meta {
flex: 1;
min-width: 0;
}
.goods-name {
font-size: 26rpx;
color: #333;
line-height: 1.4;
}
.goods-spec,
.goods-price {
margin-top: 6rpx;
font-size: 24rpx;
color: #999;
}
.field {
margin-bottom: 20rpx;
}
.label {
font-size: 26rpx;
margin-bottom: 8rpx;
color: #333;
}
.required {
color: #ff4d4f;
margin-left: 4rpx;
}
.auto-tip {
text-align: center;
}
.auto-tip-text {
font-size: 28rpx;
color: #52c41a;
line-height: 1.6;
}
.result-section {
text-align: center;
padding: 48rpx 28rpx;
}
.result-icon {
width: 96rpx;
height: 96rpx;
margin: 0 auto 24rpx;
border-radius: 50%;
font-size: 48rpx;
line-height: 96rpx;
}
.result-icon.approved {
background: #f6ffed;
color: #52c41a;
}
.result-icon.pending {
background: #fff7e6;
color: #fa8c16;
}
.result-title {
font-size: 36rpx;
font-weight: 600;
color: #222;
margin-bottom: 16rpx;
}
.result-desc {
font-size: 26rpx;
color: #999;
line-height: 1.6;
margin-bottom: 32rpx;
}
.result-section .submit {
margin-top: 0;
}
.page-body.with-footer {
padding-bottom: calc(260rpx + env(safe-area-inset-bottom));
}
.apply-footer-fixed {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 100;
}
.apply-footer-card {
background: #fff;
border-radius: 16rpx 16rpx 0 0;
padding: 24rpx 28rpx calc(24rpx + env(safe-area-inset-bottom));
box-shadow: 0 -4rpx 24rpx rgba(0, 0, 0, 0.06);
}
.apply-form-popup {
padding: 32rpx 32rpx calc(32rpx + env(safe-area-inset-bottom));
}
.apply-form-header {
margin-bottom: 32rpx;
}
.apply-form-title {
font-size: 34rpx;
font-weight: 600;
color: #222;
margin-bottom: 12rpx;
}
.apply-form-subtitle {
font-size: 24rpx;
color: #999;
line-height: 1.5;
}
.popup-field {
margin-bottom: 28rpx;
}
.popup-label {
font-size: 28rpx;
color: #333;
margin-bottom: 16rpx;
}
.popup-input {
width: 100%;
height: 88rpx;
padding: 0 24rpx;
box-sizing: border-box;
background: #f7f7f7;
border-radius: 12rpx;
font-size: 28rpx;
color: #333;
}
.field-error {
margin-top: 10rpx;
font-size: 24rpx;
color: #ff4d4f;
}
.popup-next-btn {
margin-top: 16rpx;
background: linear-gradient(90deg, #ff8f3f 0%, #ff6b35 100%);
color: #fff;
text-align: center;
padding: 24rpx;
border-radius: 44rpx;
font-size: 30rpx;
font-weight: 600;
}
.agree {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8rpx;
margin-bottom: 24rpx;
font-size: 24rpx;
color: #666;
}
.agreement-link {
color: $light-color;
}
.agreement-popup {
padding: 32rpx 28rpx 48rpx;
max-height: 70vh;
}
.agreement-popup-title {
font-size: 32rpx;
font-weight: 600;
color: #222;
margin-bottom: 24rpx;
text-align: center;
}
.agreement-popup-body {
max-height: 56vh;
}
.agreement-empty {
text-align: center;
color: #999;
font-size: 26rpx;
padding: 40rpx 0;
} }
.submit { .submit {
@include seller-entry-submit; background: $light-color;
color: #fff;
text-align: center;
padding: 24rpx;
border-radius: 40rpx;
font-size: 30rpx;
}
.submit.disabled {
opacity: 0.5;
} }
</style> </style>

View File

@@ -1,496 +1,402 @@
<template> <template>
<view class="wrapper"> <view class="page">
<!-- 筛选弹出层 TODO后续版本更新 --> <view class="search-bar">
<!-- <u-popup width="90%" v-model:show="popup" mode="right"> <view class="search-inner">
<view class="screen-title">商品筛选</view> <u-icon name="search" color="#bbb" size="18" />
<input
<view class="screen-view"> class="search-input"
<view class="screen-item"> v-model="keyword"
<h4>价格区间</h4> confirm-type="search"
<view class="flex"> placeholder="商品搜索: 请输入商品关键字"
<u-input class="u-bg" placeholder-style="font-size:22rpx;" type="number" input-align="center" placeholder="最低价"></u-input> placeholder-class="search-placeholder"
<view class="line"></view> @confirm="onSearch"
<u-input class="u-bg" placeholder-style="font-size:22rpx;" type="number" input-align="center" placeholder="最高价"></u-input> />
</view>
</view>
<view class="screen-item">
<h4>销量</h4>
<view class="flex">
<u-input class="u-bg w200 flex1" placeholder-style="font-size:22rpx;" type="number" input-align="center" placeholder="销量"></u-input>
<view class="flex1">笔以上</view>
</view>
</view>
<view class="screen-item">
<h4>收入比率</h4>
<view class="flex">
<u-input class="u-bg" placeholder-style="font-size:22rpx;" type="number" input-align="center" placeholder="最低%"></u-input>
<view class="line"></view>
<u-input class="u-bg" placeholder-style="font-size:22rpx;" type="number" input-align="center" placeholder="最高%"></u-input>
</view>
</view>
<view class="screen-item">
<h4>包邮</h4>
<view class="flex">
<u-tag class="u-tag" shape="circle" text="包邮" mode="plain" type="info" />
</view>
</view>
<view class="screen-item">
<h4>促销活动</h4>
<view class="flex">
<u-tag class="u-tag" shape="circle" text="限时抢购" mode="plain" type="info" />
<u-tag class="u-tag" shape="circle" text="拼团秒杀" mode="plain" type="info" />
</view>
</view>
<view class="screen-item">
<h4>经营类型</h4>
<view class="flex">
<u-tag class="u-tag" shape="circle" text="平台自营" mode="plain" type="info" />
<u-tag class="u-tag" shape="circle" text="三方店铺" mode="plain" type="info" />
</view>
</view>
</view>
<view class="screen-btn">
<view class="screen-clear"> 重置 </view>
<view class="screen-submit"> 确定 </view>
</view>
</u-popup> -->
<!-- 导航栏 -->
<view class="nav">
<view class="nav-item" @click="toggleGoodsTab(true)" :class="{ checked: queryParams.checked }">已选择</view>
<view class="nav-item" @click="toggleGoodsTab(false)" :class="{ checked: !queryParams.checked }">未选择</view>
<!-- <view class="nav-item" @click="popup = !popup">筛选</view> -->
</view>
<!-- 商品列表 -->
<view class="goods-list">
<scroll-view class="body-view" scroll-y @scrolltolower="loadMore">
<block v-for="(item, index) in goodsList" :key="item.id">
<u-swipe-action v-if="queryParams.checked" class="distribution-swipe">
<u-swipe-action-item
:show="item.___selected"
@open="openSwipeAction(item)"
:name="index"
:options="swipeOptions"
@click="confirmUnbindPrompt(item)"
>
<view class="goods-item">
<view class="goods-item-img" @click="navigateToGoods(item)">
<u-image width="176rpx" height="176rpx" :src="item.thumbnail"></u-image>
</view>
<view class="goods-item-desc">
<view class="-item-title" @click="navigateToGoods(item)">
{{ item.goodsName }}
</view>
<view class="-item-price" @click="navigateToGoods(item)">
佣金:
<span> {{ unitPrice(item.commission) }}</span>
</view>
<view class="-item-bottom">
<view class="-item-bootom-money" @click="navigateToGoods(item)">
<view class="-item-yj">
<span>{{ unitPrice(item.price) }}</span>
</view>
</view>
<view>
<view class="click" @click="shareDistributionGoods(item)">分销商品</view>
</view>
</view>
</view>
</view>
</u-swipe-action-item>
</u-swipe-action>
<view v-else class="goods-item">
<view class="goods-item-img" @click="navigateToGoods(item)">
<u-image width="176rpx" height="176rpx" :src="item.thumbnail"></u-image>
</view>
<view class="goods-item-desc">
<view class="-item-title" @click="navigateToGoods(item)">
{{ item.goodsName }}
</view>
<view class="-item-price" @click="navigateToGoods(item)">
佣金:
<span> {{ unitPrice(item.commission) }}</span>
</view>
<view class="-item-bottom">
<view class="-item-bootom-money" @click="navigateToGoods(item)">
<view class="-item-yj">
<span>{{ unitPrice(item.price) }}</span>
</view>
</view>
<view>
<view class="click" @click="selectGoods(item)">立即选取</view>
</view>
</view>
</view>
</view>
</block>
</scroll-view>
<view class="empty">
<!-- <u-empty v-if="empty" text="没有分销商品了" mode="list"></u-empty> -->
</view> </view>
</view> </view>
<canvas class="canvas-hide" canvas-id="qrcode" />
<drawCanvas ref="drawCanvasRef" v-if="showPoster" :res="posterData" />
<u-modal
v-model:show="showUnbindModal"
:confirm-style="{ color: lightColor }"
@confirm="confirmUnbind"
show-cancel-button
:content="unbindModalContent"
:async-close="true"
></u-modal>
<view class="sort-bar">
<view
v-for="item in sortTabs"
:key="item.field"
class="sort-item"
:class="{ active: sortField === item.field }"
@click="onSortTab(item.field)"
>
<text>{{ item.label }}</text>
<view class="sort-arrows">
<image
class="arrow-img"
:src="sortField === item.field && sortOrder === 'ASC' ? '/static/index/arrow-up-1.png' : '/static/index/arrow-up.png'"
mode="aspectFit"
/>
<image
class="arrow-img"
:src="sortField === item.field && sortOrder === 'DESC' ? '/static/index/arrow-down-1.png' : '/static/index/arrow-down.png'"
mode="aspectFit"
/>
</view>
</view>
</view>
<scroll-view
class="goods-scroll"
scroll-y
:lower-threshold="80"
@scrolltolower="loadMore"
>
<view v-if="loading && !goodsList.length" class="state-wrap">
<text class="state-text">加载中...</text>
</view>
<view v-else-if="!goodsList.length" class="state-wrap">
<text class="state-text">暂无推广商品</text>
</view>
<view v-else class="goods-list">
<view
v-for="item in goodsList"
:key="item.id"
class="goods-card"
>
<image
class="goods-image"
:src="item.thumbnail"
mode="aspectFill"
@click="goGoodsDetail(item)"
/>
<view class="goods-body">
<view class="goods-name" @click="goGoodsDetail(item)">{{ item.goodsName }}</view>
<view class="goods-detail-row">
<view class="goods-info" @click="goGoodsDetail(item)">
<view class="goods-earn"> ¥{{ formatMoney(item.commission) }}</view>
<view class="goods-price">¥{{ formatMoney(item.price) }}</view>
</view>
<view class="share-btn" @click.stop="shareGoods(item)">立即分享</view>
</view>
</view>
</view>
</view>
<view v-if="goodsList.length" class="load-more">
<text>{{ finished ? '没有更多了' : (loadingMore ? '加载中...' : '上拉加载更多') }}</text>
</view>
</scroll-view>
</view> </view>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app' import { onLoad } from '@dcloudio/uni-app'
import { useStore } from '@/store' import { distributionGoods, getDistributionGoodsSetting } from '@/api/distribution'
import {
distributionGoods,
checkedDistributionGoods,
getMpCode,
} from '@/api/goods'
import drawCanvas from '@/components/m-canvas'
import { unitPrice } from '@/utils/filters.js'
const store = useStore() const DEFAULT_SORT_MAP: Record<string, { field: string; order: string }> = {
const lightColor = computed(() => store.getters.lightColor) HIGHEST_COMMISSION: { field: 'COMMISSION', order: 'DESC' },
const swipeOptions = computed(() => [ HIGHEST_PRICE: { field: 'PRICE', order: 'DESC' },
{ HIGHEST_SALES: { field: 'SALES', order: 'DESC' },
text: '解绑', LATEST_LISTING: { field: 'LATEST', order: 'DESC' },
style: { backgroundColor: lightColor.value }, }
},
]) const sortTabs = [
{ field: 'COMMISSION', label: '佣金' },
{ field: 'LATEST', label: '最新' },
{ field: 'SALES', label: '销量' },
{ field: 'PRICE', label: '价格' },
]
const unbindModalContent = '解绑该商品?'
const showPoster = ref(false)
const showUnbindModal = ref(false)
const routeQuery = ref<Record<string, string>>({}) const routeQuery = ref<Record<string, string>>({})
const selectedGoods = ref<any>(null) const keyword = ref('')
const drawCanvasRef = ref<any>(null) const sortField = ref('COMMISSION')
const sortOrder = ref<'ASC' | 'DESC'>('DESC')
const goodsList = ref<any[]>([])
const loading = ref(false)
const loadingMore = ref(false)
const finished = ref(false)
const queryParams = ref({ const queryParams = ref({
pageNumber: 1, pageNumber: 1,
pageSize: 10, pageSize: 10,
checked: true, goodsName: '',
sortField: 'COMMISSION',
sortOrder: 'DESC',
}) })
const goodsList = ref<any[]>([])
const posterData = ref({ const distributionId = computed(() => routeQuery.value.id || '')
container: {
width: 600,
height: 960,
background: '#fff',
title: '分享背景',
},
bottom: {
img: '',
code: '',
price: 0,
desc: '',
},
})
onLoad((options) => { onLoad((options) => {
routeQuery.value = options || {} routeQuery.value = (options || {}) as Record<string, string>
initPage()
}) })
onShow(() => { async function initPage() {
goodsList.value = [] await loadGoodsSetting()
queryParams.value.pageNumber = 1 resetAndFetch()
fetchGoodsList()
})
function confirmUnbindPrompt(item: any) {
showUnbindModal.value = true
selectedGoods.value = item
} }
function confirmUnbind() { async function loadGoodsSetting() {
checkedDistributionGoods({ id: selectedGoods.value.id, checked: false }).then((res) => { try {
if (res.data.success) { const res = await getDistributionGoodsSetting()
uni.showToast({ title: '此商品解绑成功', duration: 2000 }) if (res.data?.success && res.data.result) {
showUnbindModal.value = false const setting = res.data.result
goodsList.value = [] const mapped = DEFAULT_SORT_MAP[setting.defaultSort] || DEFAULT_SORT_MAP.HIGHEST_COMMISSION
queryParams.value.pageNumber = 1 sortField.value = mapped.field
fetchGoodsList() sortOrder.value = mapped.order as 'ASC' | 'DESC'
queryParams.value.sortField = mapped.field
queryParams.value.sortOrder = mapped.order
} }
}) } catch (error) {
console.warn('load distribution goods setting failed', error)
}
} }
function openSwipeAction(item: any) { function formatMoney(val: number | string) {
goodsList.value.forEach((row) => { const num = Number(val || 0)
row.___selected = false return num.toFixed(2)
})
item.___selected = true
} }
function navigateToGoods(item: any) { function resetAndFetch() {
goodsList.value = []
finished.value = false
queryParams.value.pageNumber = 1
queryParams.value.goodsName = keyword.value.trim()
fetchGoodsList()
}
function onSearch() {
resetAndFetch()
}
function onSortTab(field: string) {
if (sortField.value === field) {
sortOrder.value = sortOrder.value === 'DESC' ? 'ASC' : 'DESC'
} else {
sortField.value = field
sortOrder.value = 'DESC'
}
queryParams.value.sortField = sortField.value
queryParams.value.sortOrder = sortOrder.value
resetAndFetch()
}
function fetchGoodsList() {
if (queryParams.value.pageNumber > 1) {
if (loadingMore.value || finished.value) return
loadingMore.value = true
} else {
loading.value = true
}
distributionGoods(queryParams.value)
.then((res) => {
const records = res.data?.result?.records || []
const total = Number(res.data?.result?.total || 0)
if (res.data?.success) {
goodsList.value.push(...records)
finished.value = goodsList.value.length >= total || records.length < queryParams.value.pageSize
}
})
.catch(() => {
if (!goodsList.value.length) {
uni.showToast({ title: '加载失败', icon: 'none' })
}
})
.finally(() => {
loading.value = false
loadingMore.value = false
})
}
function loadMore() {
if (loading.value || loadingMore.value || finished.value) return
queryParams.value.pageNumber += 1
fetchGoodsList()
}
function goGoodsDetail(item: any) {
if (!item?.skuId || !item?.goodsId) return
uni.navigateTo({ uni.navigateTo({
url: `/pages/product/goods?id=${item.skuId}&goodsId=${item.goodsId}`, url: `/pages/product/goods?id=${item.skuId}&goodsId=${item.goodsId}`,
}) })
} }
async function shareDistributionGoods(goods: any) { function shareGoods(item: any) {
uni.showToast({ title: '请请按住保存图片', duration: 2000, icon: 'none' }) const distId = distributionId.value
const page = 'pages/product/goods' if (!item?.skuId || !item?.goodsId || !distId) {
const scene = `${goods.skuId},${goods.goodsId},${routeQuery.value.id}` uni.showToast({ title: '分享信息不完整', icon: 'none' })
const result = await getMpCode({ page, scene }) return
if (result.data.success) {
const callback = result.data.result
posterData.value.container.title = `${goods.goodsName}`
posterData.value.bottom.code = `data:image/png;base64,${callback}`
posterData.value.bottom.price = unitPrice(goods.price, '¥')
posterData.value.bottom.desc = `${goods.goodsName}`
posterData.value.bottom.img = `${goods.thumbnail}`
if (showPoster.value) {
drawCanvasRef.value?.init()
}
showPoster.value = true
} else {
uni.showToast({ title: '制作二维码失败!请稍后重试', duration: 2000, icon: 'none' })
} }
} uni.navigateTo({
url: `/pages/mine/distribution/share?skuId=${item.skuId}&goodsId=${item.goodsId}&distributionId=${distId}`,
function toggleGoodsTab(isSelected: boolean) {
goodsList.value = []
queryParams.value.checked = isSelected
queryParams.value.pageNumber = 1
fetchGoodsList()
}
function selectGoods(item: any) {
checkedDistributionGoods({ id: item.id, checked: true }).then((res) => {
if (res.data.success) {
uni.showToast({ title: '已添加到我的选品库', duration: 2000, icon: 'none' })
setTimeout(() => {
goodsList.value = []
queryParams.value.pageNumber = 1
fetchGoodsList()
}, 500)
}
}) })
} }
function fetchGoodsList() {
distributionGoods(queryParams.value).then((res) => {
if (res.data.success && res.data.result.records.length >= 1) {
res.data.result.records.forEach((item: any) => {
item.___selected = false
})
goodsList.value.push(...res.data.result.records)
}
})
}
function loadMore() {
queryParams.value.pageNumber += 1
fetchGoodsList()
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.page {
.body-view { min-height: 100vh;
overflow-y: auto; background: #f5f5f5;
height: calc(100vh - 44px - 80rpx - 104rpx);
}
.canvas-hide {
/* 1 */
position: fixed;
right: 100vw;
bottom: 100vh;
/* 2 */
z-index: -9999;
/* 3 */
opacity: 0;
}
.empty {
margin: 40rpx 0;
}
.checked {
color: $main-color;
font-weight: bold;
}
.screen-btn {
display: flex; display: flex;
width: 100%; flex-direction: column;
}
.search-bar {
padding: 16rpx 24rpx;
background: #fff;
}
.search-inner {
display: flex;
align-items: center;
height: 72rpx;
padding: 0 24rpx;
border-radius: 36rpx;
background: #f5f5f5;
}
.search-input {
flex: 1;
margin-left: 12rpx;
font-size: 26rpx;
color: #333;
}
.search-placeholder {
color: #bbb;
font-size: 26rpx;
}
.sort-bar {
display: flex;
align-items: center;
height: 88rpx; height: 88rpx;
line-height: 88rpx; background: #fff;
position: fixed; border-bottom: 1rpx solid #f0f0f0;
bottom: 0; }
> .screen-clear,
.screen-submit { .sort-item {
width: 50%; flex: 1;
text-align: center; display: flex;
} align-items: center;
.screen-submit { justify-content: center;
background: $main-color; color: #666;
color: #fff; font-size: 28rpx;
&.active {
color: #ff6b35;
font-weight: 600;
} }
} }
.screen-item {
margin-bottom: 40rpx; .sort-arrows {
display: flex;
flex-direction: column;
margin-left: 6rpx;
} }
.flex1 {
padding-left: 10rpx; .arrow-img {
width: 14rpx;
height: 14rpx;
} }
.u-tag {
margin-right: 20rpx; .goods-scroll {
flex: 1;
height: calc(100vh - 200rpx);
} }
.line {
width: 40rpx; .state-wrap {
height: 2rpx; padding: 120rpx 0;
background: #999;
margin: 0 10rpx;
}
.u-bg {
background: #eff1f4;
border-radius: 0.4em;
font-size: 22rpx;
}
.screen-title {
height: 88rpx;
text-align: center; text-align: center;
font-size: 28upz;
line-height: 88rpx;
border-bottom: 1px solid #ededed;
}
.flex {
display: flex;
margin: 20rpx 0;
align-items: center;
}
.screen-view {
padding: 32rpx;
}
.bar {
padding: 0 20rpx;
display: flex;
justify-content: space-between;
align-items: center;
height: 88rpx;
width: 100%;
background: #fff;
z-index: 8;
> .bar-btn {
display: flex;
}
}
.nav {
background: #fff;
width: 100%;
display: flex;
height: 88rpx;
box-sizing: border-box;
border-top: 1px solid #ededed;
border-bottom: 1px solid #ededed;
> .nav-item {
line-height: 88rpx;
height: 88rpx;
flex: 1;
text-align: center;
position: relative;
color: #666;
}
> .nav-item.checked {
color: $main-color;
font-weight: bold;
&::after {
content: "";
position: absolute;
left: 50%;
bottom: 0;
transform: translateX(-50%);
width: 80rpx;
height: 4rpx;
background: $main-color;
border-radius: 2rpx;
}
}
} }
.distribution-swipe, .state-text {
:deep(.u-swipe-action), color: #999;
:deep(.u-swipe-action-item), font-size: 28rpx;
:deep(.u-swipe-action-item__content) {
width: 100%;
} }
:deep(.u-swipe-action-item) {
overflow: hidden;
}
:deep(.u-swipe-action-item__content) {
overflow: hidden;
}
.click {
background: $main-color;
color: #fff;
margin: 0 4rpx;
font-size: 22rpx;
padding: 10rpx 20rpx;
border-radius: 100px;
}
.goods-list { .goods-list {
// #ifdef H5 padding: 16rpx 0 24rpx;
height: calc(100vh - 176rpx);
// #endif
// #ifndef H5
height: calc(100vh - 88rpx);
// #endif
overflow: auto;
} }
.goods-item {
.goods-card {
display: flex;
margin: 0 20rpx 20rpx;
padding: 22rpx;
border-radius: 20rpx; border-radius: 20rpx;
background: #fff; background: #fff;
display: flex;
padding: 22rpx;
margin: 20rpx;
justify-content: space-between;
> .goods-item-desc {
flex: 2;
padding: 0 16rpx;
line-height: 1.7;
> .-item-bottom {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 20rpx;
> .-item-bootom-money {
> .-item-bl,
.-item-yj {
margin-right: 10rpx;
font-size: 24rpx;
color: $font-color-base;
}
}
}
> .-item-title {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
overflow: hidden;
}
> .-item-price {
color: $price-color;
> span {
font-size: 36rpx;
}
}
}
} }
.wrapper {
width: 100%; .goods-image {
flex-shrink: 0;
width: 176rpx;
height: 176rpx;
border-radius: 12rpx;
background: #f4f4f4;
}
.goods-body {
flex: 1;
min-width: 0;
padding-left: 16rpx;
display: flex;
flex-direction: column;
}
.goods-name {
color: #333;
font-size: 28rpx;
line-height: 40rpx;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
.goods-detail-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: auto;
padding-top: 10rpx;
}
.goods-info {
flex: 1;
min-width: 0;
}
.goods-earn {
display: inline-block;
padding: 4rpx 12rpx;
border-radius: 6rpx;
background: #fff0f0;
color: #ff4d4f;
font-size: 22rpx;
line-height: 1.4;
}
.goods-price {
margin-top: 10rpx;
color: #222;
font-size: 34rpx;
font-weight: 700;
line-height: 1.2;
}
.share-btn {
flex-shrink: 0;
margin-left: 12rpx;
padding: 14rpx 24rpx;
border-radius: 999rpx;
background: linear-gradient(135deg, #ff8f3f 0%, #ff6b35 100%);
color: #fff;
font-size: 24rpx;
line-height: 1;
white-space: nowrap;
}
.load-more {
padding: 24rpx 0 40rpx;
text-align: center;
color: #bbb;
font-size: 24rpx;
} }
</style> </style>

View File

@@ -0,0 +1,512 @@
<template>
<view class="page" :style="themeStyle">
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
<view class="nav-bar">
<view class="nav-back" @click="goBack">
<u-icon name="arrow-left" color="#fff" size="20" />
</view>
<view class="nav-title">分销订单详情</view>
<view class="nav-placeholder"></view>
</view>
<view class="hero">
<view class="hero-status">{{ detail.settlementStatusText || '待结算' }}</view>
<view class="hero-sub">分销订单详情</view>
</view>
<view v-if="loading" class="state-wrap">
<text class="state-text">加载中...</text>
</view>
<view v-else-if="!detail.orderItemSn" class="state-wrap">
<text class="state-text">订单不存在</text>
</view>
<view v-else class="content-card">
<view class="steps">
<view
v-for="(step, index) in progressSteps"
:key="step.key"
class="step-item"
:class="{ active: step.done, current: step.current }"
>
<view class="step-node-wrap">
<view class="step-node"></view>
<view v-if="index < progressSteps.length - 1" class="step-line"></view>
</view>
<text class="step-label">{{ step.label }}</text>
</view>
</view>
<view class="section">
<view class="section-title">
<u-icon name="bag-fill" color="#c58b4e" size="16" />
<text>物流信息</text>
</view>
<view class="logistics-card">
<view class="logistics-row">
<text class="logistics-tag">{{ deliveryTypeText }}</text>
<text class="logistics-text">{{ logisticsSummary }}</text>
</view>
<view class="logistics-trace">{{ logisticsTraceText }}</view>
</view>
</view>
<view class="buyer-row">买家{{ detail.memberName || '匿名用户' }}</view>
<view class="goods-card">
<image class="goods-image" :src="resolveImage(detail.image)" mode="aspectFill" />
<view class="goods-info">
<view class="goods-title-row">
<text class="goods-title">{{ detail.goodsName || '商品' }}</text>
<text class="goods-num">x{{ detail.num || 1 }}</text>
</view>
<view class="goods-spec">{{ formatGoodsSpecs(detail.specs) }}</view>
</view>
</view>
<view class="amount-block">
<view class="amount-line">商品总数 {{ totalGoodsNum }} </view>
<view class="amount-line">
订单总价
<text class="amount-value">¥{{ formatMoney(detail.totalOrderPrice) }}</text>
</view>
<view v-if="showCommission" class="amount-line commission-line">
{{ detail.commissionLabel || '商品佣金' }}
<text class="amount-value">¥{{ formatMoney(detail.commissionAmount) }}</text>
</view>
<view v-if="detail.settleTime && detail.settlementStatusType === 'SETTLED'" class="settle-time">
结算时间{{ formatTime(detail.settleTime) }}
</view>
</view>
<view class="section order-info-section">
<view class="section-title with-bar">
<view class="title-bar"></view>
<text>订单信息</text>
</view>
<view class="info-row">
<text class="info-label">订单编号</text>
<text class="info-value">{{ detail.orderSn || '-' }}</text>
</view>
<view class="info-row">
<text class="info-label">下单时间</text>
<text class="info-value">{{ formatTime(detail.createTime) }}</text>
</view>
<view class="info-row">
<text class="info-label">支付时间</text>
<text class="info-value">{{ formatTime(detail.paymentTime) }}</text>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { useStore } from '@/store'
import { getThemeStyle } from '@/utils/theme'
import { getDistributionOrderDetail } from '@/api/distribution'
import { parseGoodsImageUrl, formatGoodsSpecs } from '@/utils/filters.js'
const store = useStore()
const themeStyle = computed(() => getThemeStyle(store.state.theme))
const statusBarHeight = ref(20)
const loading = ref(false)
const detail = ref<Record<string, any>>({})
const orderItemSn = ref('')
const orderType = ref('PROMOTION')
onLoad((options) => {
statusBarHeight.value = uni.getWindowInfo?.()?.statusBarHeight || 20
orderItemSn.value = options?.orderItemSn || ''
orderType.value = options?.orderType || 'PROMOTION'
loadDetail()
})
const totalGoodsNum = computed(() => Number(detail.value.totalGoodsNum || detail.value.num || 0))
const showCommission = computed(() => {
const refunded = detail.value.refunded === true || detail.value.refunded === 1
return !refunded && Number(detail.value.commissionAmount || 0) > 0
})
const deliveryTypeText = computed(() => {
const method = detail.value.deliveryMethod
if (method === 'SELF_PICK_UP') return '自提'
if (method === 'LOCAL_TOWN_DELIVERY') return '同城配送'
return '快递'
})
const logisticsSummary = computed(() => {
const name = detail.value.logisticsName
const no = detail.value.logisticsNo
if (name && no) return `${name}${no}`
if (name) return name
if (no) return no
return '暂无物流信息'
})
const logisticsTraceText = computed(() => {
if (!detail.value.logisticsNo) return '暂无物流轨迹'
return '暂无物流轨迹'
})
const progressSteps = computed(() => {
const paid = detail.value.payStatus === 'PAID'
const orderStatus = detail.value.orderStatus || ''
const delivered = ['DELIVERED', 'TAKE', 'COMPLETED', 'COMPLETE'].includes(orderStatus)
|| detail.value.deliverStatus === 'DELIVERED'
const completed = ['COMPLETED', 'COMPLETE'].includes(orderStatus)
const settled = detail.value.settlementStatusType === 'SETTLED'
const steps = [
{ key: 'paid', label: '买家付款', done: paid },
{ key: 'deliver', label: '商家发货', done: delivered },
{ key: 'complete', label: '交易完成', done: completed },
{ key: 'settle', label: '结算佣金', done: settled },
]
let currentMarked = false
return steps.map((step) => {
if (!step.done && !currentMarked) {
currentMarked = true
return { ...step, current: true }
}
return { ...step, current: false }
})
})
function loadDetail() {
if (!orderItemSn.value) return
loading.value = true
getDistributionOrderDetail({
orderItemSn: orderItemSn.value,
orderType: orderType.value,
})
.then((res) => {
detail.value = res.data?.result || {}
})
.catch(() => {
detail.value = {}
})
.finally(() => {
loading.value = false
})
}
function resolveImage(image?: string) {
return parseGoodsImageUrl(image) || '/static/nodata.png'
}
function formatMoney(val: number | string) {
return Number(val || 0).toFixed(2)
}
function formatTime(value?: string) {
if (!value) return '-'
return String(value).replace('T', ' ').slice(0, 19)
}
function goBack() {
if (getCurrentPages().length > 1) {
uni.navigateBack({ delta: 1 })
} else {
uni.navigateTo({ url: '/pages/mine/distribution/order-list' })
}
}
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background: #f5f6f8;
}
.status-bar {
background: linear-gradient(180deg, #ff9f3f 0%, #ff7a2f 100%);
}
.nav-bar {
display: flex;
align-items: center;
justify-content: space-between;
height: 88rpx;
padding: 0 24rpx;
background: linear-gradient(180deg, #ff7a2f 0%, #ff6b35 100%);
}
.nav-back,
.nav-placeholder {
width: 60rpx;
}
.nav-title {
color: #fff;
font-size: 32rpx;
font-weight: 600;
}
.hero {
padding: 12rpx 32rpx 80rpx;
background: linear-gradient(180deg, #ff6b35 0%, #ff8f3f 100%);
}
.hero-status {
color: #fff;
font-size: 48rpx;
font-weight: 700;
line-height: 1.3;
}
.hero-sub {
margin-top: 8rpx;
color: rgba(255, 255, 255, 0.85);
font-size: 26rpx;
}
.content-card {
margin: -56rpx 24rpx 32rpx;
padding: 32rpx 24rpx;
border-radius: 20rpx;
background: #fff;
}
.steps {
display: flex;
justify-content: space-between;
margin-bottom: 32rpx;
}
.step-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
}
.step-node-wrap {
position: relative;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.step-node {
width: 20rpx;
height: 20rpx;
border-radius: 50%;
background: #e8e8e8;
z-index: 1;
}
.step-line {
position: absolute;
left: 50%;
top: 50%;
width: 100%;
height: 4rpx;
margin-top: -2rpx;
background: #f0f0f0;
z-index: 0;
}
.step-item.active .step-node {
background: #ff8f3f;
}
.step-item.active .step-line {
background: #ffd8bf;
}
.step-label {
margin-top: 12rpx;
color: #bbb;
font-size: 22rpx;
text-align: center;
}
.step-item.active .step-label {
color: #ff8f3f;
}
.section {
margin-bottom: 24rpx;
}
.section-title {
display: flex;
align-items: center;
gap: 8rpx;
margin-bottom: 16rpx;
color: #333;
font-size: 28rpx;
font-weight: 600;
}
.section-title.with-bar {
gap: 12rpx;
}
.title-bar {
width: 6rpx;
height: 28rpx;
border-radius: 999rpx;
background: #ff8f3f;
}
.logistics-card {
padding: 20rpx 24rpx;
border-radius: 12rpx;
background: #fafafa;
}
.logistics-row {
display: flex;
align-items: center;
gap: 12rpx;
}
.logistics-tag {
padding: 4rpx 12rpx;
border-radius: 6rpx;
background: #fff2e8;
color: #ff8f3f;
font-size: 22rpx;
}
.logistics-text {
flex: 1;
color: #666;
font-size: 24rpx;
}
.logistics-trace {
margin-top: 12rpx;
color: #ccc;
font-size: 22rpx;
}
.buyer-row {
margin-bottom: 20rpx;
color: #333;
font-size: 28rpx;
font-weight: 600;
}
.goods-card {
display: flex;
margin-bottom: 24rpx;
}
.goods-image {
width: 120rpx;
height: 120rpx;
border-radius: 12rpx;
background: #f4f4f4;
flex-shrink: 0;
}
.goods-info {
flex: 1;
min-width: 0;
margin-left: 16rpx;
}
.goods-title-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12rpx;
}
.goods-title {
flex: 1;
color: #333;
font-size: 28rpx;
line-height: 1.4;
}
.goods-num {
color: #999;
font-size: 24rpx;
flex-shrink: 0;
}
.goods-spec {
margin-top: 8rpx;
color: #999;
font-size: 24rpx;
}
.amount-block {
text-align: right;
margin-bottom: 28rpx;
}
.amount-line {
color: #666;
font-size: 24rpx;
line-height: 1.9;
}
.commission-line {
color: #333;
font-size: 28rpx;
}
.amount-value {
color: #ff4d4f;
font-size: 34rpx;
font-weight: 700;
margin-left: 8rpx;
}
.settle-time {
margin-top: 4rpx;
color: #ccc;
font-size: 22rpx;
}
.order-info-section {
padding-top: 8rpx;
border-top: 1rpx solid #f5f5f5;
}
.info-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24rpx;
padding: 18rpx 0;
border-bottom: 1rpx solid #f8f8f8;
}
.info-row:last-child {
border-bottom: none;
}
.info-label {
color: #999;
font-size: 26rpx;
flex-shrink: 0;
}
.info-value {
flex: 1;
color: #333;
font-size: 26rpx;
text-align: right;
word-break: break-all;
}
.state-wrap {
padding: 120rpx 0;
text-align: center;
}
.state-text {
color: #999;
font-size: 26rpx;
}
</style>

View File

@@ -0,0 +1,710 @@
<template>
<view class="page" :style="themeStyle">
<view class="search-bar">
<view class="search-inner">
<u-icon name="search" color="#bbb" size="18" />
<input
class="search-input"
v-model="keyword"
confirm-type="search"
placeholder="输入用户手机号、订单号或商品名"
placeholder-class="search-placeholder"
@confirm="onSearch"
/>
</view>
</view>
<view class="main-tabs">
<view
v-for="item in mainTabs"
:key="item.value"
class="main-tab"
:class="{ active: activeTab === item.value }"
@click="changeTab(item.value)"
>
{{ item.label }}
</view>
</view>
<view class="filter-bar">
<scroll-view scroll-x class="filter-scroll" :show-scrollbar="false">
<view class="filter-tabs">
<view
v-for="item in rangeTabs"
:key="item.value"
class="filter-tab"
:class="{ active: activeRange === item.value }"
@click="changeRange(item.value)"
>
{{ item.label }}
</view>
</view>
</scroll-view>
<view class="custom-time" :class="{ active: activeRange === 'CUSTOM' }" @click="openCustomPicker">
<text>自定义时间</text>
<u-icon name="arrow-down" size="12" :color="activeRange === 'CUSTOM' ? '#ff8f3f' : '#999'" />
</view>
</view>
<view class="summary-row">
<text>
<text class="summary-highlight">{{ orderTotal }}</text>
笔订单获得{{ commissionLabel }}
<text class="summary-highlight">{{ formatMoney(totalCommission) }}</text>
</text>
</view>
<view v-if="loading && !orderList.length" class="state-wrap">
<text class="state-text">加载中...</text>
</view>
<view v-else-if="!orderList.length" class="state-wrap">
<text class="state-text">暂无订单记录</text>
</view>
<view v-else class="list-wrap">
<view class="order-card" v-for="item in orderList" :key="item.orderItemSn || item.orderSn">
<view class="card-header">
<text class="buyer-name">买家{{ item.memberName || '匿名用户' }}</text>
<text class="status-text" :class="statusClass(item)">{{ item.settlementStatusText }}</text>
</view>
<view class="order-time">下单时间{{ formatTime(item.createTime) }}</view>
<view v-if="isRefunded(item)" class="refund-tag">已退款</view>
<view class="goods-row">
<image class="goods-image" :src="resolveImage(item.image)" mode="aspectFill" />
<view class="goods-info">
<view class="goods-title-row">
<text class="goods-title">{{ item.goodsName || '商品' }}</text>
<text class="goods-num">x{{ item.num || 1 }}</text>
</view>
<view class="goods-spec">{{ formatGoodsSpecs(item.specs) }}</view>
</view>
</view>
<view class="amount-block">
<view class="amount-line">实付金额¥ {{ formatMoney(item.finalPrice) }}</view>
<view
v-if="showCommission(item)"
class="amount-line commission-line"
>
{{ commissionLabel }}
<text class="commission-value">{{ formatMoney(item.commissionAmount) }} </text>
</view>
<view v-if="item.settleTime && item.settlementStatusType === 'SETTLED'" class="settle-time">
结算时间{{ formatTime(item.settleTime) }}
</view>
</view>
<view class="card-footer">
<text class="order-sn">单号{{ item.orderSn }}</text>
<text class="detail-btn" @click="goOrderDetail(item)">订单详情</text>
</view>
</view>
</view>
<view v-if="orderList.length" class="list-footer">
<text v-if="finished" class="footer-text">没有更多数据了</text>
<text v-else-if="loadingMore" class="footer-text">加载中...</text>
</view>
<u-popup v-model:show="customVisible" mode="bottom" round="16">
<view class="custom-popup">
<view class="popup-title">自定义时间</view>
<view class="popup-row">
<text class="popup-label">开始日期</text>
<picker mode="date" :value="customStartDate" @change="onStartDateChange">
<view class="picker-value">{{ customStartDate || '请选择' }}</view>
</picker>
</view>
<view class="popup-row">
<text class="popup-label">结束日期</text>
<picker mode="date" :value="customEndDate" @change="onEndDateChange">
<view class="picker-value">{{ customEndDate || '请选择' }}</view>
</picker>
</view>
<view class="popup-actions">
<view class="popup-btn ghost" @click="customVisible = false">取消</view>
<view class="popup-btn primary" @click="applyCustomRange">确定</view>
</view>
</view>
</u-popup>
</view>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { onReachBottom, onShow, onLoad } from '@dcloudio/uni-app'
import { useStore } from '@/store'
import { getThemeStyle } from '@/utils/theme'
import { getDistributionOrders } from '@/api/distribution'
import { parseGoodsImageUrl, formatGoodsSpecs } from '@/utils/filters.js'
const store = useStore()
const themeStyle = computed(() => getThemeStyle(store.state.theme))
const mainTabs = [
{ label: '推广订单', value: 'PROMOTION' },
{ label: '邀请订单', value: 'INVITE' },
]
const rangeTabs = [
{ label: '全部', value: 'ALL' },
{ label: '今日', value: 'TODAY' },
{ label: '昨日', value: 'YESTERDAY' },
{ label: '近七日', value: 'LAST_7_DAYS' },
]
const activeTab = ref('PROMOTION')
const activeRange = ref('ALL')
const keyword = ref('')
const loading = ref(false)
const loadingMore = ref(false)
const finished = ref(false)
const orderTotal = ref(0)
const totalCommission = ref(0)
const orderList = ref<any[]>([])
const pageNumber = ref(1)
const pageSize = 10
const customVisible = ref(false)
const customStartDate = ref('')
const customEndDate = ref('')
const RANGE_VALUES = ['ALL', 'TODAY', 'YESTERDAY', 'LAST_7_DAYS', 'CUSTOM']
const ORDER_TYPE_VALUES = ['PROMOTION', 'INVITE']
function applyRouteOptions(options: Record<string, string | undefined> = {}) {
const orderType = options.orderType
if (orderType && ORDER_TYPE_VALUES.includes(orderType)) {
activeTab.value = orderType
} else {
activeTab.value = 'PROMOTION'
}
const rangeType = options.rangeType
if (rangeType && RANGE_VALUES.includes(rangeType)) {
activeRange.value = rangeType
} else {
activeRange.value = 'ALL'
}
}
onLoad((options) => {
applyRouteOptions(options as Record<string, string>)
})
onShow(() => {
const pages = getCurrentPages()
const current = pages[pages.length - 1] as { options?: Record<string, string> }
if (current?.options) {
applyRouteOptions(current.options)
}
resetAndLoad()
})
onReachBottom(() => {
loadOrders(false)
})
function buildQueryParams() {
const params: Record<string, any> = {
pageNumber: pageNumber.value,
pageSize,
rangeType: activeRange.value,
orderType: activeTab.value,
}
const searchKeyword = keyword.value.trim()
if (searchKeyword) {
params.keyword = searchKeyword
}
if (activeRange.value === 'CUSTOM') {
if (customStartDate.value) {
params.startTime = `${customStartDate.value} 00:00:00`
}
if (customEndDate.value) {
params.endTime = `${customEndDate.value} 23:59:59`
}
}
return params
}
const commissionLabel = computed(() => (activeTab.value === 'INVITE' ? '邀请佣金' : '商品佣金'))
function resetAndLoad() {
pageNumber.value = 1
finished.value = false
orderList.value = []
loadOrders(true)
}
function loadOrders(reset = false) {
if (reset) {
loading.value = true
pageNumber.value = 1
finished.value = false
} else if (loading.value || loadingMore.value || finished.value) {
return
} else {
loadingMore.value = true
}
getDistributionOrders(buildQueryParams())
.then((res) => {
const result = res.data?.result || {}
const records = result.records || []
orderTotal.value = Number(result.total || 0)
totalCommission.value = Number(result.totalCommission || 0)
if (reset) {
orderList.value = records
} else {
orderList.value = orderList.value.concat(records)
}
if (records.length < pageSize || orderList.value.length >= orderTotal.value) {
finished.value = true
} else {
pageNumber.value += 1
}
})
.finally(() => {
loading.value = false
loadingMore.value = false
})
}
function changeTab(value: string) {
if (activeTab.value === value) return
activeTab.value = value
resetAndLoad()
}
function changeRange(value: string) {
if (activeRange.value === value) return
activeRange.value = value
resetAndLoad()
}
function onSearch() {
resetAndLoad()
}
function openCustomPicker() {
customVisible.value = true
}
function onStartDateChange(event: any) {
customStartDate.value = event.detail.value
}
function onEndDateChange(event: any) {
customEndDate.value = event.detail.value
}
function applyCustomRange() {
if (!customStartDate.value || !customEndDate.value) {
uni.showToast({ title: '请选择开始和结束日期', icon: 'none' })
return
}
if (customStartDate.value > customEndDate.value) {
uni.showToast({ title: '开始日期不能晚于结束日期', icon: 'none' })
return
}
activeRange.value = 'CUSTOM'
customVisible.value = false
resetAndLoad()
}
function resolveImage(image?: string) {
return parseGoodsImageUrl(image) || '/static/nodata.png'
}
function formatMoney(val: number | string) {
return Number(val || 0).toFixed(2)
}
function formatTime(value?: string) {
if (!value) return '-'
return String(value).replace('T', ' ').slice(0, 19)
}
function statusClass(item: any) {
if (item.settlementStatusType === 'SETTLED') return 'status-settled'
if (item.settlementStatusType === 'PENDING') return 'status-pending'
return 'status-not-settle'
}
function isRefunded(item: any) {
return item.refunded === true || item.refunded === 1 || item.refunded === '1'
}
function showCommission(item: any) {
return !isRefunded(item) && Number(item.commissionAmount || 0) > 0
}
function goOrderDetail(item: any) {
if (!item?.orderItemSn) return
uni.navigateTo({
url: `/pages/mine/distribution/order-detail?orderItemSn=${item.orderItemSn}&orderType=${activeTab.value}`,
})
}
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background: #f5f6f8;
}
.search-bar {
padding: 16rpx 24rpx;
background: #fff;
}
.search-inner {
display: flex;
align-items: center;
height: 72rpx;
padding: 0 24rpx;
border-radius: 999rpx;
background: #f5f5f5;
}
.search-input {
flex: 1;
margin-left: 12rpx;
color: #333;
font-size: 26rpx;
}
.search-placeholder {
color: #bbb;
font-size: 26rpx;
}
.main-tabs {
display: flex;
background: #fff;
border-bottom: 1rpx solid #f0f0f0;
}
.main-tab {
flex: 1;
position: relative;
padding: 24rpx 0;
text-align: center;
color: #666;
font-size: 30rpx;
}
.main-tab.active {
color: #ff8f3f;
font-weight: 600;
}
.main-tab.active::after {
content: '';
position: absolute;
left: 50%;
bottom: 0;
width: 56rpx;
height: 6rpx;
margin-left: -28rpx;
border-radius: 999rpx;
background: #ff8f3f;
}
.filter-bar {
display: flex;
align-items: center;
padding: 20rpx 24rpx;
background: #fff;
border-bottom: 1rpx solid #f0f0f0;
}
.filter-scroll {
flex: 1;
white-space: nowrap;
}
.filter-tabs {
display: inline-flex;
align-items: center;
gap: 16rpx;
}
.filter-tab {
padding: 10rpx 24rpx;
border-radius: 999rpx;
color: #666;
font-size: 26rpx;
background: #f5f5f5;
}
.filter-tab.active {
color: #ff8f3f;
background: #fff2e8;
font-weight: 600;
}
.custom-time {
display: flex;
align-items: center;
gap: 6rpx;
margin-left: 16rpx;
color: #666;
font-size: 24rpx;
flex-shrink: 0;
}
.custom-time.active {
color: #ff8f3f;
font-weight: 600;
}
.summary-row {
padding: 16rpx 24rpx 20rpx;
color: #999;
font-size: 24rpx;
background: #f5f6f8;
}
.summary-highlight {
color: #ff8f3f;
font-weight: 600;
}
.list-wrap {
padding: 0 24rpx 24rpx;
}
.order-card {
margin-bottom: 20rpx;
padding: 24rpx;
border-radius: 16rpx;
background: #fff;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.buyer-name {
color: #333;
font-size: 28rpx;
font-weight: 600;
}
.status-text {
font-size: 24rpx;
}
.status-settled {
color: #52c41a;
}
.status-pending {
color: #ff8f3f;
}
.status-not-settle {
color: #999;
}
.order-time {
margin-top: 12rpx;
color: #999;
font-size: 24rpx;
}
.refund-tag {
display: inline-block;
margin-top: 12rpx;
color: #ff4d4f;
font-size: 24rpx;
}
.goods-row {
display: flex;
margin-top: 20rpx;
}
.goods-image {
width: 120rpx;
height: 120rpx;
border-radius: 12rpx;
background: #f4f4f4;
flex-shrink: 0;
}
.goods-info {
flex: 1;
min-width: 0;
margin-left: 16rpx;
}
.goods-title-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12rpx;
}
.goods-title {
flex: 1;
color: #333;
font-size: 28rpx;
line-height: 1.4;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
.goods-num {
color: #999;
font-size: 24rpx;
flex-shrink: 0;
}
.goods-spec {
margin-top: 8rpx;
color: #999;
font-size: 24rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.amount-block {
margin-top: 20rpx;
text-align: right;
}
.amount-line {
color: #666;
font-size: 24rpx;
line-height: 1.8;
}
.commission-line {
color: #333;
font-size: 26rpx;
}
.commission-value {
color: #ff8f3f;
font-size: 32rpx;
font-weight: 700;
}
.settle-time {
margin-top: 4rpx;
color: #bbb;
font-size: 22rpx;
}
.card-footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 20rpx;
padding-top: 20rpx;
border-top: 1rpx solid #f5f5f5;
}
.order-sn {
flex: 1;
min-width: 0;
color: #ccc;
font-size: 22rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.detail-btn {
margin-left: 16rpx;
color: #ff8f3f;
font-size: 26rpx;
flex-shrink: 0;
}
.state-wrap {
padding: 120rpx 0;
text-align: center;
}
.state-text {
color: #999;
font-size: 26rpx;
}
.list-footer {
padding: 12rpx 0 32rpx;
text-align: center;
}
.footer-text {
color: #ccc;
font-size: 24rpx;
}
.custom-popup {
padding: 32rpx 28rpx 40rpx;
}
.popup-title {
margin-bottom: 24rpx;
text-align: center;
color: #333;
font-size: 30rpx;
font-weight: 600;
}
.popup-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx 0;
border-bottom: 1rpx solid #f5f5f5;
}
.popup-label {
color: #666;
font-size: 28rpx;
}
.picker-value {
color: #333;
font-size: 28rpx;
}
.popup-actions {
display: flex;
gap: 20rpx;
margin-top: 32rpx;
}
.popup-btn {
flex: 1;
height: 80rpx;
line-height: 80rpx;
text-align: center;
border-radius: 999rpx;
font-size: 28rpx;
}
.popup-btn.ghost {
color: #666;
background: #f5f5f5;
}
.popup-btn.primary {
color: #fff;
background: linear-gradient(90deg, #ff9f3f, #ff6b35);
}
</style>

View File

@@ -0,0 +1,428 @@
<template>
<view class="wrapper" :style="themeStyle">
<view v-if="pageLoading" class="state-wrap">
<text class="state-text">{{ pageLoadingText }}</text>
</view>
<view v-else-if="pageError" class="state-wrap">
<text class="state-text">{{ pageError }}</text>
<view class="retry-btn" @click="loadPosterSetting">重新加载</view>
</view>
<view v-else class="card" :class="{ 'card--has-bg': hasBackground }">
<image
v-if="hasBackground"
class="card-bg"
:src="backgroundUrl"
mode="aspectFill"
/>
<view class="card-content">
<view v-if="showMemberInfo" class="member-info">
<image
v-if="posterContext.memberAvatar"
class="member-avatar"
:src="avatarUrl"
mode="aspectFill"
/>
<view class="member-name" :style="textStyle">{{ posterContext.memberName || '分销员' }}</view>
</view>
<view v-if="posterContext.slogan" class="slogan" :style="textStyle">{{ posterContext.slogan }}</view>
<view class="qr-wrap" @click="previewQrCode">
<view v-if="qrLoading" class="qr-state">
<text class="qr-state-text">{{ qrLoadingText }}</text>
</view>
<view v-else-if="qrError" class="qr-state">
<text class="qr-state-text">{{ qrError }}</text>
<view class="retry-btn small" @click.stop="loadQrCode">重新生成</view>
</view>
<image
v-else-if="qrImage"
class="qr-image"
:src="qrImage"
mode="aspectFit"
show-menu-by-longpress
@error="onQrImageError"
/>
</view>
</view>
</view>
<view v-if="!pageLoading && !pageError" class="footer-actions">
<!-- #ifdef MP-WEIXIN -->
<button class="btn primary share-btn" open-type="share" :disabled="!qrImage">
<text class="btn-text">微信分享</text>
</button>
<!-- #endif -->
<view class="hint-text">长按图片保存海报</view>
</view>
<canvas canvas-id="qrCanvas" class="qr-canvas" />
</view>
</template>
<script setup lang="ts">
import { ref, computed, getCurrentInstance } from 'vue'
import { onLoad, onShareAppMessage } from '@dcloudio/uni-app'
import { useStore } from '@/store'
import { getThemeStyle } from '@/utils/theme'
import { getPromotionPoster } from '@/api/distribution'
import { getMpCode } from '@/api/goods'
import { parseGoodsImageUrl } from '@/utils/filters.js'
const DEFAULT_HOME_PAGE = 'pages/tabbar/home/index'
const QR_CANVAS_SIZE = 280
const instance = getCurrentInstance()
const store = useStore()
const themeStyle = computed(() => getThemeStyle(store.state.theme))
const pageLoading = ref(true)
const pageLoadingText = ref('加载海报设置...')
const pageError = ref('')
const qrLoading = ref(false)
const qrLoadingText = ref('葵花码生成中...')
const qrError = ref('')
const qrImage = ref('')
const posterContext = ref<Record<string, any>>({})
const showMemberInfo = computed(() => posterContext.value.memberInfoVisible !== false)
const textStyle = computed(() => ({
color: posterContext.value.textColor || '#1f2a44',
}))
const avatarUrl = computed(() => parseGoodsImageUrl(posterContext.value.memberAvatar))
const backgroundUrl = computed(() => parseGoodsImageUrl(posterContext.value.backgroundImage))
const hasBackground = computed(() => !!backgroundUrl.value)
onLoad(() => {
loadPosterSetting()
})
onShareAppMessage(() => {
const ctx = posterContext.value
const sharePage = normalizeSharePage(ctx.sharePage)
return {
title: ctx.slogan || '邀请你一起逛逛',
path: `/${sharePage}`,
imageUrl: qrImage.value || '',
}
})
function normalizeSharePage(page?: string) {
const value = String(page || DEFAULT_HOME_PAGE).trim()
return value.replace(/^\//, '')
}
function writeBase64ToTemp(base64: string): Promise<string> {
return new Promise((resolve, reject) => {
const raw = String(base64).trim()
if (!raw || raw.startsWith('{')) {
reject(new Error('葵花码数据无效'))
return
}
const data = raw.replace(/^data:image\/\w+;base64,/, '')
const filePath = `${wx.env.USER_DATA_PATH}/poster_qr_${Date.now()}.png`
wx.getFileSystemManager().writeFile({
filePath,
data,
encoding: 'base64',
success: () => resolve(filePath),
fail: () => reject(new Error('葵花码写入失败')),
})
})
}
function exportQrByCanvas(qrPath: string): Promise<string> {
return new Promise((resolve, reject) => {
const ctx = uni.createCanvasContext('qrCanvas', instance?.proxy)
ctx.setFillStyle('#ffffff')
ctx.fillRect(0, 0, QR_CANVAS_SIZE, QR_CANVAS_SIZE)
ctx.drawImage(qrPath, 0, 0, QR_CANVAS_SIZE, QR_CANVAS_SIZE)
ctx.draw(false, () => {
setTimeout(() => {
uni.canvasToTempFilePath(
{
canvasId: 'qrCanvas',
width: QR_CANVAS_SIZE,
height: QR_CANVAS_SIZE,
destWidth: QR_CANVAS_SIZE,
destHeight: QR_CANVAS_SIZE,
success: (file) => resolve(file.tempFilePath),
fail: () => reject(new Error('葵花码导出失败')),
},
instance?.proxy
)
}, 300)
})
})
}
async function resolveQrDisplay(result: string) {
const tempPath = await writeBase64ToTemp(result)
qrImage.value = await exportQrByCanvas(tempPath)
}
async function loadPosterSetting() {
pageLoading.value = true
pageLoadingText.value = '加载海报设置...'
pageError.value = ''
qrImage.value = ''
qrError.value = ''
try {
const res = await getPromotionPoster()
if (!res.data?.success || !res.data?.result) {
throw new Error(res.data?.message || '获取海报设置失败')
}
const result = res.data.result
posterContext.value = {
distributionId: result.distributionId || '',
memberName: result.memberName || '',
memberAvatar: result.memberAvatar || '',
backgroundImage: result.backgroundImage || '',
slogan: result.slogan || '发现好物,邀请你一起逛逛',
memberInfoVisible: result.memberInfoVisible !== false,
textColor: result.textColor || '#1f2a44',
sharePage: result.sharePage || DEFAULT_HOME_PAGE,
shareScene: result.shareScene || '',
}
} catch (error: any) {
console.error('load poster setting failed', error)
pageError.value = error?.message || '获取海报设置失败,请稍后重试'
return
} finally {
pageLoading.value = false
}
loadQrCode()
}
async function loadQrCode() {
if (!posterContext.value.distributionId && !posterContext.value.shareScene) {
qrError.value = '分销员信息异常,无法生成葵花码'
return
}
qrLoading.value = true
qrLoadingText.value = '葵花码生成中...'
qrError.value = ''
qrImage.value = ''
try {
const ctx = posterContext.value
const codeRes = await getMpCode({
page: normalizeSharePage(ctx.sharePage),
scene: ctx.shareScene || `bind,${ctx.distributionId}`,
})
if (!codeRes.data?.success || !codeRes.data?.result) {
throw new Error(codeRes.data?.message || '葵花码生成失败')
}
await resolveQrDisplay(codeRes.data.result)
} catch (error: any) {
console.error('load qr code failed', error)
qrError.value = error?.message || '葵花码生成失败,请稍后重试'
} finally {
qrLoading.value = false
}
}
function onQrImageError() {
qrError.value = '葵花码图片加载失败'
qrImage.value = ''
}
function previewQrCode() {
if (!qrImage.value) return
uni.previewImage({
current: qrImage.value,
urls: [qrImage.value],
})
}
</script>
<style lang="scss" scoped>
.wrapper {
min-height: 100vh;
padding: 24rpx;
box-sizing: border-box;
background: #f5f5f5;
}
.state-wrap {
padding: 160rpx 0;
text-align: center;
}
.state-text {
color: #999;
font-size: 28rpx;
}
.retry-btn {
display: inline-block;
margin-top: 24rpx;
padding: 12rpx 40rpx;
border-radius: 999rpx;
background: linear-gradient(90deg, #ff9f3f, #ff6b35);
color: #fff;
font-size: 26rpx;
}
.retry-btn.small {
margin-top: 16rpx;
padding: 8rpx 28rpx;
font-size: 24rpx;
}
.card {
position: relative;
background: #fff;
border-radius: 16rpx;
overflow: hidden;
min-height: 720rpx;
}
.card--has-bg {
background: transparent;
}
.card-bg {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
z-index: 0;
}
.card-content {
position: relative;
z-index: 1;
padding: 32rpx 24rpx;
text-align: center;
}
.card--has-bg .card-content {
background: rgba(255, 255, 255, 0.12);
}
.member-info {
display: flex;
flex-direction: column;
align-items: center;
}
.member-avatar {
width: 96rpx;
height: 96rpx;
border-radius: 50%;
margin-bottom: 16rpx;
border: 2rpx solid rgba(255, 255, 255, 0.8);
}
.member-name {
font-size: 34rpx;
font-weight: bold;
}
.slogan {
margin-top: 12rpx;
font-size: 26rpx;
line-height: 1.6;
padding: 0 16rpx;
}
.qr-wrap {
display: flex;
justify-content: center;
margin-top: 40rpx;
}
.qr-state {
width: 420rpx;
height: 420rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.92);
border-radius: 16rpx;
}
.qr-state-text {
color: #999;
font-size: 26rpx;
padding: 0 24rpx;
text-align: center;
}
.qr-image {
width: 420rpx;
height: 420rpx;
background: #fff;
border-radius: 16rpx;
}
.qr-canvas {
position: fixed;
left: -9999px;
top: -9999px;
width: 280px;
height: 280px;
}
.footer-actions {
margin-top: 24rpx;
}
.btn {
width: 100%;
text-align: center;
padding: 20rpx 0;
border-radius: 40rpx;
border: 1px solid $light-color;
color: $light-color;
font-size: 28rpx;
box-sizing: border-box;
}
.btn.primary {
background: $light-color;
color: #fff;
border-color: $light-color;
}
.btn.disabled {
opacity: 0.5;
}
.share-btn {
display: block;
width: 100%;
margin: 0;
padding: 0;
line-height: normal;
font-size: inherit;
}
.share-btn::after {
border: none;
}
.btn-text {
display: block;
padding: 20rpx 0;
color: #fff;
font-size: 28rpx;
}
.hint-text {
margin-top: 24rpx;
color: #b3b3b3;
font-size: 24rpx;
text-align: center;
}
</style>

View File

@@ -0,0 +1,28 @@
<template>
<view class="page">
<distribution-goods-share
:sku-id="query.skuId"
:goods-id="query.goodsId"
:distribution-id="query.distributionId"
/>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import DistributionGoodsShare from '@/components/distribution-goods-share/index.vue'
const query = ref<Record<string, string>>({})
onLoad((options) => {
query.value = (options || {}) as Record<string, string>
})
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background: #fff;
}
</style>

View File

@@ -29,7 +29,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, getCurrentInstance, onMounted } from 'vue' import { ref, computed, getCurrentInstance, onMounted } from 'vue'
import { useStore } from '@/store' import { useStore } from '@/store'
import { distribution, cash } from '@/api/goods' import { distribution, cash } from '@/api/distribution'
import { unitPrice } from '@/utils/filters.js' import { unitPrice } from '@/utils/filters.js'
import { getThemeStyle } from '@/utils/theme' import { getThemeStyle } from '@/utils/theme'

View File

@@ -1,8 +1,8 @@
<template> <template>
<div class="wrapper"> <div class="wrapper">
<div v-if="!wechatLogin" class="login-body"> <div v-if="!wechatLogin" class="login-body">
<u-navbar :auto-back="showBack" :border="false"></u-navbar> <u-navbar :auto-back="showBack" :border="false" :fixed="true" :placeholder="true"></u-navbar>
<div> <div class="login-header">
<div class="title">{{ loginTitleWay[current].title }}</div> <div class="title">{{ loginTitleWay[current].title }}</div>
<div :class="current == 1 ? 'desc-light' : 'desc'"> <div :class="current == 1 ? 'desc-light' : 'desc'">
{{ loginTitleWay[current].desc {{ loginTitleWay[current].desc
@@ -10,7 +10,7 @@
</div> </div>
</div> </div>
<!-- 手机号 --> <!-- 手机号 -->
<div v-show="!enableUserPwdBox"> <div v-show="!enableUserPwdBox" class="login-form-block">
<div v-show="current == 0"> <div v-show="current == 0">
<u-input <u-input
border="none" border="none"
@@ -51,7 +51,8 @@
</div> </div>
</div> </div>
<!-- 帐号密码登录 --> <!-- 帐号密码登录小程序不展示 -->
<!-- #ifndef MP-WEIXIN -->
<div v-show="enableUserPwdBox"> <div v-show="enableUserPwdBox">
<u-input <u-input
border="none" border="none"
@@ -81,6 +82,7 @@
帐号密码登录 帐号密码登录
</div> </div>
</div> </div>
<!-- #endif -->
<div class="privacy-row" v-show="current != 1"> <div class="privacy-row" v-show="current != 1">
<u-checkbox <u-checkbox
@@ -99,11 +101,14 @@
</div> </div>
</div> </div>
<!-- #ifndef MP-WEIXIN -->
<div v-if="current != 1" class="user-password-tips" @click="enableUserPwdBox = !enableUserPwdBox"> <div v-if="current != 1" class="user-password-tips" @click="enableUserPwdBox = !enableUserPwdBox">
{{ !enableUserPwdBox ? "帐号密码" : "手机号" }}登录 {{ !enableUserPwdBox ? "帐号密码" : "手机号" }}登录
</div> </div>
<!-- #endif -->
<!-- 循环出当前可使用的第三方登录模式 --> <!-- 第三方登录小程序不展示微信登录入口 -->
<!-- #ifndef MP-WEIXIN -->
<div class="flex login-list"> <div class="flex login-list">
<template v-for="(item, index) in loginList" :key="index"> <template v-for="(item, index) in loginList" :key="index">
<div v-if="item.code" :style="{ background: item.color }" class="login-item"> <div v-if="item.code" :style="{ background: item.color }" class="login-item">
@@ -115,6 +120,7 @@
</div> </div>
</template> </template>
</div> </div>
<!-- #endif -->
<myVerification v-if="codeFlag" @send="handleVerification" class="verification" ref="verification" <myVerification v-if="codeFlag" @send="handleVerification" class="verification" ref="verification"
business="LOGIN" /> business="LOGIN" />
</div> </div>
@@ -182,7 +188,6 @@ const inputStyle = {
const placeholderStyle = 'font-size: 32rpx;line-height: 32rpx;color: #999999;' const placeholderStyle = 'font-size: 32rpx;line-height: 32rpx;color: #999999;'
const loginList = ref<LoginListItem[]>([ const loginList = ref<LoginListItem[]>([
{ icon: 'weixin-fill', color: '#00a327', title: '微信', code: 'WECHAT' }, { icon: 'weixin-fill', color: '#00a327', title: '微信', code: 'WECHAT' },
{ icon: 'qq-fill', color: '#38ace9', title: 'QQ', code: 'QQ' },
{ icon: 'apple-fill', color: '#000000', title: 'Apple', code: 'APPLE' }, { icon: 'apple-fill', color: '#000000', title: 'Apple', code: 'APPLE' },
]) ])
const clientType = ref('') const clientType = ref('')
@@ -212,20 +217,18 @@ onShow(() => {
}) })
onMounted(() => { onMounted(() => {
// #ifndef APP-PLUS // #ifdef H5
//判断是否微信浏览器 // 判断是否微信浏览器(仅 H5 有 window.navigator
const ua = window.navigator.userAgent.toLowerCase() const ua = window.navigator.userAgent.toLowerCase()
if (ua.match(/MicroMessenger/i) == 'micromessenger') { if (ua.match(/MicroMessenger/i) == 'micromessenger') {
wechatLogin.value = true wechatLogin.value = true
return return
} }
clientType.value = 'H5'
// #endif // #endif
/** /**
* 条件编译判断当前客户端类型 * 条件编译判断当前客户端类型
*/ */
//#ifdef H5
clientType.value = 'H5'
//#endif
//#ifdef APP-PLUS //#ifdef APP-PLUS
clientType.value = 'APP' clientType.value = 'APP'
@@ -233,39 +236,35 @@ onMounted(() => {
uni.getProvider({ uni.getProvider({
service: 'oauth', service: 'oauth',
success: (result) => { success: (result) => {
loginList.value = result.provider.map((value) => { loginList.value = result.provider
let title = '' .filter((value) => value === 'weixin' || value === 'apple')
let codeVal = '' .map((value) => {
let color = '#8b8b8b' let title = ''
let icon = '' let codeVal = ''
switch (value) { let color = '#8b8b8b'
case 'weixin': let icon = ''
icon = 'weixin-circle-fill' switch (value) {
color = '#00a327' case 'weixin':
title = '微信' icon = 'weixin-circle-fill'
codeVal = 'WECHAT' color = '#00a327'
break title = '微信'
case 'qq': codeVal = 'WECHAT'
icon = 'qq-circle-fill' break
color = '#38ace9' case 'apple':
title = 'QQ' icon = 'apple-fill'
codeVal = 'QQ' color = '#000000'
break title = 'Apple'
case 'apple': codeVal = 'APPLE'
icon = 'apple-fill' break
color = '#000000' }
title = 'Apple' return {
codeVal = 'APPLE' title,
break code: codeVal,
} color,
return { icon,
title, appcode: value,
code: codeVal, }
color, })
icon,
appcode: value,
}
})
}, },
fail: (error) => { fail: (error) => {
uni.showToast({ uni.showToast({
@@ -277,14 +276,11 @@ onMounted(() => {
}) })
//#endif //#endif
//特殊平台,登录方式需要过滤 //微信小程序:仅手机号登录,不展示账号密码/微信第三方入口
// #ifdef H5
methodFilter(['QQ'])
// #endif
//微信小程序,只支持微信登录
// #ifdef MP-WEIXIN // #ifdef MP-WEIXIN
methodFilter(['WECHAT']) clientType.value = 'WECHAT_MP'
enableUserPwdBox.value = false
loginList.value = []
// #endif // #endif
}) })
@@ -711,10 +707,20 @@ declare function miniProgramLogin(code: string | undefined): Promise<{ data: any
box-sizing: border-box; box-sizing: border-box;
} }
.login-header {
padding-top: 64rpx;
}
/* #ifdef MP-WEIXIN */
.login-header {
padding-top: 96rpx;
}
/* #endif */
.title { .title {
padding-top: calc(104rpx); padding-top: 0;
font-style: normal; font-style: normal;
line-height: 1; line-height: 1.2;
font-weight: 500; font-weight: 500;
font-size: 56rpx; font-size: 56rpx;
color: #333; color: #333;
@@ -727,9 +733,9 @@ declare function miniProgramLogin(code: string | undefined): Promise<{ data: any
.desc, .desc,
.desc-light { .desc-light {
font-size: 32rpx; font-size: 32rpx;
line-height: 32rpx; line-height: 44rpx;
color: #333333; color: #333333;
margin-top: 40rpx; margin-top: 24rpx;
} }
.desc { .desc {
@@ -745,8 +751,12 @@ declare function miniProgramLogin(code: string | undefined): Promise<{ data: any
} }
} }
.login-form-block {
margin-top: 96rpx;
}
.mobile { .mobile {
margin-top: 80rpx; margin-top: 0;
} }
.disable { .disable {

View File

@@ -22,7 +22,7 @@
<view class="btns"> <view class="btns">
<button type="primary" :disabled="logingFlag" bindtap="getUserProfile" @click="getUserProfile()" <button type="primary" :disabled="logingFlag" bindtap="getUserProfile" @click="getUserProfile()"
class="btn-auth">登录</button> class="btn-auth">登录</button>
<div @click="backToHome" class="btn-callback">暂不登录</div> <div @click="goMobileLogin" class="btn-callback">手机号登录</div>
</view> </view>
<view class="privacy"> <view class="privacy">
<view class="privacy-row"> <view class="privacy-row">
@@ -86,9 +86,9 @@ function back() {
whetherNavigate('wx') whetherNavigate('wx')
} }
function backToHome() { function goMobileLogin() {
uni.switchTab({ uni.navigateTo({
url: '/pages/tabbar/home/index', url: '/pages/passport/login',
}) })
} }

View File

@@ -317,7 +317,8 @@
/************接口API***************/ /************接口API***************/
import { ref, reactive, computed, watch, nextTick, getCurrentInstance } from 'vue' import { ref, reactive, computed, watch, nextTick, getCurrentInstance } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app' import { onLoad, onShow } from '@dcloudio/uni-app'
import { getGoods, getGoodsList, getMpScene, getGoodsDistribution } from '@/api/goods.js' import { getGoods, getGoodsList, getMpScene } from '@/api/goods.js'
import { recordDistributionGoodsVisit } from '@/api/distribution.js'
import * as API_trade from '@/api/trade.js' import * as API_trade from '@/api/trade.js'
import * as API_Members from '@/api/members.js' import * as API_Members from '@/api/members.js'
import * as API_store from '@/api/store.js' import * as API_store from '@/api/store.js'
@@ -461,10 +462,15 @@ onShow(async () => {
const res = await getMpScene(routerVal.value.scene) const res = await getMpScene(routerVal.value.scene)
if (res.data.success) { if (res.data.success) {
let data = res.data.result.split(",") let data = res.data.result.split(",")
init(data[0], data[1], data[2]) init(data[0], data[1], data[2], data[3] || '')
} }
} else { } else {
init(routerVal.value.id, routerVal.value.goodsId, routerVal.value.distributionId) init(
routerVal.value.id,
routerVal.value.goodsId,
routerVal.value.distributionId,
routerVal.value.shareId || ''
)
} }
}) })
@@ -494,7 +500,7 @@ function selectSku(idObj: any) {
init(idObj.skuId, idObj.goodsId) init(idObj.skuId, idObj.goodsId)
} }
async function init(id: any, goodsId: any, distributionId = "") { async function init(id: any, goodsId: any, distributionId = "", shareId = "") {
isGroup.value = false isGroup.value = false
productId.value = id productId.value = id
@@ -511,11 +517,18 @@ async function init(id: any, goodsId: any, distributionId = "") {
return return
} }
if ((distributionId || store.state.distributionId) && isLogin("auth")) { const distId = distributionId || store.state.distributionId
let disResult = await getGoodsDistribution(distributionId) if (distId && goodsId && id) {
if (!disResult?.data?.success || disResult.statusCode == 403) { recordDistributionGoodsVisit({
store.state.distributionId = distributionId skuId: id,
} goodsId,
distributionId: distId,
shareId: shareId || undefined,
}).then((res) => {
if (res?.data?.success) {
store.state.distributionId = distId
}
}).catch(() => {})
} }
const resultData = response.data.result?.data const resultData = response.data.result?.data

View File

@@ -1,7 +1,12 @@
<template> <template>
<div class="layout"> <div class="layout" v-if="list.length">
<div class="background"> <div class="background">
<u-notice-bar mode="vertical" :bg-color="res.list[0].bk_color" :color="res.list[0].color" :list="list"></u-notice-bar> <u-notice-bar
direction="column"
:bg-color="noticeStyle.bk_color"
:color="noticeStyle.color"
:text="list"
></u-notice-bar>
</div> </div>
</div> </div>
</template> </template>
@@ -11,9 +16,15 @@ import { computed } from "vue";
const props = defineProps<{ res: any }>(); const props = defineProps<{ res: any }>();
const list = computed(() => const noticeStyle = computed(() => props.res?.list?.[0] || {});
props.res.list[0].title.map((i: { context: string }) => i.context)
); const list = computed(() => {
const titles = props.res?.list?.[0]?.title;
if (!Array.isArray(titles)) return [];
return titles
.map((item: { context?: string }) => (item?.context == null ? "" : String(item.context)))
.filter((text: string) => text.length > 0);
});
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@import "./tpl.scss"; @import "./tpl.scss";

View File

@@ -132,7 +132,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
import { distribution as fetchDistribution } from '@/api/goods' import { distribution as fetchDistribution } from '@/api/distribution'
import configs from '@/config/config' import configs from '@/config/config'
import storage from '@/utils/storage' import storage from '@/utils/storage'
import { tipsToLogin, setClipboard } from '@/utils/filters.js' import { tipsToLogin, setClipboard } from '@/utils/filters.js'
@@ -184,7 +184,16 @@ function linkMsgDetail() {
} }
} }
function goRecruitJoin() {
uni.navigateTo({
url: '/pages/mine/distribution/join',
})
}
function distribution() { function distribution() {
if (!tipsToLogin('normal')) {
return
}
fetchDistribution().then((res) => { fetchDistribution().then((res) => {
if (res.data.result) { if (res.data.result) {
const type = res.data.result.distributionStatus const type = res.data.result.distributionStatus
@@ -193,9 +202,7 @@ function distribution() {
url: '/pages/mine/distribution/home', url: '/pages/mine/distribution/home',
}) })
} else if (type == 'REFUSE') { } else if (type == 'REFUSE') {
uni.navigateTo({ goRecruitJoin()
url: '/pages/mine/distribution/auth',
})
} else if (type == 'RETREAT') { } else if (type == 'RETREAT') {
uni.showToast({ uni.showToast({
title: '您的分销资格已被清退。请联系管理员!', title: '您的分销资格已被清退。请联系管理员!',
@@ -216,9 +223,7 @@ function distribution() {
icon: 'none', icon: 'none',
}) })
} else { } else {
uni.navigateTo({ goRecruitJoin()
url: '/pages/mine/distribution/auth',
})
} }
}) })
} }

View File

@@ -528,6 +528,48 @@ export function serviceStatusList (val) {
return statusList[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 '默认'
}
}
/** /**
* 订单状态列表 * 订单状态列表
*/ */

View File

@@ -1,20 +1,7 @@
const path = require('path')
// module.exports = { module.exports = {
// /** transpileDependencies: [
// * 此处为发行h5,微信小程序app中删除console path.join(__dirname, 'js_sdk/u-draw-poster'),
// * 如需显示console 需要注释此处重新运行 ],
// */ }
// chainWebpack: (config) => {
// // 发行或运行时启用了压缩时会生效
// config.optimization.minimizer('terser').tap((args) => {
// const compress = args[0].terserOptions.compress
// // 非 App 平台移除 console 代码(包含所有 console 方法,如 log,debug,info...)
// compress.drop_console = true
// compress.pure_funcs = [
// '__f__', // App 平台 vue 移除日志代码
// // 'console.debug' // 可移除指定的 console 方法
// ]
// return args
// })
// }
// }