Files
weicw-dshsa 2992f4ee49 fix(药分享): 修正 page-meta 位置并兜底接口返回,修复首屏报错
问题一:<page-meta> 渲染层报错“只能是页面内的第一个节点”
page-meta 原先嵌在根 view 内,位置在根节点的孙级。而编译器
uni-template-compiler/lib/template/generate.js 的 parsePageMeta 只检查
根节点的直接 children,匹配不到就不做“提升为第一个节点”的处理;
加上 webpack-uni-app-loader/page-meta.js 只要模板含 <page-meta 就会
无条件外包一层 <view>,最终渲染成
  <view class="data-v-xxx"><view class="share-page">...<page-meta>
导致微信报错。改为让 page-meta 与业务根 view 平级、放在最前,
经真实编译器验证产物第一个标签已是 <page-meta>。

问题二:onShow 抛 Cannot read properties of undefined (reading 'list')
直接诱因是后端 /yxe/yxe-share-detail/page?groupByProduct=true 报错
(见 yxe-java 侧提交),返回体 code=1 且不带 data,getProductList 里
data.list 抛异常并中断整个 onShow,页面数据全部不渲染。
本次在小程序侧做防御性兜底,避免任一接口失败就整页白屏:
- getProductList / getTodayShare / getShareUserData / getUserData /
  getRule / getShareData 均按要求校验再赋值
- getShareData 改用 slice() 排序,避免就地改动接口返回的数组;
  amount 缺失按 0 处理,避免 NaN 让排序结果不稳定
2026-10-12 14:49:18 +08:00

1675 lines
46 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<page-meta :page-style="pageStyle"/>
<view class="share-page">
<view class="share-hero">
<AppNavbar title="药分享">
</AppNavbar>
<view class="stats-card">
<view class="flex justify-between items-center w-100p">
<!-- <view class="stat-item" @click="jumpWithAuth('/pages/share/mine')">-->
<!-- <view class="stat-value">-->
<!-- <text class="stat-number">{{-->
<!-- data.income-->
<!-- }}-->
<!-- </text>-->
<!-- <text class="stat-unit">元</text>-->
<!-- </view>-->
<!-- <text class="stat-label">我的晒单收益</text>-->
<!-- </view>-->
<view class="stat-item" @click="jumpWithAuth('/pages/share/mine')">
<view class="stat-value">
<text class="stat-number">{{ data.num }}</text>
<text class="stat-unit">个</text>
</view>
<text class="stat-label">我的晒单数量</text>
</view>
<!-- <view class="stat-action" @click="jumpWithAuth('/pages/bonus/withdraw')">去提现</view>-->
</view>
<view class="flex justify-center items-center w-100p pt-20 border-top" v-if="isLoggedIn && user.canAuth === 1">
<!-- <view class="stat-item flex-1 justify-center items-center">-->
<!-- <view class="stat-value">-->
<!-- <text class="stat-number text-main">{{-->
<!-- data.totalIncome-->
<!-- }}-->
<!-- </text>-->
<!-- <text class="stat-unit">元</text>-->
<!-- </view>-->
<!-- <text class="stat-label">合计晒单收益</text>-->
<!-- </view>-->
<view class="stat-item flex-1 justify-center items-center">
<view class="stat-value">
<text class="stat-number text-main">{{ data.totalNum }}</text>
<text class="stat-unit">个</text>
</view>
<text class="stat-label">合计晒单数量</text>
</view>
</view>
</view>
</view>
<!-- 今日晒单 -->
<view class="today-head">
<view class="city" @tap="openAreaPicker">
<u-icon name="map" size="26" color="#27c7a4"/>
<text>{{ currentCity }}</text>
</view>
<view class="head-center">
<image class="head-icon"
src="https://yaodongxiao.oss-cn-guangzhou.aliyuncs.com/20260126/a215c967f5d89626826da5bc3bec9130.png"
mode="heightFix"/>
</view>
<view v-if="canShowTodayMore" class="today-more" @tap="showAllTodayEntries">更多</view>
</view>
<view class="filter-chips">
<view class="filter-chip" @tap="openMerchantPicker">{{ merchantLabel }}</view>
</view>
<view class="today-list">
<template v-if="displayedTodayEntries.length">
<view class="today-card" v-for="(item, index) in displayedTodayEntries" :key="index" @click="showDetail(item)">
<view class="today-row">
<text class="today-label">匹配门店:</text>
<text class="today-value">{{ item.merchantName }}</text>
</view>
<view class="today-row">
<text class="today-label">订单号:</text>
<text class="today-value">{{ item.orderNo }}</text>
</view>
<view class="today-row today-row-space">
<text class="today-time">分享时间:{{ dayjs(item.createTime).format('YYYY-MM-DD HH:mm:ss') }}</text>
<view class="today-distance">
<u-icon name="map" size="24" color="#ff9f43"/>
<text>距离{{ item.distance || '100米' }}</text>
</view>
</view>
</view>
</template>
<u-empty v-else
icon="https://yaodongxiao.oss-cn-guangzhou.aliyuncs.com/20260126/ddcc1bbd561d97128ed19407b07e299b.png"
text="暂无数据"/>
</view>
<!-- 分享产品清单 -->
<view class="product-section">
<view class="section-head">
<view class="section-left" style="width: calc(100% - 100rpx)">
<image class="head-icon"
src="https://yaodongxiao.oss-cn-guangzhou.aliyuncs.com/20260126/17b2063b8acca65f86370593849e607d.png"
mode="heightFix"/>
</view>
<view
v-if="canShowProductMore"
class="section-more product-more"
@tap="showAllProductEntries"
>更多>>
</view>
</view>
<view class="product-search">
<view class="search-box">
<u-icon name="search" size="28" color="#9ca3af"/>
<input class="search-input" placeholder="请输入关键字搜索" v-model="productKeyword"/>
</view>
<view class="search-btn" @click="searchProduct">搜索</view>
</view>
<view class="product-grid">
<template v-if="displayedShareProducts.length">
<view class="product-card" v-for="(item, index) in displayedShareProducts" :key="index">
<view class="flex justify-center items-center">
<u-image
:src="item.product && item.product.cover ? item.product.cover : 'https://yaodongxiao.oss-cn-guangzhou.aliyuncs.com/20260126/35fa0fb100376d9925f872fdb752b5f1.jpeg'"
width="180rpx"
height="180rpx"
radius="12rpx"
mode="aspectFill"
@click="preview(item.product && item.product.cover ? item.product.cover : 'https://yaodongxiao.oss-cn-guangzhou.aliyuncs.com/20260126/35fa0fb100376d9925f872fdb752b5f1.jpeg')"
/>
</view>
<view class="product-info">
<view class="product-name text-center">{{ item.goodsName }}</view>
<u-line dashed/>
<view class="product-meta text-center">{{ item.product ? item.product.spec : item.spec }}</view>
<u-line dashed/>
<view class="product-meta text-center">{{ item.product ? item.product.makerAbb : '' }}</view>
<u-line dashed/>
</view>
<!-- <view class="product-bonus">-->
<!-- <text>赚¥{{ item.income || 0 }}</text>-->
<!-- <image class="bonus-tag"-->
<!-- src="https://yaodongxiao.oss-cn-guangzhou.aliyuncs.com/20260126/2dedec3908af49f55012691f7e22b408.png"-->
<!-- mode="aspectFit"/>-->
<!-- </view>-->
<u-line dashed/>
<view class="product-bonus">
<text>共分享{{ item.totalNum ? item.totalNum : 0 }}次</text>
</view>
</view>
</template>
</view>
</view>
<view class="rank-section">
<view class="section-head rank-head">
<view class="section-left" style="width: calc(100% - 100rpx)">
<image class="head-icon"
src="https://yaodongxiao.oss-cn-guangzhou.aliyuncs.com/20260126/fe741abe2e0dd7e39a60cb4deba45c4e.png"
mode="heightFix"/>
<!-- <text class="section-title">门店晒单排名</text>-->
</view>
</view>
<view class="rank-list">
<template v-if="brandRanks.length">
<view class="rank-item" v-for="(item, idx) in brandRanks" :key="idx">
<view class="rank-left">
<view class="rank-badge">
<image v-if="idx === 0" class="rank-medal"
src="https://yaodongxiao.oss-cn-guangzhou.aliyuncs.com/20260126/99b7a70575473d52e38c765a8635428c.png"
mode="aspectFit"/>
<image v-else-if="idx === 1" class="rank-medal"
src="https://yaodongxiao.oss-cn-guangzhou.aliyuncs.com/20260126/3a984885c03dbd31f1bd998f98c41493.png"
mode="aspectFit"/>
<image v-else-if="idx === 2" class="rank-medal"
src="https://yaodongxiao.oss-cn-guangzhou.aliyuncs.com/20260126/722383e4ed2ba2e98cd04ce3ee5c4b8f.png"
mode="aspectFit"/>
<text v-else class="rank-num">{{ idx + 1 }}</text>
</view>
<text class="rank-name">{{ item.merchantName || item.merchant_name || '门店' }}</text>
</view>
<view class="rank-right">
<!-- <text class="rank-reward">奖励¥{{ item.amount || 0 }}</text>-->
<text class="rank-count">晒单 {{ item.shareList.length || 0 }}</text>
</view>
</view>
</template>
<u-empty v-else
icon="https://yaodongxiao.oss-cn-guangzhou.aliyuncs.com/20260126/ddcc1bbd561d97128ed19407b07e299b.png"
text="暂无数据"/>
</view>
<!-- <view class="rank-rule" @click="openRulePopup">红包奖励规则</view>-->
</view>
<view class="footer">
<view class="share-action" @click="handleShare">我要分享</view>
</view>
<view v-if="showDetailPopup" class="share-popup-mask" @tap="closeDetailPopup" @touchmove.stop.prevent></view>
<view v-if="showDetailPopup" class="share-popup card" @tap.stop>
<view class="popup-head">
<view class="section-title">晒单详情</view>
<view class="close-btn" @tap="closeDetailPopup">关闭</view>
</view>
<view class="record-meta">门店:{{ detailEntry.merchantName || detailEntry.merchant_name || '-' }}</view>
<!-- <view class="record-meta text-red">-->
<!-- 红包:¥{{ formatPrice(detailEntry.income || 0) }}-->
<!-- <text v-if="hasExtraIncome(detailEntry)">(含额外加提:{{ formatPrice(resolveExtraIncome(detailEntry)) }}元)</text>-->
<!-- </view>-->
<view class="record-meta">
提交人:{{ detailEntry.owner || '-' }} · {{ detailEntry.date || detailEntry.shareDate || detailEntry.activityDateText || '-' }}
</view>
<view class="record-meta">留言:{{ detailEntry.remark || '-' }}</view>
<view class="detail-title">商品明细</view>
<view v-if="detailEntry.detailList.length === 0" class="detail-empty">暂无明细</view>
<view v-else class="detail-list">
<view class="detail-item" v-for="(detail, idx) in detailEntry.detailList" :key="idx">
<view class="detail-head">
<view class="detail-name">{{ detail.goodsName || detail.goods_name || detail.name || detail.title || '商品' }}</view>
</view>
<view class="detail-grid">
<view class="detail-kv">
<text class="detail-label">数量</text>
<text class="detail-value">x {{ detail.num || detail.qty || 0 }}</text>
</view>
<view class="detail-kv">
<text class="detail-label">厂家</text>
<text class="detail-value">{{ resolveManufacturer(detail) }}</text>
</view>
<view class="detail-kv">
<text class="detail-label">编号</text>
<text class="detail-value">{{ resolveCode(detail) }}</text>
</view>
<view class="detail-kv">
<text class="detail-label">规格</text>
<text class="detail-value">{{ resolveSpec(detail) }}</text>
</view>
<view class="detail-kv">
<text class="detail-label">金额</text>
<text class="detail-value text-red">{{ formatMoney(resolveAmount(detail)) }}</text>
</view>
<!-- <view class="detail-kv">-->
<!-- <text class="detail-label">额外加提</text>-->
<!-- <text class="detail-value text-red">{{ formatMoney(resolveExtraAmount(detail)) }}</text>-->
<!-- </view>-->
</view>
</view>
</view>
</view>
<view v-if="showRulePopup" class="share-popup-mask" @tap="closeRulePopup" @touchmove.stop.prevent></view>
<view v-if="showRulePopup" class="share-popup card rule-popup" @tap.stop>
<view class="popup-head">
<view class="section-title">规则</view>
<view class="close-btn" @tap="closeRulePopup">关闭</view>
</view>
<view class="rule-content">{{ rewardRules || '暂无规则' }}</view>
</view>
<AreaPicker
:show="showAreaPicker"
:default-province="currentProvince"
:default-city="currentCity"
@close="showAreaPicker = false"
@confirm="onAreaConfirm"
/>
<u-popup :show="showMerchantPicker" mode="bottom" round="24rpx" @close="closeMerchantPicker">
<view class="merchant-picker">
<view class="merchant-picker-head">
<view class="picker-action" @tap="closeMerchantPicker">取消</view>
<text class="picker-title">选择连锁和门店</text>
<view class="picker-action primary" @tap="confirmMerchantPicker">确定</view>
</view>
<view class="merchant-picker-search">
<input
v-model="merchantSearchKeyword"
class="merchant-search-input"
placeholder="搜索连锁/门店"
placeholder-class="merchant-search-placeholder"
/>
</view>
<view v-if="merchantLoading" class="merchant-picker-loading">
<u-loading-icon mode="circle"></u-loading-icon>
</view>
<picker-view
v-else
class="merchant-picker-view"
:value="merchantPickerValue"
:indicator-style="merchantIndicatorStyle"
@change="onMerchantPickerChange"
>
<picker-view-column>
<view v-for="(item, idx) in merchantChainOptions" :key="idx" class="merchant-picker-item">{{
item.text
}}
</view>
</picker-view-column>
<picker-view-column>
<view v-for="(item, idx) in merchantStoreOptions" :key="idx" class="merchant-picker-item">{{
item.text
}}
</view>
</picker-view-column>
</picker-view>
</view>
</u-popup>
<Login ref="Login" @done="getUserData"/>
</view>
</template>
<script>
import {calcDistanceMeters, formatDistance, getLocationAction} from "@/util";
import {dictDataInfoReq} from "@/api/common";
import {
shareDataReq,
shareDetailPageReq,
shareListReq,
shareUserDataReq
} from "@/api/share";
import dayjs from 'dayjs'
import Login from "@/components/Login.vue";
import AppNavbar from "@/components/AppNavbar.vue";
import AreaPicker from "@/components/AreaPicker.vue";
import {userInfoReq} from "@/api/user";
import {getUserInfo} from "@/util/user";
import {merchantGroupByChainReq} from "@/api/shop";
export default {
components: {Login, AppNavbar, AreaPicker},
data() {
return {
todayEntries: [],
brandRanks: [],
shareProducts: [],
rewardRules: '',
showDetailPopup: false,
showRulePopup: false,
detailEntry: {
detailList: []
},
isLoggedIn: false,
data: {
income: 0,
num: 0,
totalNum: 0,
totalIncome: 0,
},
lat: null,
lng: null,
currentCity: '定位中',
currentProvince: '',
showAreaPicker: false,
showMerchantPicker: false,
merchantLoading: false,
merchantChainList: [],
merchantSearchKeyword: '',
merchantPickerValue: [0, 0],
merchantChainId: '',
merchantId: '',
merchantText: '门店筛选',
showAllToday: false,
showAllProducts: false,
productKeyword: '',
productKeywordApplied: '',
user: {
canAuth: 0
}
}
},
computed: {
isPopupOpen() {
return this.showDetailPopup || this.showRulePopup
},
pageStyle() {
return this.isPopupOpen ? 'overflow: hidden;' : ''
},
displayedTodayEntries() {
const list = Array.isArray(this.todayEntries) ? this.todayEntries : []
return this.showAllToday ? list : list.slice(0, 5)
},
canShowTodayMore() {
return !this.showAllToday && this.todayEntries.length > 5
},
merchantLabel() {
return this.merchantText || '门店筛选'
},
merchantChainOptions() {
const list = Array.isArray(this.merchantChainList) ? this.merchantChainList : []
const keyword = (this.merchantSearchKeyword || '').trim()
if (!keyword) return list.length ? list : [{id: '', text: '暂无连锁', merchantCount: 0}]
const filtered = list
.map(chain => {
const visible = this.getVisibleMerchants(chain)
if (!visible.length) return null
return Object.assign({}, chain, {merchantCount: visible.length})
})
.filter(Boolean)
return filtered.length ? filtered : [{id: '', text: '无匹配连锁', merchantCount: 0}]
},
merchantStoreOptions() {
const chainIndex = Number(this.merchantPickerValue[0] || 0)
const currentChain = this.merchantChainOptions[chainIndex] || this.merchantChainOptions[0] || {}
const merchants = this.getVisibleMerchants(currentChain)
return merchants.length ? merchants : [{id: '', text: '无门店'}]
},
merchantPickerIndexes() {
const chainList = Array.isArray(this.merchantChainList) ? this.merchantChainList : []
if (!chainList.length) return [0, 0]
let chainIndex = chainList.findIndex(item => item.id === this.merchantChainId)
if (chainIndex < 0 && this.merchantId) {
chainIndex = chainList.findIndex(item => {
const merchants = this.getMerchantsByChainId(item.id)
return merchants.some(merchant => merchant.id === this.merchantId)
})
}
if (chainIndex < 0) chainIndex = 0
const merchants = this.getMerchantsByChainId(chainList[chainIndex].id)
let merchantIndex = merchants.findIndex(item => item.id === this.merchantId)
if (merchantIndex < 0) merchantIndex = 0
return [chainIndex, merchantIndex]
},
merchantIndicatorStyle() {
return 'height: 44px;'
},
displayedShareProducts() {
const list = this.filteredShareProducts
return this.showAllProducts ? list.slice(0, 48) : list.slice(0, 12)
},
canShowProductMore() {
return !this.showAllProducts && this.filteredShareProducts.length > 12
},
filteredShareProducts() {
const list = Array.isArray(this.shareProducts) ? this.shareProducts : []
const keyword = (this.productKeywordApplied || '').trim()
if (!keyword) return list
const normalized = keyword.toLowerCase()
return list.filter(item => {
const name = (item.goodsName || item.productName || '').toString().toLowerCase()
const spec = (item.product ? item.product.spec : item.spec || '').toString().toLowerCase()
const maker = (item.product ? item.product.makerAbb : '').toString().toLowerCase()
return name.includes(normalized) || spec.includes(normalized) || maker.includes(normalized)
})
}
},
watch: {
merchantSearchKeyword() {
this.merchantPickerValue = [0, 0]
}
},
created() {
// 门店明细挂实例上而非 data:data 里的一切都会进页面 data 树并被 setData 序列化
this.merchantStore = []
},
async onShow() {
await this.refreshPageData()
},
async onPullDownRefresh() {
try {
await this.refreshPageData()
} finally {
uni.stopPullDownRefresh()
}
},
methods: {
dayjs,
// 门店明细只存在实例的 merchantStore 上(不进页面 data),按连锁 id 取
getMerchantsByChainId(chainId) {
const store = Array.isArray(this.merchantStore) ? this.merchantStore : []
const chain = store.find(item => String(item.id) === String(chainId))
return chain && Array.isArray(chain.merchants) ? chain.merchants : []
},
// 搜索关键词命中的门店:连锁名命中则整列保留,否则按门店名过滤
getVisibleMerchants(chain) {
const merchants = this.getMerchantsByChainId(chain && chain.id)
const keyword = (this.merchantSearchKeyword || '').trim()
if (!keyword) return merchants
if ((chain.text || '').includes(keyword)) return merchants
return merchants.filter(merchant => (merchant.text || '').includes(keyword))
},
async refreshPageData() {
if (getUserInfo().token) {
await this.getUserData()
}
const res = await getLocationAction().catch(() => {
uni.showToast({
title: '请允许位置授权',
icon: 'none'
})
})
if (res) {
this.lat = res.latitude
this.lng = res.longitude
this.currentCity = res.city_str
this.currentProvince = res.province_str || ''
}
await this.getShareUserData()
await this.getShareData()
await this.getTodayShare()
await this.getProductList()
await this.getRule()
},
openAreaPicker() {
this.showAreaPicker = true
},
onAreaConfirm({provinceName, cityName}) {
this.currentProvince = provinceName
this.currentCity = cityName
this.showAreaPicker = false
this.getShareData()
this.getTodayShare()
this.getProductList()
},
normalizeMerchantChain(item, index) {
const chainName = this.pickFirstText(item, ['chainName', 'chain', 'chainAbb', 'name', 'title']) || `连锁${index + 1}`
const rawChainId = item.chainId ?? item.chain_id ?? item.id ?? item.chainCode ?? item.chain_code ?? chainName
const chainId = rawChainId === undefined || rawChainId === null || String(rawChainId).trim() === ''
? `chain_${index}`
: rawChainId
const childCandidates = ['children', 'merchantList', 'merchants', 'shopList', 'list', 'records']
let rawChildren = []
for (let i = 0; i < childCandidates.length; i++) {
const list = item[childCandidates[i]]
if (Array.isArray(list) && list.length) {
rawChildren = list
break
}
}
let merchants = rawChildren
.map(child => this.normalizeMerchant(child))
.filter(merchant => merchant.id !== undefined && merchant.id !== null && String(merchant.id).trim() !== '')
if (!merchants.length) {
const selfMerchant = this.normalizeMerchant(item)
if (selfMerchant.id !== undefined && selfMerchant.id !== null && String(selfMerchant.id).trim() !== '') {
merchants = [selfMerchant]
}
}
return {
id: chainId,
text: chainName,
merchants
}
},
normalizeMerchant(item) {
const id = item.id ?? item.merchantId ?? item.merchant_id
const text = item.name || item.merchantName || item.merchant_name || item.title || '未命名门店'
return {
id,
text
}
},
pickFirstText(source, keys = []) {
if (!source || typeof source !== 'object') return ''
for (let i = 0; i < keys.length; i++) {
const value = source[keys[i]]
if (value !== undefined && value !== null && String(value).trim()) {
return String(value).trim()
}
}
return ''
},
unwrapList(data) {
if (Array.isArray(data)) return data
if (!data || typeof data !== 'object') return []
const keys = ['list', 'records', 'rows', 'items', 'data', 'result', 'chainList']
for (let i = 0; i < keys.length; i++) {
const value = data[keys[i]]
if (Array.isArray(value)) return value
if (value && typeof value === 'object') {
const nested = this.unwrapList(value)
if (nested.length) return nested
}
}
return [data]
},
async getMerchantList() {
this.merchantLoading = true
try {
const {data} = await merchantGroupByChainReq({})
const list = this.unwrapList(data)
const chainList = list
.map((item, index) => this.normalizeMerchantChain(item, index))
.filter(item => Array.isArray(item.merchants) && item.merchants.length)
this.setMerchantChains([{
id: '',
text: '全部连锁',
merchants: [{id: '', text: '全部门店'}]
}].concat(chainList))
} catch (error) {
uni.showToast({
title: '门店列表获取失败',
icon: 'none'
})
} finally {
this.merchantLoading = false
}
},
// 门店明细(全量可达 8000+ 条)只放在实例上,不进页面 data。
// 一旦放进 data,页面 data 树会膨胀到 1MB+,此后每次 setData 都要重新
// diff/序列化整棵树,连只含一个字段的 setData 也会从 ~490ms 涨到 ~1000ms。
setMerchantChains(chains) {
const list = Array.isArray(chains) ? chains : []
this.merchantStore = list.map(chain => ({
id: chain.id,
text: chain.text,
merchants: Array.isArray(chain.merchants) ? chain.merchants : []
}))
this.merchantChainList = this.merchantStore.map(chain => ({
id: chain.id,
text: chain.text,
merchantCount: chain.merchants.length
}))
},
async openMerchantPicker() {
if (!this.merchantChainList || this.merchantChainList.length <= 1) {
await this.getMerchantList()
}
this.merchantSearchKeyword = ''
this.merchantPickerValue = [...this.merchantPickerIndexes]
this.showMerchantPicker = true
},
closeMerchantPicker() {
this.showMerchantPicker = false
},
onMerchantPickerChange(e) {
const value = e.detail && e.detail.value !== undefined ? e.detail.value : e.detail
const currentChainIndex = Number(this.merchantPickerValue[0] || 0)
let chainIndex = Array.isArray(value) ? Number(value[0] || 0) : Number(value || 0)
let merchantIndex = Array.isArray(value) ? Number(value[1] || 0) : 0
if (!Number.isFinite(chainIndex) || chainIndex < 0) chainIndex = 0
if (!Number.isFinite(merchantIndex) || merchantIndex < 0) merchantIndex = 0
const chain = this.merchantChainOptions[chainIndex] || this.merchantChainOptions[0] || {}
const merchants = this.getVisibleMerchants(chain)
if (!merchants.length) {
merchantIndex = 0
} else if (chainIndex !== currentChainIndex || merchantIndex > merchants.length - 1) {
merchantIndex = 0
}
this.merchantPickerValue = [chainIndex, merchantIndex]
},
confirmMerchantPicker() {
const chainIndex = Number(this.merchantPickerValue[0] || 0)
const merchantIndex = Number(this.merchantPickerValue[1] || 0)
const chain = this.merchantChainOptions[chainIndex] || null
const merchants = this.getVisibleMerchants(chain)
const selected = merchants[merchantIndex] || null
if (!selected) return
this.merchantChainId = chain ? (chain.id ?? '') : ''
this.merchantId = selected.id ?? ''
if (!this.merchantId) {
this.merchantText = '门店筛选'
} else {
this.merchantText = `${(chain && chain.text) || '连锁'}/${selected.text || '门店'}`
}
this.showMerchantPicker = false
this.getShareData()
this.getTodayShare()
this.getProductList()
},
async getRule() {
const {data} = await dictDataInfoReq(576)
this.rewardRules = (data && data.dictDataCode) || ''
},
async getShareData() {
const params = {
province: this.currentProvince,
city: this.currentCity
}
if (this.merchantId) {
params.merchantId = this.merchantId
}
const {data} = await shareDataReq(params)
const list = Array.isArray(data) ? data : (data && Array.isArray(data.list) ? data.list : [])
// slice 避免就地排序改动接口返回的数组;amount 缺失时按 0 处理,避免 NaN 让排序结果不稳定
this.brandRanks = list.slice().sort((a, b) => (parseFloat(b.amount) || 0) - (parseFloat(a.amount) || 0))
},
async getShareUserData() {
const {data} = await shareUserDataReq()
// 接口业务失败(code!=0)时 get 仍会 resolve 出不含 data 的响应体,
// 这里必须兜底为默认统计值,否则模板里的 data.num 会整页崩掉
this.data = data && typeof data === 'object'
? Object.assign({income: 0, num: 0, totalNum: 0, totalIncome: 0}, data)
: {income: 0, num: 0, totalNum: 0, totalIncome: 0}
},
async getUserData() {
const {data} = await userInfoReq()
this.isLoggedIn = true
this.user = data && typeof data === 'object' ? data : {canAuth: 0}
},
async getTodayShare() {
const params = {
createTimeStart: dayjs().format('YYYY-MM-DD'),
createTimeEnd: dayjs().format('YYYY-MM-DD 23:59:59'),
province: this.currentProvince,
city: this.currentCity
}
if (this.merchantId) {
params.merchantId = this.merchantId
}
const {data} = await shareListReq(params)
this.todayEntries = Array.isArray(data) ? data : []
this.showAllToday = false
this.updateTodayDistances()
},
async getProductList() {
const params = {
page: 1,
limit: 48,
province: this.currentProvince,
city: this.currentCity,
groupByProduct: true
}
if (this.merchantId) {
params.merchantId = this.merchantId
}
const {data} = await shareDetailPageReq(params)
// 后端分页接口失败时 data 为 undefined,直接取 data.list 会抛
// "Cannot read properties of undefined (reading 'list')"
const list = Array.isArray(data && data.list) ? data.list : []
this.shareProducts = list.filter(item => !!item.product).slice(0, 48)
this.showAllProducts = false
this.productKeyword = ''
this.productKeywordApplied = ''
},
showDetail(item) {
this.detailEntry = this.normalizeDetailEntry(item || {})
this.showDetailPopup = true
},
closeDetailPopup() {
this.showDetailPopup = false
this.detailEntry = {detailList: []}
},
openRulePopup() {
this.showRulePopup = true
},
closeRulePopup() {
this.showRulePopup = false
},
showAllTodayEntries() {
this.showAllToday = true
},
showAllProductEntries() {
this.showAllProducts = true
},
searchProduct() {
this.productKeywordApplied = (this.productKeyword || '').trim()
this.showAllProducts = false
},
updateTodayDistances() {
if (!Number.isFinite(this.lat) || !Number.isFinite(this.lng)) return
const list = Array.isArray(this.todayEntries) ? this.todayEntries : []
this.todayEntries = list.map(item => {
const merchant = item.merchant || {}
const lat = Number(merchant.lat || merchant.latitude || item.merchantLat || item.merchantLatitude || item.lat || item.latitude)
const lng = Number(merchant.lng || merchant.longitude || item.merchantLng || item.merchantLongitude || item.lng || item.longitude)
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return item
const meters = calcDistanceMeters(this.lat, this.lng, lat, lng)
return Object.assign({}, item, {
distance: formatDistance(meters)
})
})
},
handleShare() {
if (!this.isLoggedIn) {
this.$refs.Login.open()
return
}
const lat = Number.isFinite(this.lat) ? this.lat : ''
const lng = Number.isFinite(this.lng) ? this.lng : ''
const province = encodeURIComponent(this.currentProvince || '')
const city = encodeURIComponent(this.currentCity || '')
uni.navigateTo({
url: `/pages/share/create?lat=${lat}&lng=${lng}&province=${province}&city=${city}`
})
},
normalizeDetailEntry(item) {
const rawList = item.detailList || item.list || item.detail_list || []
const detailList = this.parseJsonMaybe(rawList)
return {
...item,
owner: item.owner || (item.user && (item.user.realName || item.user.nickname)) || '-',
activityDateText: item.date || item.shareDate || item.createTime || item.createdAt || item.create_time || '-',
detailList: Array.isArray(detailList) ? detailList : []
}
},
formatPrice(value) {
const num = Number(value || 0)
return num.toFixed(2)
},
resolveExtraIncome(source = {}) {
const candidates = [
source.extraIncome,
source.extra_income,
source.extraBonus,
source.extra_bonus,
source.otherIncome,
source.other_income
]
for (let i = 0; i < candidates.length; i++) {
const raw = candidates[i]
if (raw === '' || raw === null || raw === undefined) continue
const num = Number(raw)
if (Number.isFinite(num)) return num
}
return 0
},
hasExtraIncome(source = {}) {
return this.resolveExtraIncome(source) !== 0
},
resolveManufacturer(detail) {
if (!detail) return '-'
const product = detail.product || {}
const value = detail.makerAbb
|| detail.manufacturer
|| detail.vendor
|| detail.factory
|| detail.brand
|| product.makerAbb
|| product.manufacturer
|| product.vendor
|| product.factory
|| product.brand
return this.normalizeText(value)
},
resolveCode(detail) {
if (!detail) return '-'
const value = detail.goodsCode
|| detail.code
|| detail.productCode
|| detail.skuCode
|| detail.barcode
return this.normalizeText(value)
},
resolveSpec(detail) {
if (!detail) return '-'
const product = detail.product || {}
const value = detail.spec
|| detail.specification
|| detail.standard
|| product.spec
|| product.specification
|| product.standard
return this.normalizeText(value)
},
resolveAmount(detail) {
if (!detail) return ''
if (detail.income !== undefined && detail.income !== null && detail.income !== '') return detail.income
if (detail.product && detail.product.income !== undefined && detail.product.income !== null && detail.product.income !== '') {
return detail.product.income
}
return ''
},
resolveExtraAmount(detail) {
if (!detail) return ''
if (detail.extraIncome !== undefined && detail.extraIncome !== null && detail.extraIncome !== '') return detail.extraIncome
if (detail.product && detail.product.extraIncome !== undefined && detail.product.extraIncome !== null && detail.product.extraIncome !== '') {
return detail.product.extraIncome
}
return ''
},
normalizeText(value) {
if (value === 0) return '0'
if (value === null || value === undefined || value === '') return '-'
return String(value)
},
formatMoney(value) {
if (value === null || value === undefined || value === '') return '-'
const num = Number(value)
if (Number.isFinite(num)) return `¥${num.toFixed(2)}`
return String(value)
},
parseJsonMaybe(value) {
if (value === null || value === undefined || value === '') return null
if (typeof value === 'string') {
try {
return JSON.parse(value)
} catch (err) {
return value
}
}
return value
},
preview(cover) {
uni.previewImage({
urls: [cover]
})
},
jumpWithAuth(url) {
if (!this.isLoggedIn) {
this.$refs.Login.open()
return
}
uni.navigateTo({
url
})
}
}
}
</script>
<style lang="scss" scoped>
.share-page {
min-height: 100vh;
//padding: 0 20rpx 50rpx;
background: #e9fbf6 url('https://yaodongxiao.oss-cn-guangzhou.aliyuncs.com/20260126/7b74d64d385c371f718cad4a275a5082.png') no-repeat top center / 100% auto;
}
.share-hero {
padding: 0 6rpx 0;
}
.hero-icon {
width: 34rpx;
height: 34rpx;
border-radius: 10rpx;
background: rgba(39, 199, 164, 0.18);
border: 1rpx solid rgba(39, 199, 164, 0.35);
}
.stats-card {
margin: 20rpx;
padding: 18rpx 20rpx;
background: #ffffff;
border-radius: 24rpx;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
box-shadow: 0 12rpx 24rpx rgba(0, 0, 0, 0.08);
border: 1px solid #2fc6a7;
border-top: 20px solid #2fc6a7;
}
.stat-item {
display: flex;
flex-direction: column;
gap: 6rpx;
}
.stat-value {
display: flex;
align-items: baseline;
gap: 4rpx;
}
.stat-number {
font-size: 32rpx;
font-weight: 800;
color: #ff3b30;
}
.stat-unit {
font-size: 22rpx;
color: #6b7280;
}
.stat-label {
font-size: 22rpx;
color: #6b7280;
}
.stat-action {
padding: 10rpx 18rpx;
background: #2fc6a7;
color: #ffffff;
font-size: 24rpx;
font-weight: 700;
border-radius: 999rpx;
}
.today-head {
margin-top: 18rpx;
padding: 0 6rpx;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.filter-chips {
margin: 8rpx 20rpx 0;
display: flex;
justify-content: flex-end;
}
.filter-chip {
padding: 6rpx 14rpx;
border-radius: 999rpx;
border: 1rpx solid #2fc6a7;
color: #2fc6a7;
font-size: 22rpx;
}
.city {
display: flex;
align-items: center;
gap: 6rpx;
color: #2fc6a7;
font-weight: 700;
font-size: 24rpx;
position: absolute;
top: 10rpx;
left: 20rpx;
}
.today-more {
position: absolute;
top: 10rpx;
right: 20rpx;
font-size: 22rpx;
color: #2fc6a7;
font-weight: 700;
}
.head-center {
display: flex;
align-items: center;
justify-content: center;
gap: 8rpx;
color: #2fc6a7;
flex: 1;
}
.section-title {
font-size: 26rpx;
font-weight: 700;
color: #1f2937;
}
.head-center .section-title,
.section-left .section-title {
color: #2fc6a7;
}
.section-more {
font-size: 22rpx;
color: #2fc6a7;
}
.product-more {
color: #ffffff;
}
.head-icon {
//width: 30rpx;
height: 60rpx;
}
.today-list {
margin-top: 12rpx;
display: flex;
flex-direction: column;
gap: 14rpx;
}
.today-card {
background: #ffffff;
border-radius: 18rpx;
padding: 14rpx 16rpx;
border: 1rpx solid #a7f3d0;
box-shadow: 0 8rpx 16rpx rgba(39, 199, 164, 0.12);
margin: 0 20rpx 0;
}
.today-row {
display: flex;
align-items: center;
gap: 6rpx;
font-size: 24rpx;
color: #374151;
margin-top: 6rpx;
}
.today-row:first-child {
margin-top: 0;
}
.today-row-space {
justify-content: space-between;
}
.today-label {
color: #6b7280;
}
.today-time {
color: #6b7280;
font-size: 22rpx;
}
.today-distance {
display: flex;
align-items: center;
gap: 4rpx;
font-size: 22rpx;
color: #ff8a00;
}
.product-section {
margin-top: 20rpx;
padding: 22rpx 18rpx 26rpx;
border-radius: 24rpx;
background: url('https://yaodongxiao.oss-cn-guangzhou.aliyuncs.com/20260126/09dd8e6519b44d71410fc82a7e255540.jpg') no-repeat top / cover;
}
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16rpx;
}
.section-left {
display: flex;
align-items: center;
justify-content: center;
gap: 8rpx;
}
.product-search {
display: flex;
align-items: center;
gap: 12rpx;
margin-bottom: 16rpx;
}
.search-box {
flex: 1;
display: flex;
align-items: center;
gap: 8rpx;
background: #ffffff;
border-radius: 999rpx;
padding: 10rpx 16rpx;
box-shadow: 0 8rpx 14rpx rgba(0, 0, 0, 0.06);
}
.search-input {
flex: 1;
font-size: 24rpx;
color: #1f2937;
}
.search-btn {
padding: 10rpx 16rpx;
background: #2fc6a7;
color: #ffffff;
font-size: 24rpx;
font-weight: 700;
border-radius: 999rpx;
}
.product-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16rpx;
}
.product-card {
background: #ffffff;
border-radius: 16rpx;
padding: 12rpx;
box-shadow: 0 8rpx 14rpx rgba(0, 0, 0, 0.06);
}
.product-card .product-name {
font-size: 24rpx;
font-weight: 700;
color: #1f2937;
}
.product-card .product-meta {
font-size: 20rpx;
color: #6b7280;
margin-top: 4rpx;
}
.product-info {
margin-top: 10rpx;
}
.product-bonus {
margin-top: 6rpx;
display: flex;
align-items: center;
justify-content: center;
gap: 6rpx;
font-size: 22rpx;
font-weight: 700;
color: #ff6b00;
}
.bonus-tag {
width: 24rpx;
height: 24rpx;
}
.rank-section {
margin: 20rpx;
background: #ffffff;
border-radius: 24rpx;
padding: 22rpx 18rpx 80rpx;
position: relative;
box-shadow: 0 10rpx 20rpx rgba(0, 0, 0, 0.06);
}
.rank-head {
margin-bottom: 8rpx;
}
.rank-list {
display: flex;
flex-direction: column;
}
.rank-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14rpx 0;
border-bottom: 1rpx dashed #dbe5f0;
}
.rank-item:last-child {
border-bottom: none;
}
.rank-left {
display: flex;
align-items: center;
gap: 10rpx;
flex: 1;
}
.rank-badge {
width: 36rpx;
height: 36rpx;
display: flex;
align-items: center;
justify-content: center;
}
.rank-medal {
width: 36rpx;
height: 36rpx;
}
.rank-num {
color: #2fc6a7;
font-weight: 700;
font-size: 24rpx;
}
.rank-name {
color: #1f2937;
font-size: 24rpx;
}
.rank-right {
display: flex;
align-items: center;
gap: 12rpx;
font-size: 22rpx;
color: #ef4444;
}
.rank-reward {
color: #ef4444;
font-weight: 700;
}
.rank-count {
color: #ef4444;
}
.rank-rule {
position: absolute;
right: 18rpx;
bottom: 18rpx;
padding: 10rpx 22rpx;
background: linear-gradient(135deg, #ffb020, #ff7a00);
color: #ffffff;
font-size: 24rpx;
font-weight: 700;
border-radius: 999rpx;
}
.share-action {
margin: 20rpx;
padding: 20rpx 0;
background: linear-gradient(135deg, #33d0b3, #22c3a1);
color: #ffffff;
text-align: center;
font-size: 30rpx;
font-weight: 700;
border-radius: 999rpx;
box-shadow: 0 10rpx 18rpx rgba(34, 195, 161, 0.3);
}
.product {
padding: 16rpx 0;
border-bottom: 1rpx solid #edf1f7;
}
.product:last-child {
border-bottom: none;
}
.product-head {
display: flex;
justify-content: space-between;
align-items: center;
}
.product-name {
font-size: 30rpx;
color: var(--text-main);
}
.product-meta {
color: var(--text-sub);
font-size: 24rpx;
margin-top: 6rpx;
}
.product-amount {
color: #FE6869;
font-size: 24rpx;
margin-top: 6rpx;
}
.product-actions {
display: flex;
justify-content: flex-end;
gap: 12rpx;
margin-top: 12rpx;
}
.rule {
color: var(--text-sub);
margin-bottom: 8rpx;
line-height: 1.5;
font-size: 24rpx;
}
.review {
padding: 12rpx 0;
border-bottom: 1rpx dashed #e3eaf4;
}
.review:last-child {
border-bottom: none;
}
.review-name {
color: var(--text-main);
}
.review-meta {
color: var(--text-sub);
font-size: 24rpx;
margin: 4rpx 0;
}
.review-actions {
display: flex;
gap: 10rpx;
flex-wrap: wrap;
}
.share-popup-mask {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.45);
z-index: 30;
}
.share-popup {
position: fixed;
left: 50%;
bottom: 40rpx;
transform: translateX(-50%);
width: 88%;
max-height: 70vh;
overflow: auto;
z-index: 40;
}
.popup-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12rpx;
}
.close-btn {
color: var(--text-sub);
font-size: 24rpx;
}
.form-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12rpx 0;
border-bottom: 1rpx solid #edf1f7;
}
.form-row:last-of-type {
border-bottom: none;
}
.label {
color: var(--text-main);
font-size: 26rpx;
min-width: 140rpx;
}
.input {
flex: 1;
text-align: right;
font-size: 26rpx;
color: var(--text-main);
}
.picker-input {
flex: 1;
}
.picker-input-inner {
width: 100%;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8rpx;
min-height: 40rpx;
}
.picker-text {
color: var(--text-main);
font-size: 26rpx;
}
.picker-placeholder {
color: #9ca3af;
font-size: 26rpx;
}
.merchant-picker {
padding: 20rpx 24rpx 30rpx;
}
.merchant-picker-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12rpx;
}
.picker-action {
font-size: 26rpx;
color: #6b7280;
}
.picker-action.primary {
color: #2fc6a7;
font-weight: 700;
}
.picker-title {
font-size: 28rpx;
font-weight: 700;
color: #1f2937;
}
.merchant-picker-search {
margin: 6rpx 0 12rpx;
}
.merchant-search-input {
width: 100%;
background: #f3f4f6;
border-radius: 12rpx;
padding: 12rpx 16rpx;
font-size: 26rpx;
color: #1f2937;
}
.merchant-search-placeholder {
color: #9ca3af;
}
.merchant-picker-loading {
padding: 20rpx 0;
display: flex;
align-items: center;
justify-content: center;
}
.merchant-picker-view {
height: 360rpx;
}
.merchant-picker-item {
height: 44px;
line-height: 44px;
text-align: center;
font-size: 26rpx;
color: #1f2937;
}
.form-value {
flex: 1;
text-align: right;
font-size: 26rpx;
color: var(--text-main);
}
.detail-title {
margin-top: 12rpx;
font-weight: 700;
color: var(--text-main);
}
.detail-actions {
margin-top: 8rpx;
display: flex;
justify-content: flex-end;
}
.ghost-btn {
color: var(--brand-primary);
border: 1rpx solid var(--brand-primary);
padding: 6rpx 16rpx;
border-radius: 999rpx;
font-size: 24rpx;
}
.detail-empty {
color: var(--text-sub);
text-align: center;
padding: 12rpx 0;
}
.detail-list {
margin-top: 8rpx;
display: flex;
flex-direction: column;
gap: 12rpx;
}
.detail-item {
border: 1rpx dashed #dfe8f5;
border-radius: 12rpx;
padding: 12rpx;
}
.detail-name {
color: var(--text-main);
font-weight: 600;
flex: 1;
word-break: break-all;
}
.detail-grid {
display: flex;
flex-wrap: wrap;
gap: 8rpx 16rpx;
}
.detail-kv {
display: flex;
gap: 6rpx;
min-width: 45%;
}
.detail-label {
color: var(--text-sub);
white-space: nowrap;
}
.detail-value {
color: var(--text-main);
flex: 1;
word-break: break-all;
}
.detail-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6rpx;
}
.detail-index {
color: var(--text-sub);
font-size: 24rpx;
}
.detail-remove {
color: #ff3b30;
font-size: 24rpx;
}
.submit {
margin-top: 16rpx;
padding: 14rpx;
height: 60rpx;
display: flex;
align-items: center;
justify-content: center;
}
.popup-actions {
display: flex;
align-items: center;
gap: 12rpx;
margin-top: 16rpx;
}
.popup-actions .ghost-action,
.popup-actions .submit {
flex: 1;
margin-top: 0;
margin-left: 0;
margin-right: 0;
width: auto;
}
.textarea {
flex: 1;
text-align: right;
font-size: 26rpx;
color: var(--text-main);
background: transparent;
min-height: 40rpx;
line-height: 40rpx;
}
.ghost-action {
text-align: center;
padding: 10rpx 16rpx;
border-radius: 999rpx;
font-weight: 800;
font-size: 26rpx;
flex: 1;
background: #F0F6FF;
color: #2563eb;
border: 1rpx solid #F0F6FF;
margin: 10rpx;
width: calc(100% - 60rpx);
display: flex;
align-items: center;
justify-content: center;
}
.rule-popup {
max-height: 70vh;
}
.rule-content {
font-size: 24rpx;
color: #374151;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-all;
}
.record-meta {
color: var(--text-sub);
font-size: 24rpx;
margin-top: 6rpx;
}
.text-red {
color: #ef4444;
}
</style>