Files
lilishop-uniapp/pages/mine/distribution/join.vue
pikachu1995@126.com 7d3daf2f02 feat(distribution): 实现分销员中心完整功能
- 新增分销业绩统计页面,包含时间范围筛选和自定义日期选择功能
- 重构分销认证页面,改为跳转至招募页面申请分销员身份
- 添加分销商品访问记录功能,支持分享追踪和佣金计算
- 更新分销历史记录页面,优化数据结构和显示字段
- 完全重写分销员首页,包含用户信息、收益统计、等级系统等功能
2026-08-05 18:07:27 +08:00

757 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<view class="wrapper" :style="themeStyle">
<view v-if="loading" class="loading">加载中...</view>
<view v-else-if="!plan">
<view class="empty">暂未开放线上招募</view>
</view>
<view v-else class="page-body" :class="{ 'with-footer': showManualApply || isApplyPending }">
<!-- 招募海报 -->
<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="section" v-if="hasJoinCondition">
<view class="title">加入条件</view>
<view class="condition-tip">满足以下条件后可申请成为分销员</view>
<view class="condition-item" v-if="plan.requireSelfPurchaseAmount">
<view class="condition-head">
<text class="condition-label">自购金额</text>
<text class="condition-status" :class="selfPurchaseOk ? 'ok' : 'pending'">
{{ selfPurchaseOk ? '已达标' : '未达标' }}
</text>
</view>
<view class="condition-value">
{{ formatMoney(progress?.currentSelfPurchaseAmount) }} / {{ formatMoney(plan.minSelfPurchaseAmount) }}
</view>
</view>
<view class="condition-item" v-if="plan.requireConsumeCount">
<view class="condition-head">
<text class="condition-label">消费笔数</text>
<text class="condition-status" :class="consumeCountOk ? 'ok' : 'pending'">
{{ consumeCountOk ? '已达标' : '未达标' }}
</text>
</view>
<view class="condition-value">
{{ progress?.currentConsumeCount || 0 }} / {{ plan.minConsumeCount || 0 }}
</view>
</view>
<view class="condition-item" v-if="plan.requirePurchaseGoods">
<view class="condition-head">
<text class="condition-label">购买指定商品</text>
<text class="condition-status" :class="progress?.purchaseGoodsSatisfied ? 'ok' : 'pending'">
{{ progress?.purchaseGoodsSatisfied ? '已达标' : '未达标' }}
</text>
</view>
<view class="goods-list" v-if="recruitGoods.length">
<view class="goods-item" v-for="item in recruitGoods" :key="item.skuId || item.goodsId">
<image class="goods-thumb" :src="item.thumbnail" mode="aspectFill" />
<view class="goods-meta">
<view class="goods-name">{{ item.goodsName }}</view>
<view class="goods-spec" v-if="item.simpleSpecs">{{ item.simpleSpecs }}</view>
<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 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>
</template>
<script setup lang="ts">
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'
const store = useStore()
const themeStyle = computed(() => getThemeStyle(store.state.theme))
const loading = ref(true)
const pageInfo = ref<any>({})
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 plan = computed(() => pageInfo.value.plan || null)
const fields = computed(() => pageInfo.value.fields || [])
const recruitGoods = computed(() => plan.value?.recruitGoods || [])
const agreementContent = computed(() => plan.value?.agreementContent || '')
const showPoster = computed(() => {
const p = plan.value
if (!p) return false
if (p.posterEnabled === false) return false
return !!(p.posterBackground && String(p.posterBackground).trim())
})
const posterTitle = computed(() => plan.value?.posterTitle || '')
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
})
}
function formatMoney(val: number | string | undefined) {
return Number(val || 0).toFixed(2)
}
function previewPoster() {
if (!plan.value?.posterBackground) return
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>
<style lang="scss" scoped>
.wrapper {
min-height: 100vh;
padding: 24rpx;
background: #f5f6f8;
}
.loading,
.empty {
text-align: center;
color: #999;
padding: 80rpx 0;
font-size: 28rpx;
}
.section {
background: #fff;
border-radius: 16rpx;
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;
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 {
background: $light-color;
color: #fff;
text-align: center;
padding: 24rpx;
border-radius: 40rpx;
font-size: 30rpx;
}
.submit.disabled {
opacity: 0.5;
}
</style>