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

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

View File

@@ -0,0 +1,382 @@
<template>
<view class="page" :style="themeStyle">
<view class="content">
<!-- 加载中 -->
<view v-if="loading" class="state-wrap">
<text class="state-text">加载中...</text>
</view>
<!-- 账户列表 -->
<view v-else-if="accounts.length" class="account-list">
<view
class="account-item"
v-for="item in accounts"
:key="item.id"
@click="handleSelect(item)"
>
<view class="item-left">
<view v-if="isSelectMode" class="radio-circle" :class="{ active: item.id === selectedId }">
<view class="radio-dot" v-if="item.id === selectedId" />
</view>
<view class="item-info">
<text class="item-name">{{ item.holderName }}</text>
<view class="item-meta">
<text class="item-bank">{{ item.bankName }}</text>
<text class="item-card">{{ maskCard(item.cardNo) }}</text>
</view>
</view>
<view v-if="!isSelectMode && item.isDefault" class="default-tag">默认</view>
</view>
<view class="item-actions">
<view v-if="!isSelectMode && !item.isDefault" class="set-default-btn" @click.stop="handleSetDefault(item.id)">
<text class="set-default-text">设为默认</text>
</view>
<view class="item-delete" @click.stop="handleDelete(item.id)">
<u-icon name="trash" color="#ccc" size="18" />
</view>
</view>
</view>
</view>
<!-- 空状态 -->
<view v-else class="empty-wrap">
<text class="empty-text">暂无银行卡请添加</text>
</view>
<!-- 添加账户按钮 -->
<view class="add-btn-wrap">
<view class="add-btn" @click="openAddPopup">+ 添加账户</view>
</view>
</view>
<!-- 添加账户 Popup -->
<u-popup v-model:show="addPopupVisible" mode="bottom" round="16">
<view class="popup-wrap">
<view class="popup-title">添加到账账户</view>
<view class="form-item">
<text class="form-label">姓名</text>
<input
class="form-input"
v-model="form.holderName"
placeholder="请输入收款人姓名"
placeholder-class="form-placeholder"
/>
</view>
<view class="form-divider" />
<view class="form-item">
<text class="form-label">银行</text>
<input
class="form-input"
v-model="form.bankName"
placeholder="如:中国工商银行"
placeholder-class="form-placeholder"
/>
</view>
<view class="form-divider" />
<view class="form-item">
<text class="form-label">卡号</text>
<input
class="form-input"
v-model="form.cardNo"
type="number"
placeholder="请输入银行卡号"
placeholder-class="form-placeholder"
/>
</view>
<view class="popup-actions">
<view class="popup-btn ghost" @click="addPopupVisible = false">取消</view>
<view class="popup-btn primary" :class="{ disabled: saveLoading }" @click="saveAccount">
{{ saveLoading ? '保存中...' : '保存' }}
</view>
</view>
</view>
</u-popup>
</view>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import { useStore } from '@/store'
import { getThemeStyle } from '@/utils/theme'
import { getBankCards, addBankCard, deleteBankCard, setDefaultBankCard } from '@/api/distribution'
const store = useStore()
const themeStyle = computed(() => getThemeStyle(store.state.theme))
// 从 getStorageSync 读取上次选择的默认卡 id仅选择模式使用
const SELECTED_KEY = 'dist_selected_account_id'
const isSelectMode = ref(false)
const accounts = ref<any[]>([])
const selectedId = ref('')
const loading = ref(false)
const addPopupVisible = ref(false)
const saveLoading = ref(false)
const form = ref({ holderName: '', bankName: '', cardNo: '' })
onLoad((options: any) => {
isSelectMode.value = options?.mode === 'select'
uni.setNavigationBarTitle({ title: isSelectMode.value ? '选择账户' : '到账账户' })
if (isSelectMode.value) {
selectedId.value = uni.getStorageSync(SELECTED_KEY) || ''
}
})
onShow(() => {
loadAccounts()
})
function loadAccounts() {
loading.value = true
getBankCards()
.then((res: any) => {
accounts.value = res?.data?.result || []
// 如果选择模式没有已选卡,默认选中 isDefault=true 的卡
if (isSelectMode.value && !selectedId.value && accounts.value.length) {
const def = accounts.value.find((a: any) => a.isDefault)
selectedId.value = def ? def.id : accounts.value[0].id
}
})
.finally(() => {
loading.value = false
})
}
function handleSelect(item: any) {
if (!isSelectMode.value) return
selectedId.value = item.id
uni.setStorageSync(SELECTED_KEY, item.id)
uni.navigateBack()
}
function handleDelete(id: string) {
uni.showModal({
title: '删除账户',
content: '确认删除该银行卡?',
success: (res) => {
if (res.confirm) {
deleteBankCard(id)
.then(() => {
uni.showToast({ title: '已删除', icon: 'success' })
loadAccounts()
})
.catch((err: any) => {
const msg = err?.data?.message || '删除失败'
uni.showToast({ title: msg, icon: 'none' })
})
}
},
})
}
function handleSetDefault(id: string) {
setDefaultBankCard(id)
.then(() => {
uni.showToast({ title: '已设为默认', icon: 'success' })
loadAccounts()
})
.catch((err: any) => {
const msg = err?.data?.message || '操作失败'
uni.showToast({ title: msg, icon: 'none' })
})
}
function openAddPopup() {
form.value = { holderName: '', bankName: '', cardNo: '' }
addPopupVisible.value = true
}
function saveAccount() {
if (!form.value.holderName.trim()) {
uni.showToast({ title: '请输入姓名', icon: 'none' })
return
}
if (!form.value.bankName.trim()) {
uni.showToast({ title: '请输入银行名称', icon: 'none' })
return
}
if (form.value.cardNo.trim().length === 0) {
uni.showToast({ title: '请输入银行卡号', icon: 'none' })
return
}
saveLoading.value = true
addBankCard({
holderName: form.value.holderName.trim(),
bankName: form.value.bankName.trim(),
cardNo: form.value.cardNo.trim(),
})
.then((res: any) => {
const newCard = res?.data?.result
addPopupVisible.value = false
uni.showToast({ title: '添加成功', icon: 'success' })
loadAccounts()
// 如果是选择模式,自动选中刚添加的卡
if (isSelectMode.value && newCard?.id) {
selectedId.value = newCard.id
uni.setStorageSync(SELECTED_KEY, newCard.id)
}
})
.catch((err: any) => {
const msg = err?.data?.message || '添加失败,请重试'
uni.showToast({ title: msg, icon: 'none' })
})
.finally(() => {
saveLoading.value = false
})
}
function maskCard(cardNo: string) {
if (!cardNo || cardNo.length < 4) return cardNo
return '****' + cardNo.slice(-4)
}
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background: #f5f6f8;
}
.content { padding: 24rpx; }
/* ── 状态 ──────────────────────────────────── */
.state-wrap {
padding: 120rpx 0;
text-align: center;
}
.state-text { color: #999; font-size: 26rpx; }
/* ── 账户列表 ──────────────────────────────── */
.account-list { margin-bottom: 24rpx; }
.account-item {
background: #fff;
border-radius: 16rpx;
padding: 28rpx 24rpx;
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16rpx;
}
.item-left {
display: flex;
align-items: center;
gap: 20rpx;
flex: 1;
min-width: 0;
}
.radio-circle {
width: 40rpx;
height: 40rpx;
border-radius: 50%;
border: 2rpx solid #ddd;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
&.active { border-color: var(--theme-primary); }
}
.radio-dot {
width: 22rpx;
height: 22rpx;
border-radius: 50%;
background: var(--theme-primary);
}
.item-info {
display: flex;
flex-direction: column;
gap: 8rpx;
min-width: 0;
}
.item-name { font-size: 28rpx; font-weight: 500; color: #333; }
.item-meta { display: flex; align-items: center; gap: 12rpx; }
.item-bank { font-size: 24rpx; color: #666; }
.item-card { font-size: 24rpx; color: #999; }
.default-tag {
font-size: 20rpx;
color: var(--theme-primary);
border: 1rpx solid var(--theme-primary);
border-radius: 6rpx;
padding: 2rpx 10rpx;
flex-shrink: 0;
}
.item-actions {
display: flex;
align-items: center;
gap: 16rpx;
flex-shrink: 0;
}
.set-default-btn { padding: 4rpx 0; }
.set-default-text { font-size: 22rpx; color: #999; }
.item-delete { padding: 10rpx; }
/* ── 空状态 ────────────────────────────────── */
.empty-wrap { padding: 120rpx 0; text-align: center; }
.empty-text { font-size: 26rpx; color: #ccc; }
/* ── 添加按钮 ──────────────────────────────── */
.add-btn-wrap { padding-bottom: 40rpx; }
.add-btn {
height: 88rpx;
line-height: 88rpx;
text-align: center;
border-radius: 16rpx;
border: 2rpx solid var(--theme-primary);
color: var(--theme-primary);
font-size: 30rpx;
font-weight: 500;
}
/* ── 弹窗 ──────────────────────────────────── */
.popup-wrap { padding: 32rpx 28rpx 48rpx; }
.popup-title {
text-align: center;
font-size: 30rpx;
font-weight: 600;
color: #333;
margin-bottom: 32rpx;
}
.form-item { display: flex; align-items: center; padding: 18rpx 0; }
.form-label { font-size: 28rpx; color: #333; width: 100rpx; flex-shrink: 0; }
.form-input { flex: 1; font-size: 28rpx; color: #333; height: 64rpx; }
.form-placeholder { color: #ccc; font-size: 26rpx; }
.form-divider { height: 1rpx; background: #f5f5f5; }
.popup-actions { display: flex; gap: 20rpx; margin-top: 32rpx; }
.popup-btn {
flex: 1;
height: 88rpx;
line-height: 88rpx;
text-align: center;
border-radius: 999rpx;
font-size: 28rpx;
font-weight: 500;
}
.popup-btn.ghost { color: #666; background: #f5f5f5; }
.popup-btn.primary { color: #fff; background: var(--theme-primary); }
.popup-btn.disabled { opacity: 0.5; }
</style>

View File

@@ -4,32 +4,20 @@
<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()
import {
resolveDistributionIdFromScene,
tryBindDistribution,
} from '@/utils/distributionBind.js'
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]
}
distributionId = await resolveDistributionIdFromScene(String(options.scene))
}
if (distributionId) {
store.state.distributionId = distributionId
if (storage.getAccessToken()) {
try {
await getGoodsDistribution(distributionId)
} catch (error) {
console.warn('bind distribution failed', error)
}
}
await tryBindDistribution(distributionId)
}
uni.switchTab({ url: '/pages/tabbar/home/index' })

View File

@@ -0,0 +1,204 @@
<template>
<view class="page" :style="themeStyle">
<view v-if="cashLoading && !cashList.length" class="state-wrap">
<text class="state-text">加载中...</text>
</view>
<view v-else-if="!cashList.length" class="state-wrap">
<text class="state-text">暂无提现记录</text>
</view>
<view v-else class="log-list">
<view class="log-item" v-for="item in cashList" :key="item.id">
<view class="log-left">
<view class="log-sn">单号{{ item.sn || item.id }}</view>
<view class="log-time">{{ formatTime(item.createTime) }}</view>
</view>
<view class="log-right">
<text class="log-amount log-amount-out">-{{ formatMoney(item.price) }}</text>
<view class="cash-status-tag" :class="cashStatusClass(item.distributionCashStatus)">
{{ cashStatusLabel(item.distributionCashStatus) }}
</view>
</view>
</view>
</view>
<view class="list-footer">
<text v-if="cashFinished && cashList.length" class="footer-text">没有更多数据了</text>
<text v-else-if="cashLoadingMore" class="footer-text">加载中...</text>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { onLoad, onReachBottom } from '@dcloudio/uni-app'
import { useStore } from '@/store'
import { getThemeStyle } from '@/utils/theme'
import { cashLog } from '@/api/distribution'
const store = useStore()
const themeStyle = computed(() => getThemeStyle(store.state.theme))
const cashList = ref<any[]>([])
const cashPage = ref(1)
const cashTotal = ref(0)
const cashLoading = ref(false)
const cashLoadingMore = ref(false)
const cashFinished = ref(false)
const PAGE_SIZE = 15
onLoad(() => {
loadCashHistory(true)
})
onReachBottom(() => {
loadCashHistory(false)
})
function loadCashHistory(reset: boolean) {
if (!reset && (cashLoading.value || cashLoadingMore.value || cashFinished.value)) return
if (reset) {
cashLoading.value = true
cashPage.value = 1
cashFinished.value = false
} else {
cashLoadingMore.value = true
}
cashLog({ pageNumber: cashPage.value, pageSize: PAGE_SIZE, sort: 'createTime', order: 'desc' })
.then((res: any) => {
const result = res?.data?.result || {}
const records: any[] = result.records || []
cashTotal.value = Number(result.total || 0)
cashList.value = reset ? records : [...cashList.value, ...records]
if (records.length < PAGE_SIZE || cashList.value.length >= cashTotal.value) {
cashFinished.value = true
} else {
cashPage.value += 1
}
})
.finally(() => {
cashLoading.value = false
cashLoadingMore.value = false
})
}
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)
}
const CASH_STATUS_MAP: Record<string, { label: string; cls: string }> = {
APPLY: { label: '审核中', cls: 'status-pending' },
VIA_AUDITING: { label: '已通过', cls: 'status-success' },
FAIL_AUDITING: { label: '已驳回', cls: 'status-fail' },
}
function cashStatusLabel(status: string) {
return CASH_STATUS_MAP[status]?.label || status
}
function cashStatusClass(status: string) {
return CASH_STATUS_MAP[status]?.cls || ''
}
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background: #f5f6f8;
padding: 16rpx 24rpx 40rpx;
}
.state-wrap {
padding: 120rpx 0;
text-align: center;
}
.state-text {
color: #999;
font-size: 26rpx;
}
.log-list {
padding-top: 16rpx;
}
.log-item {
display: flex;
align-items: center;
justify-content: space-between;
background: #fff;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 16rpx;
}
.log-left {
display: flex;
flex-direction: column;
gap: 10rpx;
}
.log-time {
font-size: 22rpx;
color: #999;
}
.log-sn {
font-size: 24rpx;
color: #666;
max-width: 360rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.log-right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8rpx;
}
.log-amount {
font-size: 32rpx;
font-weight: 700;
}
.log-amount-out {
color: #333;
}
.cash-status-tag {
font-size: 22rpx;
padding: 4rpx 14rpx;
border-radius: 999rpx;
}
.status-pending {
background: #fff8e6;
color: #e6a23c;
}
.status-success {
background: #e8f8ec;
color: #3aaa52;
}
.status-fail {
background: #fef0f0;
color: #f56c6c;
}
.list-footer {
padding: 12rpx 0 32rpx;
text-align: center;
}
.footer-text {
color: #ccc;
font-size: 24rpx;
}
</style>

View File

@@ -9,74 +9,81 @@
<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>
<template v-else>
<view v-if="qrLoading || posterComposing" class="card state-card">
<text class="state-text">{{ posterComposing ? '邀请卡合成中...' : qrLoadingText }}</text>
</view>
</view>
<view v-if="!pageLoading && !pageError" class="footer-actions">
<view v-else-if="qrError || composeError" class="card state-card">
<text class="state-text">{{ composeError || qrError }}</text>
<view class="retry-btn" @click="retryGenerate">重新生成</view>
</view>
<image
v-else-if="posterImage"
class="poster-image"
:src="posterImage"
mode="widthFix"
show-menu-by-longpress
@click="previewPoster"
/>
</template>
<view v-if="posterImage" class="footer-actions">
<!-- #ifdef MP-WEIXIN -->
<button class="btn primary share-btn" open-type="share" :disabled="!qrImage">
<button class="btn primary share-btn" open-type="share">
<text class="btn-text">微信分享</text>
</button>
<!-- #endif -->
<view class="hint-text">长按图片保存海报</view>
<view class="save-btn" @click="savePoster">保存海报</view>
<view class="hint-text">点击预览长按或保存到相册</view>
</view>
<canvas canvas-id="inviteQrCanvas" class="qr-canvas" />
<view class="canvas-hide">
<!-- #ifdef MP-WEIXIN -->
<canvas type="2d" id="inviteQrCanvas" class="qr-canvas" />
<canvas type="2d" id="inviteComposeCanvas" :style="composeCanvasStyle" />
<!-- #endif -->
<!-- #ifndef MP-WEIXIN -->
<canvas canvas-id="inviteQrCanvas" class="qr-canvas" />
<canvas canvas-id="inviteComposeCanvas" id="inviteComposeCanvas" :style="composeCanvasStyle" />
<!-- #endif -->
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, getCurrentInstance } from 'vue'
import { onLoad, onShareAppMessage } from '@dcloudio/uni-app'
import { onLoad, onReady, 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'
import {
POSTER_WIDTH,
POSTER_HEIGHT,
composeDistributionPoster,
savePosterToAlbum,
} from '@/utils/distributionPosterCanvas'
const DEFAULT_JOIN_PAGE = 'pages/mine/distribution/join'
const QR_CANVAS_SIZE = 280
const COMPOSE_CANVAS_SELECTOR = 'inviteComposeCanvas'
function getCanvasPixelRatio() {
return wx.getWindowInfo?.().pixelRatio || wx.getDeviceInfo?.().pixelRatio || 2
}
const instance = getCurrentInstance()
const store = useStore()
const themeStyle = computed(() => getThemeStyle(store.state.theme))
const composeCanvasStyle = {
width: `${POSTER_WIDTH}px`,
height: `${POSTER_HEIGHT}px`,
}
let canvas2dNode: any = null
const pageLoading = ref(true)
const pageLoadingText = ref('加载邀请卡设置...')
@@ -85,20 +92,24 @@ const qrLoading = ref(false)
const qrLoadingText = ref('葵花码生成中...')
const qrError = ref('')
const qrImage = ref('')
const posterImage = ref('')
const posterComposing = ref(false)
const composeError = 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()
})
onReady(() => {
// #ifdef MP-WEIXIN
initCanvas2d()
// #endif
})
onShareAppMessage(() => {
const ctx = inviteContext.value
const sharePage = normalizeSharePage(ctx.sharePage)
@@ -106,7 +117,7 @@ onShareAppMessage(() => {
return {
title: ctx.slogan || '邀请您成为我的下级分销员',
path: `/${sharePage}${query}`,
imageUrl: qrImage.value || '',
imageUrl: posterImage.value || qrImage.value || '',
}
})
@@ -134,7 +145,65 @@ function writeBase64ToTemp(base64: string): Promise<string> {
})
}
function exportQrByCanvas(qrPath: string): Promise<string> {
function initCanvas2d() {
return new Promise<void>((resolve, reject) => {
if (canvas2dNode) {
resolve()
return
}
uni
.createSelectorQuery()
.in(instance?.proxy)
.select('#inviteQrCanvas')
.fields({ node: true, size: true })
.exec((res: any[]) => {
const node = res?.[0]?.node
if (!node) {
reject(new Error('canvas 节点获取失败'))
return
}
canvas2dNode = node
const dpr = getCanvasPixelRatio()
node.width = QR_CANVAS_SIZE * dpr
node.height = QR_CANVAS_SIZE * dpr
resolve()
})
})
}
function exportQrByCanvas2d(qrPath: string): Promise<string> {
return new Promise((resolve, reject) => {
const canvas = canvas2dNode
const ctx = canvas.getContext('2d')
const dpr = getCanvasPixelRatio()
ctx.scale(dpr, dpr)
ctx.fillStyle = '#ffffff'
ctx.fillRect(0, 0, QR_CANVAS_SIZE, QR_CANVAS_SIZE)
const img = canvas.createImage()
img.onload = () => {
ctx.drawImage(img, 0, 0, QR_CANVAS_SIZE, QR_CANVAS_SIZE)
setTimeout(() => {
uni.canvasToTempFilePath(
{
canvas,
width: QR_CANVAS_SIZE,
height: QR_CANVAS_SIZE,
destWidth: QR_CANVAS_SIZE,
destHeight: QR_CANVAS_SIZE,
success: (file: any) => resolve(file.tempFilePath),
fail: () => reject(new Error('葵花码导出失败')),
},
instance?.proxy
)
}, 300)
}
img.onerror = () => reject(new Error('葵花码图片加载失败'))
img.src = qrPath
})
}
function exportQrByCanvasLegacy(qrPath: string): Promise<string> {
return new Promise((resolve, reject) => {
const ctx = uni.createCanvasContext('inviteQrCanvas', instance?.proxy)
ctx.setFillStyle('#ffffff')
@@ -159,17 +228,59 @@ function exportQrByCanvas(qrPath: string): Promise<string> {
})
}
async function exportQrByCanvas(qrPath: string): Promise<string> {
// #ifdef MP-WEIXIN
await initCanvas2d()
return exportQrByCanvas2d(qrPath)
// #endif
// #ifndef MP-WEIXIN
return exportQrByCanvasLegacy(qrPath)
// #endif
}
async function resolveQrDisplay(result: string) {
const tempPath = await writeBase64ToTemp(result)
qrImage.value = await exportQrByCanvas(tempPath)
}
async function composePoster() {
if (!qrImage.value) return
posterComposing.value = true
composeError.value = ''
posterImage.value = ''
try {
const ctx = inviteContext.value
posterImage.value = await composeDistributionPoster(
COMPOSE_CANVAS_SELECTOR,
instance?.proxy || instance,
{
backgroundImage: backgroundUrl.value,
memberAvatar: avatarUrl.value,
memberName: ctx.memberName || '分销员',
slogan: ctx.slogan || '',
qrImage: qrImage.value,
textColor: ctx.textColor || '#1f2a44',
showMemberInfo: ctx.memberInfoVisible !== false,
}
)
} catch (error: any) {
console.error('compose invite card failed', error)
composeError.value = error?.message || '邀请卡合成失败,请稍后重试'
} finally {
posterComposing.value = false
}
}
async function loadInviteCardSetting() {
pageLoading.value = true
pageLoadingText.value = '加载邀请卡设置...'
pageError.value = ''
qrImage.value = ''
posterImage.value = ''
qrError.value = ''
composeError.value = ''
try {
const res = await getInviteCard()
@@ -210,6 +321,8 @@ async function loadQrCode() {
qrLoadingText.value = '葵花码生成中...'
qrError.value = ''
qrImage.value = ''
posterImage.value = ''
composeError.value = ''
try {
const ctx = inviteContext.value
@@ -222,6 +335,7 @@ async function loadQrCode() {
}
await resolveQrDisplay(codeRes.data.result)
await composePoster()
} catch (error: any) {
console.error('load invite qr code failed', error)
qrError.value = error?.message || '葵花码生成失败,请稍后重试'
@@ -230,18 +344,25 @@ async function loadQrCode() {
}
}
function onQrImageError() {
qrError.value = '葵花码图片加载失败'
qrImage.value = ''
function retryGenerate() {
if (qrImage.value && composeError.value) {
composePoster()
return
}
loadQrCode()
}
function previewQrCode() {
if (!qrImage.value) return
function previewPoster() {
if (!posterImage.value) return
uni.previewImage({
current: qrImage.value,
urls: [qrImage.value],
current: posterImage.value,
urls: [posterImage.value],
})
}
function savePoster() {
savePosterToAlbum(posterImage.value)
}
</script>
<style lang="scss" scoped>
@@ -262,7 +383,8 @@ function previewQrCode() {
font-size: 28rpx;
}
.retry-btn {
.retry-btn,
.save-btn {
display: inline-block;
margin-top: 24rpx;
padding: 12rpx 40rpx;
@@ -272,107 +394,24 @@ function previewQrCode() {
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;
aspect-ratio: 630 / 1000;
}
.card--has-bg {
background: transparent;
.state-card {
display: flex;
align-items: center;
justify-content: center;
}
.card-bg {
position: absolute;
left: 0;
top: 0;
.poster-image {
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;
display: block;
}
.footer-actions {
@@ -416,10 +455,31 @@ function previewQrCode() {
font-size: 28rpx;
}
.save-btn {
display: block;
width: 100%;
margin-top: 24rpx;
text-align: center;
box-sizing: border-box;
}
.hint-text {
margin-top: 24rpx;
color: #b3b3b3;
font-size: 24rpx;
text-align: center;
}
.canvas-hide {
position: fixed;
left: -9999px;
top: 0;
opacity: 0;
pointer-events: none;
}
.qr-canvas {
width: 280px;
height: 280px;
}
</style>

View File

@@ -4,7 +4,16 @@
<view v-else-if="!plan">
<view class="empty">暂未开放线上招募</view>
</view>
<view v-else class="page-body" :class="{ 'with-footer': showManualApply || isApplyPending }">
<view v-else class="page-body" :class="{ 'with-footer': showLoginFooter || showManualApply || isApplyPending }">
<!-- 邀请人信息来自图文邀请卡 -->
<view class="inviter-header" v-if="showInviterHeader">
<image class="inviter-avatar" :src="inviterAvatar" mode="aspectFill" />
<view class="inviter-meta">
<view class="inviter-label">邀请您加入</view>
<view class="inviter-name">{{ inviterName }}</view>
</view>
</view>
<!-- 招募海报 -->
<view class="section poster-section" v-if="showPoster">
<image
@@ -22,7 +31,9 @@
<!-- 加入条件 -->
<view class="section" v-if="hasJoinCondition">
<view class="title">加入条件</view>
<view class="condition-tip">满足以下条件后可申请成为分销员</view>
<view class="condition-tip">
{{ isUserLoggedIn ? '满足以下条件后可申请成为分销员' : '登录后可查看您的达标进度并申请成为分销员' }}
</view>
<view class="condition-item" v-if="plan.requireSelfPurchaseAmount">
<view class="condition-head">
@@ -87,7 +98,14 @@
</view>
</view>
<view class="apply-footer-fixed" v-if="showManualApply || isApplyPending">
<view class="apply-footer-fixed" v-if="showLoginFooter">
<view class="apply-footer-card">
<view class="login-tip">登录后即可查看加入条件并申请成为分销员</view>
<view class="submit" @click="goLogin">立即登录</view>
</view>
</view>
<view class="apply-footer-fixed" v-else-if="showManualApply || isApplyPending">
<view class="apply-footer-card">
<view class="agree" v-if="showManualApply" @click="agreed = !agreed">
<checkbox :checked="agreed" @click.stop="agreed = !agreed" />
@@ -148,8 +166,13 @@ import { ref, reactive, computed } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import { useStore } from '@/store'
import { getThemeStyle } from '@/utils/theme'
import { getRecruitPage, getRecruitProgress, submitRecruitApplication } from '@/api/distribution'
import { tipsToLogin } from '@/utils/filters.js'
import storage from '@/utils/storage.js'
import config from '@/config/config'
import { parseGoodsImageUrl, tipsToLogin } from '@/utils/filters.js'
import { resolveRecruitDistributionIdFromScene } from '@/utils/distributionBind.js'
import { getRecruitPage, getRecruitProgress, getRecruitInviter, submitRecruitApplication } from '@/api/distribution'
const defaultAvatar = config.defaultUserPhoto
const store = useStore()
const themeStyle = computed(() => getThemeStyle(store.state.theme))
@@ -166,6 +189,9 @@ const agreementVisible = ref(false)
const applyFormVisible = ref(false)
const formInfoCompleted = ref(false)
const fieldErrors = reactive<Record<string, string>>({})
const inviterInfo = ref<any>(null)
const isUserLoggedIn = computed(() => !!storage.getAccessToken())
const plan = computed(() => pageInfo.value.plan || null)
const fields = computed(() => pageInfo.value.fields || [])
@@ -219,12 +245,25 @@ const isManualApply = computed(() => {
return (plan.value?.applyMode || 'MANUAL') === 'MANUAL'
})
const showLoginFooter = computed(() => {
return !!plan.value && !isUserLoggedIn.value && !loading.value
})
const showInviterHeader = computed(() => {
if (!recruitToken.value) return false
const info = inviterInfo.value
return !!(info?.memberName || info?.memberAvatar)
})
const inviterName = computed(() => inviterInfo.value?.memberName || '分销员')
const inviterAvatar = computed(() => parseGoodsImageUrl(inviterInfo.value?.memberAvatar) || defaultAvatar)
const showManualApply = computed(() => {
return !showApplyResult.value && conditionsMet.value && isManualApply.value
return isUserLoggedIn.value && !showApplyResult.value && conditionsMet.value && isManualApply.value
})
const showAutoApplyTip = computed(() => {
return !showApplyResult.value && conditionsMet.value && plan.value?.applyMode === 'AUTO'
return isUserLoggedIn.value && !showApplyResult.value && conditionsMet.value && plan.value?.applyMode === 'AUTO'
})
const showApplyForm = computed(() => {
@@ -250,10 +289,48 @@ const consumeCountOk = computed(() => {
})
onLoad((query) => {
recruitToken.value = query?.token || query?.distributionId || ''
onLoad(async (query) => {
let token = query?.token || query?.distributionId || ''
if (!token && query?.scene) {
token = await resolveRecruitDistributionIdFromScene(query.scene)
}
recruitToken.value = token ? String(token) : ''
if (recruitToken.value) {
loadInviterInfo(recruitToken.value)
}
})
function loadInviterInfo(distributionId: string) {
getRecruitInviter(distributionId).then((res) => {
if (res.data?.success) {
inviterInfo.value = res.data.result || null
}
})
}
function emptyProgress() {
return {
satisfied: false,
currentSelfPurchaseAmount: 0,
currentConsumeCount: 0,
purchaseGoodsSatisfied: false,
}
}
function hasConditionalJoin(planData: any) {
if (!planData || planData.joinConditionType !== 'CONDITIONAL') return false
return !!(planData.requireSelfPurchaseAmount || planData.requireConsumeCount || planData.requirePurchaseGoods)
}
function goLogin() {
// #ifdef MP-WEIXIN
uni.navigateTo({ url: '/pages/passport/wechatMPLogin' })
// #endif
// #ifndef MP-WEIXIN
uni.navigateTo({ url: '/pages/passport/login' })
// #endif
}
onShow(() => {
loadPageData()
})
@@ -265,10 +342,20 @@ function loadPageData() {
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
const tasks: Promise<any>[] = [getRecruitPage()]
if (isUserLoggedIn.value) {
tasks.push(getRecruitProgress())
}
Promise.all(tasks)
.then((results) => {
const pageRes = results[0]
const pageResult = pageRes.data?.result || {}
pageInfo.value = pageResult
if (results.length > 1) {
progress.value = results[1].data?.result || emptyProgress()
} else {
progress.value = hasConditionalJoin(pageResult.plan) ? emptyProgress() : { satisfied: true }
}
})
.finally(() => {
loading.value = false
@@ -401,6 +488,50 @@ function submit() {
font-size: 28rpx;
}
.inviter-header {
display: flex;
align-items: center;
background: #fff;
border-radius: 16rpx;
padding: 28rpx;
margin-bottom: 24rpx;
}
.inviter-avatar {
width: 96rpx;
height: 96rpx;
border-radius: 50%;
background: #f5f5f5;
margin-right: 24rpx;
flex-shrink: 0;
}
.inviter-meta {
flex: 1;
min-width: 0;
}
.inviter-label {
font-size: 24rpx;
color: #999;
margin-bottom: 8rpx;
}
.inviter-name {
font-size: 32rpx;
font-weight: 600;
color: #222;
line-height: 1.4;
}
.login-tip {
font-size: 26rpx;
color: #666;
text-align: center;
margin-bottom: 24rpx;
line-height: 1.5;
}
.section {
background: #fff;
border-radius: 16rpx;

View File

@@ -9,74 +9,72 @@
<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>
<template v-else>
<view v-if="qrLoading || posterComposing" class="card state-card">
<text class="state-text">{{ posterComposing ? '海报合成中...' : qrLoadingText }}</text>
</view>
</view>
<view v-if="!pageLoading && !pageError" class="footer-actions">
<view v-else-if="qrError || composeError" class="card state-card">
<text class="state-text">{{ composeError || qrError }}</text>
<view class="retry-btn" @click="retryGenerate">重新生成</view>
</view>
<image
v-else-if="posterImage"
class="poster-image"
:src="posterImage"
mode="widthFix"
show-menu-by-longpress
@click="previewPoster"
/>
</template>
<view v-if="posterImage" class="hint-fixed">长按保存分享</view>
<view class="canvas-hide">
<!-- #ifdef MP-WEIXIN -->
<button class="btn primary share-btn" open-type="share" :disabled="!qrImage">
<text class="btn-text">微信分享</text>
</button>
<canvas type="2d" id="qrCanvas" class="qr-canvas" />
<canvas type="2d" id="posterComposeCanvas" :style="composeCanvasStyle" />
<!-- #endif -->
<!-- #ifndef MP-WEIXIN -->
<canvas canvas-id="qrCanvas" class="qr-canvas" />
<canvas canvas-id="posterComposeCanvas" id="posterComposeCanvas" :style="composeCanvasStyle" />
<!-- #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 { onLoad, onReady, 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'
import {
POSTER_WIDTH,
POSTER_HEIGHT,
composeDistributionPoster,
} from '@/utils/distributionPosterCanvas'
const DEFAULT_HOME_PAGE = 'pages/tabbar/home/index'
const QR_CANVAS_SIZE = 280
const COMPOSE_CANVAS_SELECTOR = 'posterComposeCanvas'
function getCanvasPixelRatio() {
return wx.getWindowInfo?.().pixelRatio || wx.getDeviceInfo?.().pixelRatio || 2
}
const instance = getCurrentInstance()
const store = useStore()
const themeStyle = computed(() => getThemeStyle(store.state.theme))
const composeCanvasStyle = {
width: `${POSTER_WIDTH}px`,
height: `${POSTER_HEIGHT}px`,
}
let canvas2dNode: any = null
const pageLoading = ref(true)
const pageLoadingText = ref('加载海报设置...')
@@ -85,27 +83,28 @@ const qrLoading = ref(false)
const qrLoadingText = ref('葵花码生成中...')
const qrError = ref('')
const qrImage = ref('')
const posterImage = ref('')
const posterComposing = ref(false)
const composeError = 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()
})
onReady(() => {
// #ifdef MP-WEIXIN
initCanvas2d()
// #endif
})
onShareAppMessage(() => {
const ctx = posterContext.value
const sharePage = normalizeSharePage(ctx.sharePage)
return {
title: ctx.slogan || '邀请你一起逛逛',
path: `/${sharePage}`,
imageUrl: qrImage.value || '',
imageUrl: posterImage.value || qrImage.value || '',
}
})
@@ -133,7 +132,65 @@ function writeBase64ToTemp(base64: string): Promise<string> {
})
}
function exportQrByCanvas(qrPath: string): Promise<string> {
function initCanvas2d() {
return new Promise<void>((resolve, reject) => {
if (canvas2dNode) {
resolve()
return
}
uni
.createSelectorQuery()
.in(instance?.proxy)
.select('#qrCanvas')
.fields({ node: true, size: true })
.exec((res: any[]) => {
const node = res?.[0]?.node
if (!node) {
reject(new Error('canvas 节点获取失败'))
return
}
canvas2dNode = node
const dpr = getCanvasPixelRatio()
node.width = QR_CANVAS_SIZE * dpr
node.height = QR_CANVAS_SIZE * dpr
resolve()
})
})
}
function exportQrByCanvas2d(qrPath: string): Promise<string> {
return new Promise((resolve, reject) => {
const canvas = canvas2dNode
const ctx = canvas.getContext('2d')
const dpr = getCanvasPixelRatio()
ctx.scale(dpr, dpr)
ctx.fillStyle = '#ffffff'
ctx.fillRect(0, 0, QR_CANVAS_SIZE, QR_CANVAS_SIZE)
const img = canvas.createImage()
img.onload = () => {
ctx.drawImage(img, 0, 0, QR_CANVAS_SIZE, QR_CANVAS_SIZE)
setTimeout(() => {
uni.canvasToTempFilePath(
{
canvas,
width: QR_CANVAS_SIZE,
height: QR_CANVAS_SIZE,
destWidth: QR_CANVAS_SIZE,
destHeight: QR_CANVAS_SIZE,
success: (file: any) => resolve(file.tempFilePath),
fail: () => reject(new Error('葵花码导出失败')),
},
instance?.proxy
)
}, 300)
}
img.onerror = () => reject(new Error('葵花码图片加载失败'))
img.src = qrPath
})
}
function exportQrByCanvasLegacy(qrPath: string): Promise<string> {
return new Promise((resolve, reject) => {
const ctx = uni.createCanvasContext('qrCanvas', instance?.proxy)
ctx.setFillStyle('#ffffff')
@@ -158,17 +215,59 @@ function exportQrByCanvas(qrPath: string): Promise<string> {
})
}
async function exportQrByCanvas(qrPath: string): Promise<string> {
// #ifdef MP-WEIXIN
await initCanvas2d()
return exportQrByCanvas2d(qrPath)
// #endif
// #ifndef MP-WEIXIN
return exportQrByCanvasLegacy(qrPath)
// #endif
}
async function resolveQrDisplay(result: string) {
const tempPath = await writeBase64ToTemp(result)
qrImage.value = await exportQrByCanvas(tempPath)
}
async function composePoster() {
if (!qrImage.value) return
posterComposing.value = true
composeError.value = ''
posterImage.value = ''
try {
const ctx = posterContext.value
posterImage.value = await composeDistributionPoster(
COMPOSE_CANVAS_SELECTOR,
instance?.proxy || instance,
{
backgroundImage: backgroundUrl.value,
memberAvatar: avatarUrl.value,
memberName: ctx.memberName || '分销员',
slogan: ctx.slogan || '',
qrImage: qrImage.value,
textColor: ctx.textColor || '#1f2a44',
showMemberInfo: ctx.memberInfoVisible !== false,
}
)
} catch (error: any) {
console.error('compose poster failed', error)
composeError.value = error?.message || '海报合成失败,请稍后重试'
} finally {
posterComposing.value = false
}
}
async function loadPosterSetting() {
pageLoading.value = true
pageLoadingText.value = '加载海报设置...'
pageError.value = ''
qrImage.value = ''
posterImage.value = ''
qrError.value = ''
composeError.value = ''
try {
const res = await getPromotionPoster()
@@ -209,6 +308,8 @@ async function loadQrCode() {
qrLoadingText.value = '葵花码生成中...'
qrError.value = ''
qrImage.value = ''
posterImage.value = ''
composeError.value = ''
try {
const ctx = posterContext.value
@@ -221,6 +322,7 @@ async function loadQrCode() {
}
await resolveQrDisplay(codeRes.data.result)
await composePoster()
} catch (error: any) {
console.error('load qr code failed', error)
qrError.value = error?.message || '葵花码生成失败,请稍后重试'
@@ -229,18 +331,24 @@ async function loadQrCode() {
}
}
function onQrImageError() {
qrError.value = '葵花码图片加载失败'
qrImage.value = ''
function retryGenerate() {
if (qrImage.value && composeError.value) {
composePoster()
return
}
loadQrCode()
}
function previewQrCode() {
if (!qrImage.value) return
function previewPoster() {
if (!posterImage.value) return
uni.previewImage({
current: qrImage.value,
urls: [qrImage.value],
current: posterImage.value,
urls: [posterImage.value],
})
}
const avatarUrl = computed(() => parseGoodsImageUrl(posterContext.value.memberAvatar))
const backgroundUrl = computed(() => parseGoodsImageUrl(posterContext.value.backgroundImage))
</script>
<style lang="scss" scoped>
@@ -271,158 +379,43 @@ function previewQrCode() {
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;
aspect-ratio: 630 / 1000;
}
.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 {
.state-card {
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 {
.poster-image {
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 {
border-radius: 16rpx;
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 {
.hint-fixed {
margin-top: 24rpx;
color: #b3b3b3;
font-size: 24rpx;
text-align: center;
}
.canvas-hide {
position: fixed;
left: -9999px;
top: 0;
opacity: 0;
pointer-events: none;
}
.qr-canvas {
width: 280px;
height: 280px;
}
</style>

View File

@@ -0,0 +1,191 @@
<template>
<view class="page" :style="themeStyle">
<view v-if="walletLoading && !walletList.length" class="state-wrap">
<text class="state-text">加载中...</text>
</view>
<view v-else-if="!walletList.length" class="state-wrap">
<text class="state-text">暂无流水记录</text>
</view>
<view v-else class="log-list">
<view class="log-item" v-for="item in walletList" :key="item.id">
<view class="log-left">
<text class="log-title">{{ logTypeLabel(item.logType) }}</text>
<text class="log-time">{{ formatTime(item.createTime) }}</text>
</view>
<view class="log-right">
<text class="log-amount" :class="logAmountClass(item.logType)">
{{ logAmountSign(item.logType) }}{{ formatMoney(Math.abs(item.changeAmount)) }}
</text>
</view>
</view>
</view>
<view class="list-footer">
<text v-if="walletFinished && walletList.length" class="footer-text">没有更多数据了</text>
<text v-else-if="walletLoadingMore" class="footer-text">加载中...</text>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { onLoad, onReachBottom } from '@dcloudio/uni-app'
import { useStore } from '@/store'
import { getThemeStyle } from '@/utils/theme'
import { getWalletLog } from '@/api/distribution'
const store = useStore()
const themeStyle = computed(() => getThemeStyle(store.state.theme))
const walletList = ref<any[]>([])
const walletPage = ref(1)
const walletTotal = ref(0)
const walletLoading = ref(false)
const walletLoadingMore = ref(false)
const walletFinished = ref(false)
const PAGE_SIZE = 15
onLoad(() => {
loadWalletLog(true)
})
onReachBottom(() => {
loadWalletLog(false)
})
function loadWalletLog(reset: boolean) {
if (!reset && (walletLoading.value || walletLoadingMore.value || walletFinished.value)) return
if (reset) {
walletLoading.value = true
walletPage.value = 1
walletFinished.value = false
} else {
walletLoadingMore.value = true
}
getWalletLog({ pageNumber: walletPage.value, pageSize: PAGE_SIZE })
.then((res: any) => {
const result = res?.data?.result || {}
const records: any[] = result.records || []
walletTotal.value = Number(result.total || 0)
walletList.value = reset ? records : [...walletList.value, ...records]
if (records.length < PAGE_SIZE || walletList.value.length >= walletTotal.value) {
walletFinished.value = true
} else {
walletPage.value += 1
}
})
.finally(() => {
walletLoading.value = false
walletLoadingMore.value = false
})
}
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)
}
const LOG_TYPE_MAP: Record<string, { label: string; income: boolean }> = {
COMMISSION_FREEZE: { label: '佣金待结算', income: false },
COMMISSION_SETTLE: { label: '销售员佣金', income: true },
COMMISSION_REFUND: { label: '佣金退回', income: false },
WITHDRAW_APPLY: { label: '提现申请', income: false },
WITHDRAW_APPROVE: { label: '提现到账', income: false },
WITHDRAW_REJECT: { label: '提现驳回', income: true },
}
function logTypeLabel(type: string) {
return LOG_TYPE_MAP[type]?.label || type
}
function logAmountClass(type: string) {
return LOG_TYPE_MAP[type]?.income ? 'log-amount-in' : 'log-amount-out'
}
function logAmountSign(type: string) {
return LOG_TYPE_MAP[type]?.income ? '+' : '-'
}
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background: #f5f6f8;
padding: 16rpx 24rpx 40rpx;
}
.state-wrap {
padding: 120rpx 0;
text-align: center;
}
.state-text {
color: #999;
font-size: 26rpx;
}
.log-list {
padding-top: 16rpx;
}
.log-item {
display: flex;
align-items: center;
justify-content: space-between;
background: #fff;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 16rpx;
}
.log-left {
display: flex;
flex-direction: column;
gap: 10rpx;
}
.log-title {
font-size: 28rpx;
font-weight: 500;
color: #333;
line-height: 1.4;
}
.log-time {
font-size: 22rpx;
color: #999;
}
.log-right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8rpx;
}
.log-amount {
font-size: 32rpx;
font-weight: 700;
}
.log-amount-in {
color: var(--theme-primary);
}
.log-amount-out {
color: #333;
}
.list-footer {
padding: 12rpx 0 32rpx;
text-align: center;
}
.footer-text {
color: #ccc;
font-size: 24rpx;
}
</style>

View File

@@ -0,0 +1,502 @@
<template>
<view class="page" :style="themeStyle">
<view class="content">
<!-- 到账账户 -->
<view class="section-card">
<view class="account-row" @click="openAccountDrawer">
<text class="account-label">到账账户</text>
<view class="account-right">
<view v-if="selectedAccount" class="account-info">
<text class="account-bank">{{ selectedAccount.bankName }}</text>
<text class="account-card">{{ maskCard(selectedAccount.cardNo) }}</text>
</view>
<text v-else class="account-add-text">请添加</text>
<u-icon name="arrow-right" color="#bbb" size="14" />
</view>
</view>
</view>
<!-- 提现金额 -->
<view class="section-card amount-card">
<text class="amount-label">提现金额</text>
<view class="amount-input-wrap">
<text class="amount-symbol">¥</text>
<input
class="amount-input"
type="digit"
v-model="amount"
placeholder="0.00"
placeholder-class="amount-placeholder"
/>
</view>
<view class="amount-divider" />
<view class="amount-hint-row">
<text class="amount-balance-hint">可提现余额 ¥{{ formatMoney(canRebate) }}</text>
<text class="amount-all" @click="fillAll">全部提现</text>
</view>
<text v-if="errorMsg" class="amount-error">{{ errorMsg }}</text>
</view>
<!-- 提现按钮 -->
<view
class="submit-btn"
:class="{ 'submit-btn--disabled': !canSubmit }"
@click="submitWithdraw"
>{{ submitLoading ? '提交中...' : '提现' }}</view>
</view>
<!-- 选择账户抽屉 -->
<u-popup v-model:show="drawerVisible" mode="bottom" round="16">
<view class="drawer-wrap">
<view class="drawer-header">
<text class="drawer-title">选择账户</text>
<view class="drawer-close" @click="drawerVisible = false">
<u-icon name="close" color="#999" size="18" />
</view>
</view>
<view v-if="drawerLoading" class="drawer-state">
<text class="drawer-state-text">加载中...</text>
</view>
<view v-else-if="allCards.length" class="drawer-list">
<view
class="drawer-item"
v-for="item in allCards"
:key="item.id"
@click="selectAccount(item)"
>
<view class="radio-circle" :class="{ active: selectedAccount?.id === item.id }">
<view class="radio-dot" v-if="selectedAccount?.id === item.id" />
</view>
<view class="drawer-item-info">
<text class="drawer-item-name">{{ item.holderName }}</text>
<view class="drawer-item-meta">
<text class="drawer-item-bank">{{ item.bankName }}</text>
<text class="drawer-item-card">{{ maskCard(item.cardNo) }}</text>
</view>
</view>
</view>
</view>
<view v-else class="drawer-state">
<text class="drawer-state-text">暂无银行卡</text>
</view>
<view class="drawer-add-btn" @click="openAddForm">+ 添加账户</view>
</view>
</u-popup>
<!-- 添加账户 Popup -->
<u-popup v-model:show="addPopupVisible" mode="bottom" round="16">
<view class="popup-wrap">
<view class="popup-title">添加到账账户</view>
<view class="form-item">
<text class="form-label">姓名</text>
<input
class="form-input"
v-model="form.holderName"
placeholder="请输入收款人姓名"
placeholder-class="form-placeholder"
/>
</view>
<view class="form-divider" />
<view class="form-item">
<text class="form-label">银行</text>
<input
class="form-input"
v-model="form.bankName"
placeholder="如:中国工商银行"
placeholder-class="form-placeholder"
/>
</view>
<view class="form-divider" />
<view class="form-item">
<text class="form-label">卡号</text>
<input
class="form-input"
v-model="form.cardNo"
type="number"
placeholder="请输入银行卡号"
placeholder-class="form-placeholder"
/>
</view>
<view class="popup-actions">
<view class="popup-btn ghost" @click="addPopupVisible = false">取消</view>
<view
class="popup-btn primary"
:class="{ disabled: saveLoading }"
@click="saveAccount"
>{{ saveLoading ? '保存中...' : '保存' }}</view>
</view>
</view>
</u-popup>
</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 { distribution, cash, getBankCards, addBankCard } from '@/api/distribution'
const store = useStore()
const themeStyle = computed(() => getThemeStyle(store.state.theme))
const canRebate = ref(0)
const amount = ref('')
const submitLoading = ref(false)
const selectedAccount = ref<any>(null)
const allCards = ref<any[]>([])
const inited = ref(false)
// 抽屉
const drawerVisible = ref(false)
const drawerLoading = ref(false)
// 添加账户弹窗
const addPopupVisible = ref(false)
const saveLoading = ref(false)
const form = ref({ holderName: '', bankName: '', cardNo: '' })
function loadInfo() {
distribution().then((res: any) => {
const d = res?.data?.result || {}
canRebate.value = Number(d.canRebate || 0)
if (!inited.value) {
const prefill = Math.min(canRebate.value, 9999)
amount.value = prefill > 0 ? prefill.toFixed(2) : ''
inited.value = true
}
})
}
function loadCards() {
drawerLoading.value = true
getBankCards()
.then((res: any) => {
allCards.value = res?.data?.result || []
if (!selectedAccount.value && allCards.value.length) {
const def = allCards.value.find((a: any) => a.isDefault) || allCards.value[0]
selectedAccount.value = def
}
})
.finally(() => {
drawerLoading.value = false
})
}
onLoad(() => {
loadInfo()
loadCards()
})
function openAccountDrawer() {
drawerVisible.value = true
loadCards()
}
function selectAccount(item: any) {
selectedAccount.value = item
drawerVisible.value = false
}
function openAddForm() {
form.value = { holderName: '', bankName: '', cardNo: '' }
addPopupVisible.value = true
}
function saveAccount() {
if (!form.value.holderName.trim()) {
uni.showToast({ title: '请输入姓名', icon: 'none' })
return
}
if (!form.value.bankName.trim()) {
uni.showToast({ title: '请输入银行名称', icon: 'none' })
return
}
if (!form.value.cardNo.trim()) {
uni.showToast({ title: '请输入银行卡号', icon: 'none' })
return
}
saveLoading.value = true
addBankCard({
holderName: form.value.holderName.trim(),
bankName: form.value.bankName.trim(),
cardNo: form.value.cardNo.trim(),
})
.then((res: any) => {
const newCard = res?.data?.result
addPopupVisible.value = false
uni.showToast({ title: '添加成功', icon: 'success' })
loadCards()
if (newCard?.id) {
selectedAccount.value = newCard
drawerVisible.value = false
}
})
.catch((err: any) => {
const msg = err?.data?.message || '添加失败,请重试'
uni.showToast({ title: msg, icon: 'none' })
})
.finally(() => {
saveLoading.value = false
})
}
function fillAll() {
const max = Math.min(canRebate.value, 9999)
amount.value = max.toFixed(2)
}
const errorMsg = computed(() => {
if (!amount.value) return ''
const price = Number(amount.value)
if (price < 1) return '最小提现金额为1元'
if (price > 9999) return '单次提现最多9999元'
if (price > canRebate.value) return '提现金额不能超过可提现余额'
return ''
})
const canSubmit = computed(() => {
const price = Number(amount.value)
return !submitLoading.value && price >= 1 && price <= 9999 && price <= canRebate.value
})
function submitWithdraw() {
if (!selectedAccount.value) {
uni.showToast({ title: '请先添加到账账户', icon: 'none' })
return
}
if (!canSubmit.value) return
const price = Number(amount.value)
submitLoading.value = true
cash({ price })
.then(() => {
uni.showToast({ title: '提现申请已提交', icon: 'success' })
setTimeout(() => uni.navigateBack(), 1500)
})
.catch((err: any) => {
const msg = err?.data?.message || '提现失败,请重试'
uni.showToast({ title: msg, icon: 'none' })
})
.finally(() => {
submitLoading.value = false
})
}
function formatMoney(val: number) {
return Number(val || 0).toFixed(2)
}
function maskCard(cardNo: string) {
if (!cardNo || cardNo.length < 4) return cardNo
return '****' + cardNo.slice(-4)
}
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background: #f5f6f8;
}
.content { padding: 24rpx; }
.section-card {
background: #fff;
border-radius: 20rpx;
padding: 0 32rpx;
margin-bottom: 24rpx;
}
.account-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 32rpx 0;
}
.account-label { font-size: 28rpx; color: #333; font-weight: 500; }
.account-right { display: flex; align-items: center; gap: 8rpx; }
.account-info { display: flex; align-items: center; gap: 10rpx; }
.account-bank { font-size: 24rpx; color: #999; }
.account-card { font-size: 24rpx; color: #999; }
.account-add-text { font-size: 26rpx; color: var(--theme-primary); }
.amount-card { padding: 32rpx; }
.amount-label { font-size: 26rpx; color: #999; display: block; margin-bottom: 20rpx; }
.amount-input-wrap {
display: flex;
align-items: center;
gap: 6rpx;
margin-bottom: 24rpx;
height: 80rpx;
}
.amount-symbol {
font-size: 36rpx;
font-weight: 500;
color: #333;
line-height: 80rpx;
flex-shrink: 0;
}
.amount-input {
flex: 1;
font-size: 60rpx;
font-weight: 700;
color: #333;
height: 80rpx;
line-height: 80rpx;
background: transparent;
min-width: 0;
}
.amount-placeholder { font-size: 60rpx; color: #ddd; font-weight: 300; }
.amount-divider { height: 1rpx; background: #f5f5f5; margin-bottom: 20rpx; }
.amount-hint-row { display: flex; align-items: center; justify-content: space-between; }
.amount-balance-hint { font-size: 24rpx; color: #999; }
.amount-all { font-size: 24rpx; color: var(--theme-primary); }
.amount-error { display: block; font-size: 24rpx; color: #f56c6c; margin-top: 16rpx; }
.submit-btn {
margin-top: 16rpx;
height: 88rpx;
line-height: 88rpx;
text-align: center;
border-radius: 16rpx;
background: var(--theme-primary);
color: #fff;
font-size: 32rpx;
font-weight: 600;
letter-spacing: 4rpx;
box-shadow: 0 8rpx 24rpx var(--theme-primary-30);
}
.submit-btn--disabled { opacity: 0.5; box-shadow: none; }
/* ── 选择账户抽屉 ──────────────────────────── */
.drawer-wrap { padding: 0 0 48rpx; }
.drawer-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 32rpx 32rpx 24rpx;
border-bottom: 1rpx solid #f5f5f5;
}
.drawer-title { font-size: 30rpx; font-weight: 600; color: #333; }
.drawer-close {
width: 56rpx;
height: 56rpx;
display: flex;
align-items: center;
justify-content: center;
}
.drawer-list { max-height: 600rpx; overflow-y: auto; }
.drawer-item {
display: flex;
align-items: center;
gap: 24rpx;
padding: 28rpx 32rpx;
border-bottom: 1rpx solid #f8f8f8;
}
.radio-circle {
width: 40rpx;
height: 40rpx;
border-radius: 50%;
border: 2rpx solid #ddd;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
&.active { border-color: var(--theme-primary); }
}
.radio-dot {
width: 22rpx;
height: 22rpx;
border-radius: 50%;
background: var(--theme-primary);
}
.drawer-item-info {
display: flex;
flex-direction: column;
gap: 8rpx;
flex: 1;
min-width: 0;
}
.drawer-item-name { font-size: 28rpx; font-weight: 500; color: #333; }
.drawer-item-meta { display: flex; align-items: center; gap: 12rpx; }
.drawer-item-bank { font-size: 24rpx; color: #666; }
.drawer-item-card { font-size: 24rpx; color: #999; }
.drawer-state { padding: 60rpx 0; text-align: center; }
.drawer-state-text { font-size: 26rpx; color: #ccc; }
.drawer-add-btn {
margin: 24rpx 32rpx 0;
height: 88rpx;
line-height: 88rpx;
text-align: center;
border-radius: 16rpx;
border: 2rpx solid var(--theme-primary);
color: var(--theme-primary);
font-size: 30rpx;
font-weight: 500;
}
/* ── 添加账户弹窗 ──────────────────────────── */
.popup-wrap { padding: 32rpx 28rpx 48rpx; }
.popup-title {
text-align: center;
font-size: 30rpx;
font-weight: 600;
color: #333;
margin-bottom: 32rpx;
}
.form-item { display: flex; align-items: center; padding: 18rpx 0; }
.form-label { font-size: 28rpx; color: #333; width: 100rpx; flex-shrink: 0; }
.form-input { flex: 1; font-size: 28rpx; color: #333; height: 64rpx; }
.form-placeholder { color: #ccc; font-size: 26rpx; }
.form-divider { height: 1rpx; background: #f5f5f5; }
.popup-actions { display: flex; gap: 20rpx; margin-top: 32rpx; }
.popup-btn {
flex: 1;
height: 88rpx;
line-height: 88rpx;
text-align: center;
border-radius: 999rpx;
font-size: 28rpx;
font-weight: 500;
}
.popup-btn.ghost { color: #666; background: #f5f5f5; }
.popup-btn.primary { color: #fff; background: var(--theme-primary); }
.popup-btn.disabled { opacity: 0.5; }
</style>