Merge branch 'feature/order-delivery-fulfillment'

This commit is contained in:
田香琪
2026-08-13 17:30:02 +08:00
51 changed files with 12602 additions and 3027 deletions

View File

@@ -1,375 +1,386 @@
<template>
<view class="add-address">
<up-form
:model="form"
ref="uForm"
error-type="toast"
:rules="rules"
label-position="left"
label-width="180rpx"
>
<up-form-item label="收货人" label-width="180rpx" prop="name" :border-bottom="true">
<u-input
v-model="form.name"
border="none"
input-align="right"
clearable
placeholder="请输入收货人姓名"
/>
</up-form-item>
<up-form-item label="手机号码" label-width="180rpx" prop="mobile" :border-bottom="true">
<u-input
v-model="form.mobile"
type="number"
maxlength="11"
border="none"
input-align="right"
placeholder="请输入收货人手机号码"
/>
</up-form-item>
<up-form-item label="所在区域" label-width="180rpx" prop="___path" :border-bottom="true">
<view class="form-value" @click="showPicker">
{{ form.___path || '请选择所在地区' }}
</view>
<template #right>
<u-icon name="arrow-right" color="#ccc" size="16"></u-icon>
</template>
</up-form-item>
<up-form-item class="detailAddress" label="详细地址" label-width="180rpx" prop="detail" :border-bottom="true">
<u-input
type="textarea"
v-model="form.detail"
maxlength="100"
height="150"
border="none"
placeholder="街道楼牌号等"
/>
</up-form-item>
<up-form-item label="地址别名" label-width="180rpx" :border-bottom="false">
<u-input
v-model="form.alias"
border="none"
input-align="right"
placeholder="请输入地址别名"
/>
</up-form-item>
<view class="default-row">
<u-checkbox
usedAlone
shape="circle"
size="30"
:active-color="lightColor"
v-model:checked="form.isDefault"
label="设为默认地址"
></u-checkbox>
</view>
<view class="saveBtn" @click="save">保存</view>
</up-form>
<m-city
:provinceData="list"
headTitle="区域选择"
ref="cityPicker"
@funcValue="getpickerParentValue"
pickerSize="4"
></m-city>
<uniMap v-if="mapFlag" @close="closeMap" @callback="callBackAddress" />
</view>
</template>
<script setup lang="ts">
import { ref, reactive, computed, getCurrentInstance } from 'vue'
import { onLoad, onShow, onReady } from '@dcloudio/uni-app'
import { useStore } from '@/store'
import { addAddress as addAddressApi, editAddress, getAddressDetail } from '@/api/address.js'
import MCity from '@/components/m-city/m-city.vue'
import uniMap from '@/components/uniMap'
import permision from '@/js_sdk/wa-permission/permission.js'
const store = useStore()
const { proxy } = getCurrentInstance()!
const lightColor = computed(() => store.getters.lightColor)
const mapFlag = ref(false)
const routerVal = ref<Record<string, string>>({})
const uForm = ref<any>(null)
const cityPicker = ref<any>(null)
const form = reactive<Record<string, any>>({
detail: '',
name: '',
mobile: '',
consigneeAddressIdPath: [],
consigneeAddressPath: [],
___path: '',
isDefault: false,
})
const list = ref([
{
id: '',
localName: '请选择',
children: [],
},
])
const rules = {
name: [
{
required: true,
message: '收货人姓名不能为空',
trigger: ['blur', 'change'],
},
],
mobile: [
{
required: true,
message: '手机号码不能为空',
trigger: ['blur', 'change'],
},
{
validator: (_rule: unknown, value: string) => proxy.$u.test.mobile(value),
message: '手机号码不正确',
trigger: ['change', 'blur'],
},
],
___path: [
{
required: true,
message: '请选择所在区域',
trigger: ['change'],
},
],
detail: [
{
required: true,
message: '请填写详细地址',
trigger: ['blur', 'change'],
},
],
}
onShow(() => {})
onLoad((option) => {
uni.showLoading({ title: '加载中' })
routerVal.value = option || {}
if (option.id) {
getAddressDetail(option.id).then((res) => {
const params = res.data.result
params.___path = params.consigneeAddressPath
Object.assign(form, params)
if (store.state.isShowToast) uni.hideLoading()
})
}
uni.hideLoading()
})
onReady(() => {
uForm.value?.setRules(rules)
})
function closeMap() {
mapFlag.value = false
}
function refuseMap() {
uni.showModal({
title: '温馨提示',
content: '您已拒绝定位,请开启',
confirmText: '去设置',
success(res) {
if (res.confirm) {
// #ifndef MP-WEIXIN
uni.getSystemInfo({
success(sysRes) {
if (sysRes.platform == 'ios') {
plus.runtime.openURL('app-settings://')
} else if (sysRes.platform == 'android') {
const main = plus.android.runtimeMainActivity()
const Intent = plus.android.importClass('android.content.Intent')
const mIntent = new Intent('android.settings.ACTION_SETTINGS')
main.startActivity(mIntent)
}
},
})
// #endif
}
},
})
}
async function requestAndroidPermission(permisionID: string) {
const result = await permision.requestAndroidPermission(permisionID)
if (result == 1) {
mapFlag.value = true
} else {
refuseMap()
}
}
function callBackAddress(val: any) {
uni.showLoading({ title: '加载中' })
if (val.regeocode && val) {
const address = val.regeocode
form.detail = address.formatted_address
form.___path = val.data.result.name
form.consigneeAddressIdPath = val.data.result.id
form.consigneeAddressPath = val.data.result.name
form.lat = val.latitude
form.lon = val.longitude
uni.hideLoading()
}
mapFlag.value = !mapFlag.value
}
function save() {
uForm.value?.validate().then(() => {
const params = { ...form }
delete params.___path
if (Array.isArray(params.consigneeAddressIdPath)) {
params.consigneeAddressIdPath = params.consigneeAddressIdPath.join(',')
}
if (Array.isArray(params.consigneeAddressPath)) {
params.consigneeAddressPath = params.consigneeAddressPath.join(',')
}
if (!params.id) {
addAddressApi(params).then((res) => {
if (res.data.success) {
uni.navigateBack()
} else {
uni.showToast({ title: res.data.message || '保存失败', icon: 'none' })
}
})
} else {
delete params.updateBy
delete params.updateTime
editAddress(params).then((res) => {
if (res.data.success) {
uni.navigateBack()
} else {
uni.showToast({ title: res.data.message || '保存失败', icon: 'none' })
}
})
}
}).catch(() => {})
}
function getpickerParentValue(e: any[]) {
form.consigneeAddressIdPath = []
form.consigneeAddressPath = []
let name = ''
e.forEach((item, index) => {
if (item.id) {
form.consigneeAddressIdPath.push(item.id)
form.consigneeAddressPath.push(item.localName)
name += item.localName
form.___path = name
}
if (index == e.length - 1) {
const _town = item.children.filter((_child: any) => _child.id == item.id)
form.lat = _town[0].center.split(',')[1]
form.lon = _town[0].center.split(',')[0]
}
})
}
function showPicker() {
cityPicker.value?.show()
}
</script>
<style scoped lang="scss">
page {
background: #f5f5f5;
}
.add-address {
min-height: 100vh;
background: #fff;
padding-bottom: 40rpx;
}
:deep(.u-form-item) {
padding: 0 30rpx;
min-height: 100rpx;
}
:deep(.u-form-item__body) {
min-height: 100rpx;
align-items: center;
}
:deep(.u-form-item__body__left__content__label) {
font-size: 30rpx;
color: #333;
}
:deep(.u-form-item__body__right__content__slot) {
flex: 1;
display: flex;
justify-content: flex-end;
align-items: center;
}
:deep(.u-input) {
padding: 0 !important;
}
:deep(.u-input__content__field-wrapper__field) {
font-size: 28rpx !important;
color: #666 !important;
}
.form-value {
width: 100%;
text-align: right;
font-size: 28rpx;
color: #666;
}
.detailAddress {
:deep(.u-form-item__body) {
align-items: flex-start;
padding-top: 24rpx;
padding-bottom: 24rpx;
min-height: auto;
}
:deep(.u-form-item__body__left) {
padding-top: 8rpx;
}
:deep(.u-form-item__body__right__content__slot) {
justify-content: flex-start;
align-items: flex-start;
}
}
.default-row {
padding: 24rpx 30rpx;
}
:deep(.default-row .u-checkbox__label) {
font-size: 28rpx;
color: $font-color-light;
}
.saveBtn {
margin: 40rpx 30rpx 0;
height: 80rpx;
line-height: 80rpx;
text-align: center;
font-size: 30rpx;
background: $light-color;
color: #fff;
border-radius: 40rpx;
}
<template>
<view class="add-address">
<up-form
:model="form"
ref="uForm"
error-type="toast"
:rules="rules"
label-position="left"
label-width="180rpx"
>
<up-form-item label="收货人" label-width="180rpx" prop="name" :border-bottom="true">
<u-input
v-model="form.name"
border="none"
input-align="right"
clearable
placeholder="请输入收货人姓名"
/>
</up-form-item>
<up-form-item label="手机号码" label-width="180rpx" prop="mobile" :border-bottom="true">
<u-input
v-model="form.mobile"
type="number"
maxlength="11"
border="none"
input-align="right"
placeholder="请输入收货人手机号码"
/>
</up-form-item>
<up-form-item label="所在区域" label-width="180rpx" prop="___path" :border-bottom="true">
<view class="form-value" @click="showPicker">
{{ form.___path || '请选择所在地区' }}
</view>
<template #right>
<u-icon name="arrow-right" color="#ccc" size="16"></u-icon>
</template>
</up-form-item>
<up-form-item class="detailAddress" label="详细地址" label-width="180rpx" prop="detail" :border-bottom="true">
<u-input
type="textarea"
v-model="form.detail"
maxlength="100"
height="150"
border="none"
input-align="right"
placeholder="街道楼牌号等"
/>
</up-form-item>
<up-form-item label="地址别名" label-width="180rpx" :border-bottom="false">
<u-input
v-model="form.alias"
border="none"
input-align="right"
placeholder="请输入地址别名"
/>
</up-form-item>
<view class="default-row">
<u-checkbox
usedAlone
shape="circle"
size="30"
:active-color="lightColor"
v-model:checked="form.isDefault"
label="设为默认地址"
></u-checkbox>
</view>
<view class="saveBtn" @click="save">保存</view>
</up-form>
<m-city
:provinceData="list"
headTitle="区域选择"
ref="cityPicker"
@funcValue="getpickerParentValue"
pickerSize="4"
></m-city>
<uniMap v-if="mapFlag" @close="closeMap" @callback="callBackAddress" />
</view>
</template>
<script setup lang="ts">
import { ref, reactive, computed, getCurrentInstance } from 'vue'
import { onLoad, onShow, onReady } from '@dcloudio/uni-app'
import { useStore } from '@/store'
import { addAddress as addAddressApi, editAddress, getAddressDetail } from '@/api/address.js'
import MCity from '@/components/m-city/m-city.vue'
import uniMap from '@/components/uniMap'
import permision from '@/js_sdk/wa-permission/permission.js'
const store = useStore()
const { proxy } = getCurrentInstance()!
const lightColor = computed(() => store.getters.lightColor)
const mapFlag = ref(false)
const routerVal = ref<Record<string, string>>({})
const uForm = ref<any>(null)
const cityPicker = ref<any>(null)
const form = reactive<Record<string, any>>({
detail: '',
name: '',
mobile: '',
consigneeAddressIdPath: [],
consigneeAddressPath: [],
___path: '',
isDefault: false,
})
const list = ref([
{
id: '',
localName: '请选择',
children: [],
},
])
const rules = {
name: [
{
required: true,
message: '收货人姓名不能为空',
trigger: ['blur', 'change'],
},
],
mobile: [
{
required: true,
message: '手机号码不能为空',
trigger: ['blur', 'change'],
},
{
validator: (_rule: unknown, value: string) => proxy.$u.test.mobile(value),
message: '手机号码不正确',
trigger: ['change', 'blur'],
},
],
___path: [
{
required: true,
message: '请选择所在区域',
trigger: ['change'],
},
],
detail: [
{
required: true,
message: '请填写详细地址',
trigger: ['blur', 'change'],
},
],
}
onShow(() => {})
onLoad((option) => {
uni.showLoading({ title: '加载中' })
routerVal.value = option || {}
if (option.id) {
getAddressDetail(option.id).then((res) => {
const params = res.data.result
params.___path = params.consigneeAddressPath
Object.assign(form, params)
if (store.state.isShowToast) uni.hideLoading()
})
}
uni.hideLoading()
})
onReady(() => {
uForm.value?.setRules(rules)
})
function closeMap() {
mapFlag.value = false
}
function refuseMap() {
uni.showModal({
title: '温馨提示',
content: '您已拒绝定位,请开启',
confirmText: '去设置',
success(res) {
if (res.confirm) {
// #ifndef MP-WEIXIN
uni.getSystemInfo({
success(sysRes) {
if (sysRes.platform == 'ios') {
plus.runtime.openURL('app-settings://')
} else if (sysRes.platform == 'android') {
const main = plus.android.runtimeMainActivity()
const Intent = plus.android.importClass('android.content.Intent')
const mIntent = new Intent('android.settings.ACTION_SETTINGS')
main.startActivity(mIntent)
}
},
})
// #endif
}
},
})
}
async function requestAndroidPermission(permisionID: string) {
const result = await permision.requestAndroidPermission(permisionID)
if (result == 1) {
mapFlag.value = true
} else {
refuseMap()
}
}
function callBackAddress(val: any) {
uni.showLoading({ title: '加载中' })
if (val.regeocode && val) {
const address = val.regeocode
form.detail = address.formatted_address
form.___path = val.data.result.name
form.consigneeAddressIdPath = val.data.result.id
form.consigneeAddressPath = val.data.result.name
form.lat = val.latitude
form.lon = val.longitude
uni.hideLoading()
}
mapFlag.value = !mapFlag.value
}
function save() {
uForm.value?.validate().then(() => {
const params = { ...form }
delete params.___path
if (Array.isArray(params.consigneeAddressIdPath)) {
params.consigneeAddressIdPath = params.consigneeAddressIdPath.join(',')
}
if (Array.isArray(params.consigneeAddressPath)) {
params.consigneeAddressPath = params.consigneeAddressPath.join(',')
}
if (!params.id) {
addAddressApi(params).then((res) => {
if (res.data.success) {
uni.navigateBack()
} else {
uni.showToast({ title: res.data.message || '保存失败', icon: 'none' })
}
})
} else {
delete params.updateBy
delete params.updateTime
editAddress(params).then((res) => {
if (res.data.success) {
uni.navigateBack()
} else {
uni.showToast({ title: res.data.message || '保存失败', icon: 'none' })
}
})
}
}).catch(() => {})
}
function getpickerParentValue(e: any[]) {
form.consigneeAddressIdPath = []
form.consigneeAddressPath = []
let name = ''
e.forEach((item, index) => {
if (item.id) {
form.consigneeAddressIdPath.push(item.id)
form.consigneeAddressPath.push(item.localName)
name += item.localName
form.___path = name
}
if (index == e.length - 1) {
const _town = item.children.filter((_child: any) => _child.id == item.id)
form.lat = _town[0].center.split(',')[1]
form.lon = _town[0].center.split(',')[0]
}
})
}
function showPicker() {
cityPicker.value?.show()
}
</script>
<style scoped lang="scss">
page {
background: #f5f5f5;
}
.add-address {
min-height: 100vh;
background: #fff;
padding-bottom: 40rpx;
}
:deep(.u-form-item) {
padding: 0 30rpx;
min-height: 100rpx;
}
:deep(.u-form-item__body) {
min-height: 100rpx;
align-items: center;
}
:deep(.u-form-item__body__left__content__label) {
font-size: 30rpx;
color: #333;
}
:deep(.u-form-item__body__right__content__slot) {
flex: 1;
display: flex;
justify-content: flex-end;
align-items: center;
}
:deep(.u-input) {
padding: 0 !important;
}
:deep(.u-input__content__field-wrapper__field) {
font-size: 28rpx !important;
color: #666 !important;
}
.form-value {
width: 100%;
text-align: right;
font-size: 28rpx;
color: #666;
}
.detailAddress {
:deep(.u-form-item__body) {
align-items: flex-start;
padding-top: 24rpx;
padding-bottom: 24rpx;
min-height: auto;
}
:deep(.u-form-item__body__left) {
padding-top: 8rpx;
}
:deep(.u-form-item__body__right__content__slot) {
justify-content: flex-end;
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 {
padding: 24rpx 30rpx;
}
:deep(.default-row .u-checkbox__label) {
font-size: 28rpx;
color: $font-color-light;
}
.saveBtn {
margin: 40rpx 30rpx 0;
height: 80rpx;
line-height: 80rpx;
text-align: center;
font-size: 30rpx;
background: $light-color;
color: #fff;
border-radius: 40rpx;
}
</style>

View File

@@ -1,10 +1,555 @@
<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>
<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>
<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>

View File

@@ -1,99 +1,24 @@
<template>
<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>您提交的信息正在审核</view>
<view>提交认证申请后工作人员将在三个工作日进行核对完成审核</view>
<view>分销员申请已改为招募流程</view>
<view>请前往招募页面提交申请</view>
</view>
<u-button :customStyle="{ background: lightColor, color: '#fff', marginTop: '40rpx' }" @click="goJoin">
前往招募页
</u-button>
</view>
</template>
<script setup lang="ts">
import { reactive, computed, ref, getCurrentInstance } from 'vue'
import { onReady } from '@dcloudio/uni-app'
import { computed } from 'vue'
import { useStore } from '@/store'
import { applyDistribution } from '@/api/goods'
const store = useStore()
const { proxy } = getCurrentInstance()!
const lightColor = computed(() => store.getters.lightColor)
const uFormRef = ref<any>(null)
const formData = reactive({
name: '',
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' })
})
function goJoin() {
uni.redirectTo({ url: '/pages/mine/distribution/join' })
}
</script>
@@ -102,8 +27,8 @@ function submitForm() {
padding: 32rpx;
}
.tips {
margin-top: 20rpx;
font-size: 24rpx;
color: #999;
font-size: 28rpx;
color: #666;
line-height: 1.6;
}
</style>

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

@@ -0,0 +1,32 @@
<template>
<view class="bind-page"></view>
</template>
<script setup lang="ts">
import { onLoad } from '@dcloudio/uni-app'
import {
resolveDistributionIdFromScene,
tryBindDistribution,
} from '@/utils/distributionBind.js'
onLoad(async (options) => {
let distributionId = (options?.distributionId as string) || ''
if (options?.scene) {
distributionId = await resolveDistributionIdFromScene(String(options.scene))
}
if (distributionId) {
await tryBindDistribution(distributionId)
}
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,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

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

File diff suppressed because it is too large Load Diff

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,485 @@
<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>
<template v-else>
<view v-if="qrLoading || posterComposing" class="card state-card">
<text class="state-text">{{ posterComposing ? '邀请卡合成中...' : qrLoadingText }}</text>
</view>
<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">
<text class="btn-text">微信分享</text>
</button>
<!-- #endif -->
<view class="save-btn" @click="savePoster">保存海报</view>
<view class="hint-text">点击预览长按或保存到相册</view>
</view>
<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, 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('加载邀请卡设置...')
const pageError = ref('')
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 avatarUrl = computed(() => parseGoodsImageUrl(inviteContext.value.memberAvatar))
const backgroundUrl = computed(() => parseGoodsImageUrl(inviteContext.value.backgroundImage))
onLoad(() => {
loadInviteCardSetting()
})
onReady(() => {
// #ifdef MP-WEIXIN
initCanvas2d()
// #endif
})
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: posterImage.value || 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 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')
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 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()
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 = ''
posterImage.value = ''
composeError.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)
await composePoster()
} catch (error: any) {
console.error('load invite qr code failed', error)
qrError.value = error?.message || '葵花码生成失败,请稍后重试'
} finally {
qrLoading.value = false
}
}
function retryGenerate() {
if (qrImage.value && composeError.value) {
composePoster()
return
}
loadQrCode()
}
function previewPoster() {
if (!posterImage.value) return
uni.previewImage({
current: posterImage.value,
urls: [posterImage.value],
})
}
function savePoster() {
savePosterToAlbum(posterImage.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,
.save-btn {
display: inline-block;
margin-top: 24rpx;
padding: 12rpx 40rpx;
border-radius: 999rpx;
background: linear-gradient(90deg, #ff9f3f, #ff6b35);
color: #fff;
font-size: 26rpx;
}
.card {
position: relative;
background: #fff;
border-radius: 16rpx;
overflow: hidden;
aspect-ratio: 630 / 1000;
}
.state-card {
display: flex;
align-items: center;
justify-content: center;
}
.poster-image {
width: 100%;
border-radius: 16rpx;
display: block;
}
.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;
}
.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>

File diff suppressed because it is too large Load Diff

View File

@@ -1,496 +1,402 @@
<template>
<view class="wrapper">
<!-- 筛选弹出层 TODO后续版本更新 -->
<!-- <u-popup width="90%" v-model:show="popup" mode="right">
<view class="screen-title">商品筛选</view>
<view class="screen-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-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 class="page">
<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>
<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>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import { useStore } from '@/store'
import {
distributionGoods,
checkedDistributionGoods,
getMpCode,
} from '@/api/goods'
import drawCanvas from '@/components/m-canvas'
import { unitPrice } from '@/utils/filters.js'
import { onLoad } from '@dcloudio/uni-app'
import { distributionGoods, getDistributionGoodsSetting } from '@/api/distribution'
const store = useStore()
const lightColor = computed(() => store.getters.lightColor)
const swipeOptions = computed(() => [
{
text: '解绑',
style: { backgroundColor: lightColor.value },
},
])
const DEFAULT_SORT_MAP: Record<string, { field: string; order: string }> = {
HIGHEST_COMMISSION: { field: 'COMMISSION', order: 'DESC' },
HIGHEST_PRICE: { field: 'PRICE', order: 'DESC' },
HIGHEST_SALES: { field: 'SALES', order: 'DESC' },
LATEST_LISTING: { field: 'LATEST', order: 'DESC' },
}
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 selectedGoods = ref<any>(null)
const drawCanvasRef = ref<any>(null)
const keyword = ref('')
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({
pageNumber: 1,
pageSize: 10,
checked: true,
goodsName: '',
sortField: 'COMMISSION',
sortOrder: 'DESC',
})
const goodsList = ref<any[]>([])
const posterData = ref({
container: {
width: 600,
height: 960,
background: '#fff',
title: '分享背景',
},
bottom: {
img: '',
code: '',
price: 0,
desc: '',
},
})
const distributionId = computed(() => routeQuery.value.id || '')
onLoad((options) => {
routeQuery.value = options || {}
routeQuery.value = (options || {}) as Record<string, string>
initPage()
})
onShow(() => {
goodsList.value = []
queryParams.value.pageNumber = 1
fetchGoodsList()
})
function confirmUnbindPrompt(item: any) {
showUnbindModal.value = true
selectedGoods.value = item
async function initPage() {
await loadGoodsSetting()
resetAndFetch()
}
function confirmUnbind() {
checkedDistributionGoods({ id: selectedGoods.value.id, checked: false }).then((res) => {
if (res.data.success) {
uni.showToast({ title: '此商品解绑成功', duration: 2000 })
showUnbindModal.value = false
goodsList.value = []
queryParams.value.pageNumber = 1
fetchGoodsList()
async function loadGoodsSetting() {
try {
const res = await getDistributionGoodsSetting()
if (res.data?.success && res.data.result) {
const setting = res.data.result
const mapped = DEFAULT_SORT_MAP[setting.defaultSort] || DEFAULT_SORT_MAP.HIGHEST_COMMISSION
sortField.value = mapped.field
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) {
goodsList.value.forEach((row) => {
row.___selected = false
})
item.___selected = true
function formatMoney(val: number | string) {
const num = Number(val || 0)
return num.toFixed(2)
}
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({
url: `/pages/product/goods?id=${item.skuId}&goodsId=${item.goodsId}`,
})
}
async function shareDistributionGoods(goods: any) {
uni.showToast({ title: '请请按住保存图片', duration: 2000, icon: 'none' })
const page = 'pages/product/goods'
const scene = `${goods.skuId},${goods.goodsId},${routeQuery.value.id}`
const result = await getMpCode({ page, scene })
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' })
function shareGoods(item: any) {
const distId = distributionId.value
if (!item?.skuId || !item?.goodsId || !distId) {
uni.showToast({ title: '分享信息不完整', icon: 'none' })
return
}
}
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)
}
uni.navigateTo({
url: `/pages/mine/distribution/share?skuId=${item.skuId}&goodsId=${item.goodsId}&distributionId=${distId}`,
})
}
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>
<style lang="scss" scoped>
.body-view {
overflow-y: auto;
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 {
.page {
min-height: 100vh;
background: #f5f5f5;
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;
line-height: 88rpx;
position: fixed;
bottom: 0;
> .screen-clear,
.screen-submit {
width: 50%;
text-align: center;
}
.screen-submit {
background: $main-color;
color: #fff;
background: #fff;
border-bottom: 1rpx solid #f0f0f0;
}
.sort-item {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: #666;
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;
height: 2rpx;
background: #999;
margin: 0 10rpx;
}
.u-bg {
background: #eff1f4;
border-radius: 0.4em;
font-size: 22rpx;
}
.screen-title {
height: 88rpx;
.state-wrap {
padding: 120rpx 0;
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,
:deep(.u-swipe-action),
:deep(.u-swipe-action-item),
:deep(.u-swipe-action-item__content) {
width: 100%;
.state-text {
color: #999;
font-size: 28rpx;
}
: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 {
// #ifdef H5
height: calc(100vh - 176rpx);
// #endif
// #ifndef H5
height: calc(100vh - 88rpx);
// #endif
overflow: auto;
padding: 16rpx 0 24rpx;
}
.goods-item {
.goods-card {
display: flex;
margin: 0 20rpx 20rpx;
padding: 22rpx;
border-radius: 20rpx;
background: #fff;
}
.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;
padding: 22rpx;
margin: 20rpx;
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;
> .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;
}
}
}
margin-top: auto;
padding-top: 10rpx;
}
.wrapper {
width: 100%;
.goods-info {
flex: 1;
min-width: 0;
}
</style>
.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>

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,421 @@
<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>
<template v-else>
<view v-if="qrLoading || posterComposing" class="card state-card">
<text class="state-text">{{ posterComposing ? '海报合成中...' : qrLoadingText }}</text>
</view>
<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 -->
<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>
</view>
</template>
<script setup lang="ts">
import { ref, computed, getCurrentInstance } from 'vue'
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('加载海报设置...')
const pageError = ref('')
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>>({})
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: posterImage.value || 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 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')
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 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()
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 = ''
posterImage.value = ''
composeError.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)
await composePoster()
} catch (error: any) {
console.error('load qr code failed', error)
qrError.value = error?.message || '葵花码生成失败,请稍后重试'
} finally {
qrLoading.value = false
}
}
function retryGenerate() {
if (qrImage.value && composeError.value) {
composePoster()
return
}
loadQrCode()
}
function previewPoster() {
if (!posterImage.value) return
uni.previewImage({
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>
.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;
}
.card {
position: relative;
background: #fff;
border-radius: 16rpx;
overflow: hidden;
aspect-ratio: 630 / 1000;
}
.state-card {
display: flex;
align-items: center;
justify-content: center;
}
.poster-image {
width: 100%;
border-radius: 16rpx;
display: block;
}
.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,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

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

View File

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

View File

@@ -6,7 +6,7 @@
<div class="bagbar">{{ formatSendTime(row.send_time) }}</div>
</div>
<u-card
@click="navigateToLogisticsDetail(row.sn, row.logi_id, row.ship_no)"
@click="navigateToLogisticsDetail(row.sn)"
:title="pageTitle"
title-color="#666666"
title-size="24"
@@ -69,9 +69,9 @@ function extractRecords(res: any) {
return []
}
function navigateToLogisticsDetail(sn: string, logiId: string, shipNo: string) {
function navigateToLogisticsDetail(sn: string) {
uni.navigateTo({
url: `/pages/mine/msgTips/packageMsg/logisticsDetail?order_sn=${sn}&logi_id=${logiId}&ship_no=${shipNo}`,
url: `/pages/order/deliverDetail?order_sn=${sn}`,
})
}

View File

@@ -1,90 +1,52 @@
<template>
<view class="logistics-detail">
<view class="card">
<view class="card-title">
<span>{{ logisticsInfo.shipper }}</span>快递 <span>{{ logisticsInfo.logisticCode }}</span>
</view>
<view class="time-line">
<u-time-line v-if="logisticsInfo.traces && logisticsInfo.traces.length != 0">
<u-time-line-item nodeTop="2" v-for="(item, index) in logisticsInfo.traces" :key="index">
<template #node>
<view
v-if="index == logisticsInfo.traces.length - 1"
class="u-node"
:style="{ background: lightColor }"
style="padding: 0 4px"
>
<u-icon name="pushpin-fill" color="#fff" :size="24"></u-icon>
</view>
</template>
<template #content>
<view>
<view class="u-order-desc">{{ item.AcceptStation }}</view>
<view class="u-order-time">{{ item.AcceptTime }}</view>
</view>
</template>
</u-time-line-item>
</u-time-line>
<u-empty class="empty" v-else text="目前没有物流订单" mode="list"></u-empty>
</view>
</view>
<view class="redirect-wrap">
<u-loading mode="circle" />
</view>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { useStore } from '@/store'
import { getExpress } from '@/api/trade.js'
import { getPackage, getExpress } from '@/api/trade.js'
const store = useStore()
onLoad(async (option) => {
const orderSn = option?.order_sn
if (!orderSn) {
uni.showToast({ title: '订单号缺失', icon: 'none' })
return
}
const lightColor = computed(() => store.getters.lightColor)
const logisticsInfo = ref<Record<string, any>>({})
try {
const packageRes = await getPackage(orderSn)
const packages = packageRes?.data?.result || []
if (packages.length) {
uni.redirectTo({
url: `/pages/order/deliverDetail?order_sn=${orderSn}`,
})
return
}
onLoad((option) => {
fetchLogistics(option.order_sn)
})
const traceRes = await getExpress(orderSn)
if (traceRes?.data?.result) {
uni.redirectTo({
url: `/pages/order/deliverDetail?order_sn=${orderSn}`,
})
return
}
} catch {
// 降级跳转,由 deliverDetail 统一处理展示
}
function fetchLogistics(orderSn: string) {
getExpress(orderSn).then((res) => {
logisticsInfo.value = res.data.result || {}
uni.redirectTo({
url: `/pages/order/deliverDetail?order_sn=${orderSn}`,
})
}
})
</script>
<style lang="scss">
.card-title {
background: #f2f2f2;
}
.logistics-detail {
margin-top: 20rpx;
padding: 0 16rpx;
}
.card {
background: #fff;
border-radius: 20rpx;
width: 100%;
> .card-title {
font-size: 24rpx;
border-top-left-radius: 20rpx;
border-top-right-radius: 20rpx;
padding: 16rpx;
}
> .time-line {
padding: 16rpx 32rpx;
}
}
.u-order-desc {
font-size: 26rpx;
color: #666;
margin: 10rpx 0;
}
.u-order-time {
font-size: 24rpx;
color: #999;
}
.empty {
padding: 40rpx 0;
.redirect-wrap {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
}
</style>

View File

@@ -1,238 +1,310 @@
<template>
<div>
<view class="logistics-detail">
<view class="card">
<div v-if="logisticsList && logisticsList.length>0">
<ul class="express-log" v-for="(packageItem, packageIndex) in logisticsList" :key="packageIndex">
<div class="layui-layer-wrap">
<dl>
<dt>物流公司</dt>
<dd><div class="text-box">{{ packageItem.logisticsName }}</div></dd>
</dl>
<dl>
<dt>快递单号</dt>
<dd>
<div nctype="ordersSn" class="text-box">
<a class="item" :href='"https://www.baidu.com/s?wd="+packageItem.logisticsNo' target="_blank">{{ packageItem.logisticsNo }}</a>
</div>
</dd>
</dl>
<div class="div-express-log">
<ul class="express-log express-log-name">
<li v-for="(item, index) in packageItem.orderPackageItemList" :key="index">
<p class="time" style="width: 50%;"><span>商品名称</span><span>{{ item.goodsName }}</span></p>
<p class="time" style="width: 30%;"><span>发货时间</span><span>{{ item.logisticsTime }}</span></p>
<p class="time" style="width: 20%;"><span>发货数量</span><span>{{ item.deliverNumber }}</span></p>
</li>
</ul>
</div>
<div class="div-express-log">
<ul class="express-log" v-if="packageItem.traces && packageItem.traces.traces">
<li v-for="(item, index) in packageItem.traces.traces" :key="index">
<span class="time">{{ item.AcceptTime || item.acceptTime }}</span>
<span class="detail">{{ item.AcceptStation || item.remark }}</span>
</li>
</ul>
<ul class="express-log" v-else>
<li>暂无物流信息</li>
</ul>
</div>
</div>
</ul>
</div>
</view>
<view class="logistics-detail">
<view v-if="loading" class="loading-wrap">
<u-loading mode="circle" />
</view>
</div>
<view v-else-if="loadError" class="error-wrap">
<u-empty text="加载失败,请重试" mode="list" />
<u-button size="medium" shape="circle" @click="retryLoad">重试</u-button>
</view>
<view v-else class="card">
<view v-if="orderStatus === 'PARTS_DELIVERED'" class="parts-tip">
其余商品待商家继续发货
</view>
<!-- 包裹列表 -->
<view
v-for="(packageItem, index) in packageList"
:key="packageItem.packageNo || index"
class="package-card"
>
<view class="package-title">{{ getPackageTitle(packageItem, index) }}</view>
<template v-if="isNoDelivery(packageItem)">
<view v-if="packageItem.logisticsTime" class="info-row">
<text class="label">完成时间</text>
<text class="value">{{ packageItem.logisticsTime }}</text>
</view>
<view v-if="packageItem.deliveryRemark" class="info-row">
<text class="label">商家说明</text>
<text class="value">{{ packageItem.deliveryRemark }}</text>
</view>
</template>
<template v-else>
<view class="info-row">
<text class="label">物流公司</text>
<text class="value">{{ packageItem.logisticsName || '-' }}</text>
</view>
<view class="info-row">
<text class="label">运单号</text>
<text class="value">{{ packageItem.logisticsNo || '-' }}</text>
</view>
<view v-if="packageItem.logisticsTime" class="info-row">
<text class="label">发货时间</text>
<text class="value">{{ packageItem.logisticsTime }}</text>
</view>
</template>
<view
v-if="packageItem.orderPackageItemList && packageItem.orderPackageItemList.length"
class="div-express-log goods-block"
>
<view
class="goods-row"
v-for="(item, gIndex) in packageItem.orderPackageItemList"
:key="item.orderItemSn || gIndex"
>
<text class="goods-name">{{ item.goodsName }}</text>
<text class="goods-meta">x{{ item.deliverNumber }}</text>
</view>
</view>
<view v-if="!isNoDelivery(packageItem)" class="div-express-log">
<ul class="express-log" v-if="packageItem.traces && packageItem.traces.traces && packageItem.traces.traces.length">
<li v-for="(item, tIndex) in packageItem.traces.traces" :key="tIndex">
<span class="time">{{ item.AcceptTime || item.acceptTime }}</span>
<span class="detail">{{ item.AcceptStation || item.remark }}</span>
</li>
</ul>
<ul class="express-log" v-else>
<li>物流信息同步中</li>
</ul>
</view>
</view>
<!-- 历史订单降级主表物流 -->
<view v-if="!packageList.length && legacyTraces" class="package-card">
<view class="package-title">整单发货</view>
<view class="info-row">
<text class="label">物流公司</text>
<text class="value">{{ legacyTraces.shipper || '-' }}</text>
</view>
<view class="info-row">
<text class="label">运单号</text>
<text class="value">{{ legacyTraces.logisticCode || '-' }}</text>
</view>
<view class="div-express-log">
<ul class="express-log" v-if="legacyTraces.traces && legacyTraces.traces.length">
<li v-for="(item, index) in legacyTraces.traces" :key="index">
<span class="time">{{ item.AcceptTime || item.acceptTime }}</span>
<span class="detail">{{ item.AcceptStation || item.remark }}</span>
</li>
</ul>
<ul class="express-log" v-else>
<li>物流信息同步中</li>
</ul>
</view>
</view>
<u-empty
v-if="!packageList.length && !legacyTraces"
class="empty"
text="物流信息同步中"
mode="list"
/>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { getPackage } from '@/api/trade.js'
import { getPackage, getExpress } from '@/api/trade.js'
import { getOrderDetail } from '@/api/order.js'
import { isNoDelivery, getPackageTitle } from '@/utils/orderDelivery.js'
const logisticsList = ref<any[]>([])
const packageList = ref<any[]>([])
const legacyTraces = ref<any>(null)
const orderSn = ref('')
const orderStatus = ref('')
const loading = ref(true)
const loadError = ref(false)
onLoad((option) => {
const sn = option.order_sn
if (sn) fetchLogistics(sn)
orderSn.value = option?.order_sn || ''
if (orderSn.value) {
loadData(orderSn.value)
} else {
loading.value = false
loadError.value = true
}
})
function fetchLogistics(sn: string) {
getPackage(sn).then((res) => {
if (res.data.success) {
logisticsList.value = res.data.result
}
})
function retryLoad() {
if (orderSn.value) loadData(orderSn.value)
}
async function loadData(sn: string) {
loading.value = true
loadError.value = false
packageList.value = []
legacyTraces.value = null
try {
const orderRes = await getOrderDetail(sn)
if (orderRes.data?.success) {
orderStatus.value = orderRes.data.result?.order?.orderStatus || ''
}
const packageRes = await getPackage(sn)
if (!packageRes.data?.success) {
loadError.value = true
return
}
const packages = packageRes.data.result || []
if (packages.length) {
packageList.value = packages
return
}
const traceRes = await getExpress(sn)
if (traceRes.data?.success && traceRes.data.result) {
legacyTraces.value = traceRes.data.result
}
} catch {
loadError.value = true
} finally {
loading.value = false
}
}
</script>
<style >
<style>
page {
background: #fff;
background: #f1f1f1;
}
</style>
<style lang="scss" scoped>
// @import url('./goods.scss');
.goods-item-view {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 10rpx 30rpx;
.goods-img {
flex: 1;
}
.goods-info {
padding-left: 30rpx;
flex: 3;
.goods-title {
margin-bottom: 10rpx;
color: $font-color-dark;
}
.goods-specs {
font-size: 24rpx;
margin-bottom: 10rpx;
color: #cccccc;
}
.goods-price {
font-size: 28rpx;
margin-bottom: 10rpx;
color: #ff5a10;
}
}
.goods-num {
>.good-complaint {
margin-top: 10rpx;
}
text-align: center;
flex: 1;
width: 60rpx;
color: $main-color;
}
}
.goods-info {
flex: 2;
}
.card-title {
background: #f2f2f2;
}
.logistics-detail {
margin-top: 20rpx;
padding: 0 16rpx;
margin-top: 20rpx;
padding: 0 16rpx 40rpx;
}
.loading-wrap,
.error-wrap {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80rpx 0;
gap: 24rpx;
}
.card {
background: #fff;
border-radius: 20rpx;
width: 100%;
> .card-title {
font-size: 24rpx;
border-top-left-radius: 20rpx;
border-top-right-radius: 20rpx;
padding: 16rpx;
}
> .time-line {
padding: 16rpx 32rpx;
}
}
.u-order-title {
font-weight: bold;
}
.u-order-desc {
font-size: 26rpx;
color: #666;
margin: 10rpx 0;
}
.u-order-time {
font-size: 24rpx;
color: #999;
}
.empty {
padding: 40rpx 0;
background: #fff;
border-radius: 20rpx;
width: 100%;
padding: 16rpx 0;
}
.parts-tip {
margin: 0 20rpx 16rpx;
padding: 16rpx 20rpx;
background: #fff7e6;
color: #fa8c16;
font-size: 24rpx;
border-radius: 8rpx;
}
.package-card {
padding: 20rpx;
border-bottom: 1rpx solid #f5f5f5;
&:last-child {
border-bottom: none;
}
}
.package-title {
font-size: 28rpx;
font-weight: bold;
color: #333;
margin-bottom: 16rpx;
}
.info-row {
display: flex;
font-size: 26rpx;
line-height: 44rpx;
padding: 4rpx 0;
.label {
color: #999;
flex-shrink: 0;
}
.value {
color: #333;
word-break: break-all;
}
}
.goods-block {
padding: 16rpx 20rpx;
}
.goods-row {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 24rpx;
line-height: 44rpx;
color: #666;
.goods-name {
flex: 1;
margin-right: 16rpx;
}
.goods-meta {
flex-shrink: 0;
color: #999;
}
}
.express-log {
/*margin: 5px -10px 5px 5px;*/
padding: 20rpx;
list-style-type: none;
li {
display: flex;
margin-top: 18rpx;
}
li:nth-of-type(1) {
margin-top: 0;
}
.time {
width: 140rpx;
display: flex;
// float: left;
flex-direction: column;
font-size: 24rpx;
line-height: 40rpx;
span:nth-of-type(1) {
margin-bottom: 16rpx;
padding: 20rpx;
list-style-type: none;
li {
display: flex;
margin-top: 18rpx;
&:first-child {
margin-top: 0;
}
}
}
.detail {
width: 100%;
flex: 1;
margin-left: 30rpx;
display: inline-block;
font-size: 24rpx;
line-height: 40rpx;
}
.time {
width: 180rpx;
flex-shrink: 0;
font-size: 24rpx;
line-height: 40rpx;
color: #999;
}
li {
line-height: 60rpx;
}
.detail {
flex: 1;
margin-left: 20rpx;
font-size: 24rpx;
line-height: 40rpx;
color: #333;
}
}
.layui-layer-wrap {
dl {
border-top: solid 1px #f5f5f5;
margin-top: -2rpx;
overflow: hidden;
dt {
font-size: 26rpx;
line-height: 40rpx;
display: inline-block;
padding: 16rpx 1% 16rpx 0;
color: #999;
}
dd {
font-size: 26rpx;
line-height: 40rpx;
display: inline-block;
padding: 16rpx 0 16rpx 16rpx;
border-left: solid 2rpx #f5f5f5;
.text-box {
line-height: 40rpx;
color: #333;
word-break: break-all;
}
}
}
}
.div-express-log {
// max-height: 600rpx;
border: solid 2rpx #e7e7e7;
background: #fafafa;
border-radius: 10rpx;
margin-bottom: 6rpx;
// overflow-y: auto;
// overflow-x: auto;
border: solid 2rpx #e7e7e7;
background: #fafafa;
border-radius: 10rpx;
margin-top: 16rpx;
}
</style>
.empty {
padding: 40rpx 0;
}
</style>

View File

@@ -294,18 +294,9 @@ function waitPay(val: any) {
function pay(val: any) {
if (val.sn) {
// #ifdef MP-WEIXIN
new LiLiWXPay({
sn: val.sn,
price: val.flowPrice,
orderType: 'ORDER',
}).pay()
// #endif
// #ifndef MP-WEIXIN
uni.navigateTo({
url: '/pages/cart/payment/payOrder?order_sn=' + val.sn,
})
// #endif
}
}

View File

@@ -8,8 +8,8 @@
</div>
</div>
<!-- 物流信息 / 卡密入口 -->
<view class="info-view logistics-view">
<!-- 物流/配送信息 / 卡密入口 -->
<view class="info-view logistics-view" v-if="showDeliveryInfoBlock">
<view class="logistics-List">
<view v-if="isECouponOrder" class="card-key-entry">
<view v-if="ecouponCardKeyDelivered" class="card-key-entry__btn" @click="goCardKeyDetail">
@@ -25,16 +25,16 @@
<view class="verificationCode" v-else-if="order.verificationCode">
券码 {{ order.orderStatus == 'CANCELLED' ? '已失效' : order.verificationCode }}
</view>
<view @click="handleClickDeliver()" class="info-view logi-view" v-else-if="orderPackage && orderPackage.length">
<view class="verificationCode">
当前订单有 {{ orderPackage.length }} 个包裹快递
</view>
<div>
点击此处查看
</div>
</view>
<view v-else-if="!isECouponOrder" class="logistics-List-title">
{{ '暂无物流信息' }}
<view
v-else-if="allowOperation.showLogistics"
@click="handleClickDeliver()"
class="info-view logi-view"
>
<view class="verificationCode">{{ deliveryEntryText }}</view>
<div>点击此处查看</div>
</view>
<view v-else-if="order.orderStatus === 'UNDELIVERED'" class="logistics-List-title">
等待商家发货
</view>
</view>
</view>
@@ -144,7 +144,7 @@
<view class="customer-service"
v-if="orderDetail.allowOperationVO && orderDetail.allowOperationVO.cancel == true"
@click="onCancel(order.sn)">取消订单</view>
<view class="customer-service" v-if="order.orderStatus == 'DELIVERED' && !isECouponOrder" @click="onLogistics(order)">查看物流</view>
<view class="customer-service" v-if="allowOperation.showLogistics" @click="handleClickDeliver()">查看物流</view>
<view class="customer-service" v-if="order.orderStatus != 'UNPAID' && order.orderPromotionType == 'PINTUAN'"
@click="ByUserMessage(order)">查看拼团信息</view>
<view class="customer-service"
@@ -219,7 +219,7 @@
>立即付款</view>
<view
class="pay-btn"
v-if="order.orderStatus == 'DELIVERED' && !isECouponOrder"
v-if="allowOperation.rog"
@click="onRog(order.sn)"
>确认收货</view>
<view
@@ -275,6 +275,11 @@ import {
talkIm,
callPhone,
} from '@/utils/filters.js'
import {
shouldLoadDelivery,
getDeliveryEntryText,
getOrderAllowOperation,
} from '@/utils/orderDelivery.js'
import {
isECouponOrder as checkECouponOrder,
isNonPhysicalOrder as checkNonPhysicalOrder,
@@ -300,7 +305,6 @@ const orderStatusMap: Record<string, { title: string; value?: string }> = {
TAKE: { title: '待核验' },
}
const logisticsList = ref<any>('')
const shareFlag = ref(false)
const order = ref<Record<string, any>>({})
const cancelShow = ref(false)
@@ -311,8 +315,9 @@ const sn = ref('')
const cancelList = ref<any[]>([])
const rogShow = ref(false)
const reason = ref('')
const orderPackage = ref<any>('')
// orderType=E_COUPON无物流/核销码;卡密见 cardKeyDetail仅本单 orderItems不含满赠子单
const orderPackage = ref<any[]>([])
const legacyTraces = ref<any>(null)
const isECouponOrder = computed(() => checkECouponOrder(order.value?.orderType))
const isNonPhysicalOrder = computed(() => checkNonPhysicalOrder(order.value?.orderType))
const ecouponCardKeyDelivered = computed(() =>
@@ -325,6 +330,27 @@ const ecouponPendingMessage = computed(() => {
return pending ? resolveCardKeyFulfillMessage(pending) : ''
})
const allowOperation = computed(() =>
getOrderAllowOperation(order.value, orderDetail.value)
)
const showDeliveryInfoBlock = computed(() => {
if (isECouponOrder.value) return true
if (order.value.orderType === 'VIRTUAL') return false
if (order.value.deliveryMethod === 'SELF_PICK_UP') return false
if (order.value.verificationCode) return true
if (order.value.deliveryMethod === 'LOGISTICS') return true
return false
})
const deliveryEntryText = computed(() =>
getDeliveryEntryText(
orderPackage.value,
order.value.orderStatus,
legacyTraces.value
)
)
function hideLoadingIfNeeded() {
if (store.state.isShowToast) uni.hideLoading()
}
@@ -335,12 +361,27 @@ onLoad((options) => {
loadData(orderSnParam)
})
function getOrderPackage() {
getPackage(order.value.sn).then((res) => {
if (res.data.success) {
orderPackage.value = res.data.result
async function loadDelivery(orderData: Record<string, any>) {
orderPackage.value = []
legacyTraces.value = null
if (!shouldLoadDelivery(orderData)) {
return
}
try {
const packageResponse = await getPackage(orderData.sn)
const packages = packageResponse?.data?.result || []
if (packages.length) {
orderPackage.value = packages
return
}
})
const traceResponse = await getExpress(orderData.sn)
legacyTraces.value = traceResponse?.data?.result || null
} catch {
// 配送信息加载失败时不阻断订单详情展示
}
}
function handleClickDeliver() {
@@ -349,6 +390,13 @@ function handleClickDeliver() {
})
}
function goCardKeyDetail() {
if (!sn.value) return
uni.navigateTo({
url: `/pages/order/cardKey/cardKeyDetail?sn=${sn.value}`,
})
}
function refundPriceList(status: string) {
switch (status) {
case 'ALL_REFUND':
@@ -380,12 +428,6 @@ function goToShopPage(val: any) {
})
}
function loadLogistics(orderSnParam: string) {
getExpress(orderSnParam).then((res) => {
logisticsList.value = res.data.result
})
}
function inviteGroup() {
shareFlag.value = true
}
@@ -404,32 +446,19 @@ function ByUserMessage(orderItem: any) {
function loadData(orderSnParam: string) {
uni.showLoading({ title: '加载中' })
getOrderDetail(orderSnParam).then((res) => {
getOrderDetail(orderSnParam).then(async (res) => {
const result = res.data.result
order.value = result.order
orderGoodsList.value = result.orderItems
orderDetail.value = result
if (
result.order.deliveryMethod === 'LOGISTICS' &&
!checkECouponOrder(result.order.orderType) &&
result.order.orderType !== 'VIRTUAL'
) {
// E_COUPON 无物流信息
loadLogistics(orderSnParam)
getOrderPackage()
if (!order.value.allowOperationVO && result.allowOperationVO) {
order.value.allowOperationVO = result.allowOperationVO
}
await loadDelivery(order.value)
hideLoadingIfNeeded()
})
}
function goCardKeyDetail() {
// 仅传 orderSn卡密在二级页重新请求订单详情不经路由/Vuex 传递明文
if (!sn.value) return
uni.navigateTo({
url: `/pages/order/cardKey/cardKeyDetail?sn=${sn.value}`,
})
}
function onReceipt(val: any) {
uni.navigateTo({
url: '/pages/order/invoice/invoiceDetail?id=' + val.id,
@@ -528,18 +557,6 @@ function onComment(_orderSnText: string) {
})
}
function onLogistics(orderItem: any) {
uni.navigateTo({
url:
'/pages/mine/msgTips/packageMsg/logisticsDetail?logi_id=' +
orderItem.logi_id +
'&ship_no=' +
orderItem.ship_no +
'&order_sn=' +
orderItem.sn,
})
}
function reasonChange(val: string) {
reason.value = val
}

View File

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

View File

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

View File

@@ -318,7 +318,9 @@
/************接口API***************/
import { ref, reactive, computed, watch, nextTick, getCurrentInstance } from 'vue'
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 { tryBindDistribution } from '@/utils/distributionBind.js'
import * as API_trade from '@/api/trade.js'
import * as API_Members from '@/api/members.js'
import * as API_store from '@/api/store.js'
@@ -469,10 +471,15 @@ onShow(async () => {
const res = await getMpScene(routerVal.value.scene)
if (res.data.success) {
let data = res.data.result.split(",")
init(data[0], data[1], data[2])
init(data[0], data[1], data[2], data[3] || '')
}
} else {
init(routerVal.value.id, routerVal.value.goodsId, routerVal.value.distributionId)
init(
routerVal.value.id,
routerVal.value.goodsId,
routerVal.value.distributionId,
routerVal.value.shareId || ''
)
}
})
@@ -502,7 +509,7 @@ function selectSku(idObj: any) {
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
productId.value = id
@@ -519,11 +526,21 @@ async function init(id: any, goodsId: any, distributionId = "") {
return
}
if ((distributionId || store.state.distributionId) && isLogin("auth")) {
let disResult = await getGoodsDistribution(distributionId)
if (!disResult?.data?.success || disResult.statusCode == 403) {
store.state.distributionId = distributionId
}
const distId = distributionId || store.state.distributionId
if (distId) {
await tryBindDistribution(distId)
}
if (distId && goodsId && id && storage.getAccessToken()) {
recordDistributionGoodsVisit({
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

View File

@@ -1,7 +1,12 @@
<template>
<div class="layout">
<div class="layout" v-if="list.length">
<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>
</template>
@@ -11,9 +16,15 @@ import { computed } from "vue";
const props = defineProps<{ res: any }>();
const list = computed(() =>
props.res.list[0].title.map((i: { context: string }) => i.context)
);
const noticeStyle = computed(() => props.res?.list?.[0] || {});
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>
<style lang="scss" scoped>
@import "./tpl.scss";

View File

@@ -1,282 +1,287 @@
<template>
<view>
<!-- 常用工具 -->
<view class="interact-tools" style="margin-bottom: 15px">
<div class="paddingBox">
<view class="interact-container">
<view class="interact-item" @click="navigateTo('/pages/mine/address/addressManage')">
<image class="interact-item-icon" src="/static/mine/myaddress.png" mode=""></image>
<view>地址管理</view>
</view>
<view class="interact-item" @click="navigateTo('/pages/mine/myTracks')">
<image class="interact-item-icon" src="/static/mine/logistics.png" mode=""></image>
<view>我的足迹</view>
</view>
<view class="interact-item" @click="navigateTo('/pages/order/evaluate/myEvaluate')">
<image class="interact-item-icon" src="/static/mine/feedback.png" mode=""></image>
<view>我的评价</view>
</view>
<view class="interact-item" @click="linkMsgDetail()">
<image class="interact-item-icon" src="/static/mine/mycommit.png" mode=""></image>
<view>我的消息</view>
</view>
<view class="interact-item" @click="navigateTo('/pages/mine/myCollect')">
<image class="interact-item-icon" src="/static/mine/myfavorite.png" mode=""></image>
<view>我的关注</view>
</view>
<view class="interact-item" @click="navigateTo('/pages/mine/point/myPoint')">
<image class="interact-item-icon" src="/static/mine/mypoint.png" mode=""></image>
<view>我的积分</view>
</view>
<view class="interact-item" @click="distribution">
<image class="interact-item-icon" src="/static/mine/distribution.png" mode=""></image>
<view>我的分销</view>
</view>
<view class="interact-item" @click="navigateTo('/pages/order/complain/complainList')">
<image class="interact-item-icon" src="/static/mine/shensu.png" mode=""></image>
<view>我的投诉</view>
</view>
<view class="interact-item" @click="navigateTo('/pages/cart/coupon/myCoupon')">
<image class="interact-item-icon" src="/static/mine/mycoupon.png" mode=""></image>
<view>优惠券</view>
</view>
<view class="interact-item" @click="navigateTo('/pages/mine/signIn')">
<image class="interact-item-icon" src="/static/mine/sign.png" mode=""></image>
<view>每日签到</view>
</view>
<view class="interact-item" @click="navigateTo('/pages/cart/coupon/couponCenter')">
<image class="interact-item-icon" src="/static/mine/couponcenter.png" mode=""></image>
<view>领券中心</view>
</view>
<view class="interact-item" @click="navigateTo('/pages/promotion/bargain/log')">
<image class="interact-item-icon" src="/static/mine/kanjia.png" mode=""></image>
<view>砍价记录</view>
</view>
<view class="interact-item" @click="navigateTo('/pages/mine/set/feedBack')">
<image class="interact-item-icon" src="/static/mine/feedback.png" mode=""></image>
<view>意见反馈</view>
</view>
<view class="interact-item" @click="navigateTo('/pages/mine/set/editionIntro')">
<image class="interact-item-icon" src="/static/mine/pointgift.png" mode=""></image>
<view>关于</view>
</view>
<view class="interact-item" @click="navigateTo('/pages/passport/entry/seller/index')">
<image class="interact-item-icon" src="/static/mine/feedback.png" mode=""></image>
<view>店铺入驻</view>
</view>
<!-- <view class="interact-item" @click="inviter()">-->
<!-- <image class="interact-item-icon" src="/static/mine/share.png" mode=""></image>-->
<!-- <view>邀新</view>-->
<!-- </view>-->
<view class="interact-item" @click="navigateTo('/pages/mine/set/setUp')">
<image class="interact-item-icon" src="/static/mine/setting.png" mode=""></image>
<view>设置</view>
</view>
</view>
</div>
<template>
<u-popup v-model:show="sharingShow" mode="bottom" border-radius="14">
<view style="margin: 10px; text-align: center;"> 请邀请用户扫描二维码或者将地址复制转发给其他用户 </view>
<view class='qrcode'>
<uqrcode v-if="sharingLink" ref="uqrcode" canvas-id="qrcode" :value="sharingLink" :options="{ margin: 10 }">
</uqrcode>
</view>
<view class="copy-text" @click="getDetail(sharingLink)">
{{sharingLink}}
</view>
<view class="confrim-btn">
<u-button @click="sharingShow = false;">关闭</u-button>
</view>
</u-popup>
</template>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { distribution as fetchDistribution } from '@/api/goods'
import configs from '@/config/config'
import storage from '@/utils/storage'
import { tipsToLogin, setClipboard } from '@/utils/filters.js'
const sharingShow = ref(false)
const sharingLink = ref('')
function handleNavigate(url: string) {
uni.navigateTo({ url })
}
function inviter() {
if (storage.getUserInfo().id) {
sharingLink.value = configs.shareLink + '?inviter=' + storage.getUserInfo().id
sharingShow.value = true
} else {
uni.showToast({
title: '请先登录',
duration: 2000,
icon: 'none',
})
}
}
function getDetail(link: string) {
setClipboard(link)
}
function navigateTo(url: string) {
const ignores = [
'/pages/mine/set/setUp',
'/pages/mine/set/editionIntro',
'/pages/mine/set/feedBack',
]
if (!ignores.includes(url)) {
if (tipsToLogin('normal')) {
handleNavigate(url)
}
} else {
handleNavigate(url)
}
}
function linkMsgDetail() {
if (tipsToLogin('normal')) {
uni.navigateTo({
url: '/pages/tabbar/home/title',
})
}
}
function distribution() {
fetchDistribution().then((res) => {
if (res.data.result) {
const type = res.data.result.distributionStatus
if (type == 'PASS') {
uni.navigateTo({
url: '/pages/mine/distribution/home',
})
} else if (type == 'REFUSE') {
uni.navigateTo({
url: '/pages/mine/distribution/auth',
})
} else if (type == 'RETREAT') {
uni.showToast({
title: '您的分销资格已被清退。请联系管理员!',
duration: 2000,
icon: 'none',
})
} else {
uni.showToast({
title: '您的信息正在审核',
duration: 2000,
icon: 'none',
})
}
} else if (!res.data.success && res.data.code == 22000) {
uni.showToast({
title: '分销功能暂未开启',
duration: 2000,
icon: 'none',
})
} else {
uni.navigateTo({
url: '/pages/mine/distribution/auth',
})
}
})
}
</script>
<style lang="scss" scoped>
.copy-text {
display: flex;
align-items: center;
justify-content: center;
margin: 10px;
line-break: anywhere;
}
.interact-tools {
border-left: none;
border-right: none;
.interactBox {
height: 156rpx;
}
.interact-container {
margin: 0 20rpx;
background: #fff;
border-radius: 20rpx;
box-shadow: 0 4rpx 24rpx 0 rgba($color: #f6f6f6, $alpha: 1);
.interact-item-icon {
width: 56rpx;
height: 56rpx;
margin-bottom: 10rpx;
}
display: flex;
align-items: center;
flex-wrap: wrap;
text-align: center;
.interact-item {
font-size: 24rpx;
width: 25%;
flex: 0 0 25%;
box-sizing: border-box;
height: 150rpx;
padding: 20rpx 8rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #666;
}
}
}
.qrcode {
margin: 0 auto;
width: 200px;
}
</style>
<template>
<view>
<!-- 常用工具 -->
<view class="interact-tools" style="margin-bottom: 15px">
<div class="paddingBox">
<view class="interact-container">
<view class="interact-item" @click="navigateTo('/pages/mine/address/addressManage')">
<image class="interact-item-icon" src="/static/mine/myaddress.png" mode=""></image>
<view>地址管理</view>
</view>
<view class="interact-item" @click="navigateTo('/pages/mine/myTracks')">
<image class="interact-item-icon" src="/static/mine/logistics.png" mode=""></image>
<view>我的足迹</view>
</view>
<view class="interact-item" @click="navigateTo('/pages/order/evaluate/myEvaluate')">
<image class="interact-item-icon" src="/static/mine/feedback.png" mode=""></image>
<view>我的评价</view>
</view>
<view class="interact-item" @click="linkMsgDetail()">
<image class="interact-item-icon" src="/static/mine/mycommit.png" mode=""></image>
<view>我的消息</view>
</view>
<view class="interact-item" @click="navigateTo('/pages/mine/myCollect')">
<image class="interact-item-icon" src="/static/mine/myfavorite.png" mode=""></image>
<view>我的关注</view>
</view>
<view class="interact-item" @click="navigateTo('/pages/mine/point/myPoint')">
<image class="interact-item-icon" src="/static/mine/mypoint.png" mode=""></image>
<view>我的积分</view>
</view>
<view class="interact-item" @click="distribution">
<image class="interact-item-icon" src="/static/mine/distribution.png" mode=""></image>
<view>我的分销</view>
</view>
<view class="interact-item" @click="navigateTo('/pages/order/complain/complainList')">
<image class="interact-item-icon" src="/static/mine/shensu.png" mode=""></image>
<view>我的投诉</view>
</view>
<view class="interact-item" @click="navigateTo('/pages/cart/coupon/myCoupon')">
<image class="interact-item-icon" src="/static/mine/mycoupon.png" mode=""></image>
<view>优惠券</view>
</view>
<view class="interact-item" @click="navigateTo('/pages/mine/signIn')">
<image class="interact-item-icon" src="/static/mine/sign.png" mode=""></image>
<view>每日签到</view>
</view>
<view class="interact-item" @click="navigateTo('/pages/cart/coupon/couponCenter')">
<image class="interact-item-icon" src="/static/mine/couponcenter.png" mode=""></image>
<view>领券中心</view>
</view>
<view class="interact-item" @click="navigateTo('/pages/promotion/bargain/log')">
<image class="interact-item-icon" src="/static/mine/kanjia.png" mode=""></image>
<view>砍价记录</view>
</view>
<view class="interact-item" @click="navigateTo('/pages/mine/set/feedBack')">
<image class="interact-item-icon" src="/static/mine/feedback.png" mode=""></image>
<view>意见反馈</view>
</view>
<view class="interact-item" @click="navigateTo('/pages/mine/set/editionIntro')">
<image class="interact-item-icon" src="/static/mine/pointgift.png" mode=""></image>
<view>关于</view>
</view>
<view class="interact-item" @click="navigateTo('/pages/passport/entry/seller/index')">
<image class="interact-item-icon" src="/static/mine/feedback.png" mode=""></image>
<view>店铺入驻</view>
</view>
<!-- <view class="interact-item" @click="inviter()">-->
<!-- <image class="interact-item-icon" src="/static/mine/share.png" mode=""></image>-->
<!-- <view>邀新</view>-->
<!-- </view>-->
<view class="interact-item" @click="navigateTo('/pages/mine/set/setUp')">
<image class="interact-item-icon" src="/static/mine/setting.png" mode=""></image>
<view>设置</view>
</view>
</view>
</div>
<template>
<u-popup v-model:show="sharingShow" mode="bottom" border-radius="14">
<view style="margin: 10px; text-align: center;"> 请邀请用户扫描二维码或者将地址复制转发给其他用户 </view>
<view class='qrcode'>
<uqrcode v-if="sharingLink" ref="uqrcode" canvas-id="qrcode" :value="sharingLink" :options="{ margin: 10 }">
</uqrcode>
</view>
<view class="copy-text" @click="getDetail(sharingLink)">
{{sharingLink}}
</view>
<view class="confrim-btn">
<u-button @click="sharingShow = false;">关闭</u-button>
</view>
</u-popup>
</template>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { distribution as fetchDistribution } from '@/api/distribution'
import configs from '@/config/config'
import storage from '@/utils/storage'
import { tipsToLogin, setClipboard } from '@/utils/filters.js'
const sharingShow = ref(false)
const sharingLink = ref('')
function handleNavigate(url: string) {
uni.navigateTo({ url })
}
function inviter() {
if (storage.getUserInfo().id) {
sharingLink.value = configs.shareLink + '?inviter=' + storage.getUserInfo().id
sharingShow.value = true
} else {
uni.showToast({
title: '请先登录',
duration: 2000,
icon: 'none',
})
}
}
function getDetail(link: string) {
setClipboard(link)
}
function navigateTo(url: string) {
const ignores = [
'/pages/mine/set/setUp',
'/pages/mine/set/editionIntro',
'/pages/mine/set/feedBack',
]
if (!ignores.includes(url)) {
if (tipsToLogin('normal')) {
handleNavigate(url)
}
} else {
handleNavigate(url)
}
}
function linkMsgDetail() {
if (tipsToLogin('normal')) {
uni.navigateTo({
url: '/pages/tabbar/home/title',
})
}
}
function goRecruitJoin() {
uni.navigateTo({
url: '/pages/mine/distribution/join',
})
}
function distribution() {
if (!tipsToLogin('normal')) {
return
}
fetchDistribution().then((res) => {
if (res.data.result) {
const type = res.data.result.distributionStatus
if (type == 'PASS') {
uni.navigateTo({
url: '/pages/mine/distribution/home',
})
} else if (type == 'REFUSE') {
goRecruitJoin()
} else if (type == 'RETREAT') {
uni.showToast({
title: '您的分销资格已被清退。请联系管理员!',
duration: 2000,
icon: 'none',
})
} else {
uni.showToast({
title: '您的信息正在审核',
duration: 2000,
icon: 'none',
})
}
} else if (!res.data.success && res.data.code == 22000) {
uni.showToast({
title: '分销功能暂未开启',
duration: 2000,
icon: 'none',
})
} else {
goRecruitJoin()
}
})
}
</script>
<style lang="scss" scoped>
.copy-text {
display: flex;
align-items: center;
justify-content: center;
margin: 10px;
line-break: anywhere;
}
.interact-tools {
border-left: none;
border-right: none;
.interactBox {
height: 156rpx;
}
.interact-container {
margin: 0 20rpx;
background: #fff;
border-radius: 20rpx;
box-shadow: 0 4rpx 24rpx 0 rgba($color: #f6f6f6, $alpha: 1);
.interact-item-icon {
width: 56rpx;
height: 56rpx;
margin-bottom: 10rpx;
}
display: flex;
align-items: center;
flex-wrap: wrap;
text-align: center;
.interact-item {
font-size: 24rpx;
width: 25%;
flex: 0 0 25%;
box-sizing: border-box;
height: 150rpx;
padding: 20rpx 8rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #666;
}
}
}
.qrcode {
margin: 0 auto;
width: 200px;
}
</style>