Files
yxe-mp/pages/share/index.vue
T
weicw-dshsa 575b4a723a perf(门店选择): 门店明细移出页面 data,修复首屏卡顿
门店接口 /shop/merchant/groupByChain 一次返回 8105 家门店(约 1.1MB)。
此前把门店明细原样放进页面 data,data 树膨胀到 1118KB,导致此后每次
setData 都要重新 diff/序列化整棵树——即使载荷只有 1 个字段,耗时也从
约 490ms 涨到约 1000ms,首屏因此多花约 3.5s。

改为把明细挂在实例属性 merchantStore 上(不进页面 data),
merchantChainList 只保留 {id, text, merchantCount} 骨架,
新增 getMerchantsByChainId() / getVisibleMerchants() 按需取数。

涉及页面:
- packageUser/self-report/create.vue
- packageUser/chronic/index.vue
- pages/task/detail.vue
- pages/share/create.vue
- pages/share/index.vue
- pages/audit/components/AuditShareItem.vue

实测(微信开发者工具自动化):
- 页面 data 门店明细 1118KB -> 1KB
- 单字段 setData 998ms -> 10~53ms
- self-report/create 首屏 9055ms -> 5341ms
- 门店 picker 选项数不变(大参林 2700 项),搜索与选中回填均正常
2026-10-11 15:18:06 +08:00

1668 lines
46 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="share-page">
<page-meta :page-style="pageStyle"/>
<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.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 && data.list ? data.list : [])
this.brandRanks = list.sort((a, b) => parseFloat(b.amount) - parseFloat(a.amount))
},
async getShareUserData() {
const {data} = await shareUserDataReq()
this.data = data
},
async getUserData() {
const {data} = await userInfoReq()
this.isLoggedIn = true
this.user = data
},
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 = 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)
this.shareProducts = data.list
this.shareProducts = this.shareProducts.filter(item => !!item.product).filter((item, index) => index < 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>