feat: 新增地址提交与分享功能,优化活动记录列表显示用户及奖品信息,并支持抽奖页开发者模式

This commit is contained in:
邹方成 2025-12-26 17:28:57 +08:00
parent a3ec9c102d
commit 3dde150cde
7 changed files with 693 additions and 48 deletions

View File

@ -102,6 +102,14 @@ export function cancelShipping(user_id, batch_no) {
return authRequest({ url: `/api/app/users/${user_id}/inventory/cancel-shipping`, method: 'POST', data: { batch_no } })
}
export function createAddressShare(user_id, inventory_id) {
return authRequest({ url: `/api/app/users/${user_id}/inventory/address-share/create`, method: 'POST', data: { inventory_id } })
}
export function revokeAddressShare(user_id, inventory_id) {
return authRequest({ url: `/api/app/users/${user_id}/inventory/address-share/revoke`, method: 'POST', data: { inventory_id } })
}
export function getItemCards(user_id, status, page = 1, page_size = 20) {
const data = { page, page_size }
if (status !== undefined) data.status = status

View File

@ -1,15 +1,29 @@
<template>
<view class="records-wrapper">
<view class="records-list" v-if="records && records.length">
<view v-for="(item, idx) in records" :key="item.id || idx" class="record-item">
<image class="record-img" :src="item.image" mode="aspectFill" />
<view class="record-info">
<view class="record-title">{{ item.title }}</view>
<view class="record-meta">
<text class="record-count">x{{ item.count }}</text>
<!-- <text v-if="item.percent" class="record-percent">{{ item.percent }}%</text> -->
<view v-for="(item, idx) in records" :key="item.id ? `${item.id}_${idx}` : idx" class="record-item">
<!-- 用户信息 (左侧, 紧凑) -->
<view class="user-info-section">
<image class="user-avatar" :src="item.avatar || defaultAvatar" mode="aspectFill" />
<view class="user-detail">
<text class="user-name">{{ item.user_name }}</text>
<text class="record-time">{{ formatTime(item.created_at) }}</text>
</view>
</view>
<!-- 奖品信息 (右侧, 扩展) -->
<view class="prize-info-section">
<view class="prize-image-wrap">
<image class="record-img" :src="item.image" mode="aspectFill" />
<view class="level-badge" v-if="item.level_name">{{ item.level_name }}</view>
</view>
<view class="record-info">
<view class="record-title">{{ item.title }}</view>
<view class="record-meta">
<text class="record-count">x1</text>
</view>
</view>
</view>
</view>
</view>
<view class="empty-state-compact" v-else>
@ -23,6 +37,8 @@
</template>
<script setup>
import { computed } from 'vue'
defineProps({
records: {
type: Array,
@ -33,6 +49,21 @@ defineProps({
default: '暂无购买记录'
}
})
const defaultAvatar = 'https://mmbiz.qpic.cn/mmbiz/icTdbqWNOwNRna42FI242Lcia07jQodd2FJGIYQfG0LAJGFxM4FbnQP6yfMxBgJ0F3YRqJCJ1aPAK2dQagdusBZg/0'
function formatTime(t) {
if (!t) return ''
const d = new Date(t)
if (isNaN(d.getTime())) return t //
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
const hh = String(d.getHours()).padStart(2, '0')
const mm = String(d.getMinutes()).padStart(2, '0')
const ss = String(d.getSeconds()).padStart(2, '0')
return `${m}-${day} ${hh}:${mm}:${ss}`
}
</script>
<style lang="scss" scoped>
@ -42,8 +73,9 @@ defineProps({
.record-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: $spacing-sm 0;
padding: $spacing-md $spacing-sm;
border-bottom: 1rpx solid rgba(0, 0, 0, 0.03);
&:last-child {
@ -51,43 +83,121 @@ defineProps({
}
}
.record-img {
width: 80rpx;
height: 80rpx;
border-radius: $radius-md;
margin-right: $spacing-md;
.record-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: $spacing-md $spacing-sm;
border-bottom: 1rpx solid rgba(0, 0, 0, 0.03);
&:last-child {
border-bottom: none;
}
}
.user-info-section {
display: flex;
align-items: center;
gap: $spacing-xs;
flex: 0 0 35%; //
min-width: 0;
}
.user-avatar {
width: 56rpx;
height: 56rpx;
border-radius: 50%;
background: $bg-secondary;
border: 1px solid rgba(0,0,0,0.05);
flex-shrink: 0;
}
.user-detail {
display: flex;
flex-direction: column;
gap: 2rpx;
min-width: 0;
}
.user-name {
font-size: 24rpx;
color: $text-main;
font-weight: 500;
@include text-ellipsis(1);
}
.record-time {
font-size: 20rpx;
color: $text-sub;
@include text-ellipsis(1);
}
.prize-info-section {
display: flex;
align-items: center;
gap: $spacing-sm;
flex: 1;
min-width: 0;
justify-content: flex-end;
}
.prize-image-wrap {
position: relative;
width: 72rpx;
height: 72rpx;
flex-shrink: 0;
}
.record-img {
width: 100%;
height: 100%;
border-radius: $radius-md;
background: $bg-secondary;
border: 1px solid rgba(0,0,0,0.05);
}
.level-badge {
position: absolute;
top: -6rpx;
right: -6rpx;
background: $gradient-gold;
color: #fff;
font-size: 16rpx;
padding: 2rpx 6rpx;
border-radius: 4rpx;
font-weight: bold;
box-shadow: 0 2rpx 4rpx rgba(0,0,0,0.1);
}
.record-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: flex-start; //
}
.record-title {
font-size: $font-md;
font-weight: 600;
font-size: 24rpx;
font-weight: 500;
color: $text-main;
margin-bottom: $spacing-xs;
@include text-ellipsis(1);
@include text-ellipsis(2); //
line-height: 1.3;
margin-bottom: 4rpx;
width: 100%;
}
.record-meta {
display: flex;
align-items: center;
gap: $spacing-sm;
}
.record-count {
font-size: $font-sm;
font-size: 20rpx;
color: $brand-primary;
font-weight: 600;
}
.record-percent {
font-size: $font-xs;
color: $text-sub;
background: rgba($brand-primary, 0.08);
padding: 2rpx 8rpx;
border-radius: 4rpx;
}
/* 紧凑优雅的空状态 */

View File

@ -24,25 +24,26 @@ export function useRecords() {
const res = await getIssueDrawLogs(activityId, issueId)
const list = (res && res.list) || (Array.isArray(res) ? res : [])
// 聚合同一奖品的记录
const aggregate = {}
list.forEach(it => {
const key = it.reward_id || it.id
if (!aggregate[key]) {
aggregate[key] = {
id: key,
title: it.reward_name || it.title || it.name || '-',
image: it.reward_image || it.image || '',
count: 0
}
}
aggregate[key].count += 1
})
// 直接使用原始记录列表,不进行聚合
// 映射字段以符合 RecordsList 组件的展示需求
winRecords.value = list.map(it => ({
id: it.id,
title: it.reward_name || it.title || it.name || '-', // 奖品名称
image: it.reward_image || it.image || '', // 奖品图片
count: 1, // 单个记录数量为1
// const total = list.length || 1
winRecords.value = Object.values(aggregate).map(it => ({
...it,
// percent: ((it.count / total) * 100).toFixed(1)
// 用户信息
user_id: it.user_id,
user_name: it.user_name || '匿名用户',
avatar: it.avatar,
// 时间信息
created_at: it.created_at,
// 其他元数据
is_winner: it.is_winner,
level: it.level,
level_name: getLevelName(it.level)
}))
} catch (e) {
console.error('fetchWinRecords error', e)
@ -52,6 +53,13 @@ export function useRecords() {
}
}
function getLevelName(level) {
if (!level) return ''
// 尝试映射 1->A, 2->B ...
const map = { 1: 'A', 2: 'B', 3: 'C', 4: 'D', 5: 'E', 6: 'F', 7: 'G', 8: 'H', 9: 'I', 10: 'J' }
return map[level] || (level + '赏')
}
function clearRecords() {
winRecords.value = []
}

View File

@ -90,6 +90,12 @@
"navigationBarTitleText": "编辑地址"
}
},
{
"path": "pages/address/submit",
"style": {
"navigationBarTitleText": "填写收货信息"
}
},
{
"path": "pages/help/index",
"style": {

View File

@ -30,7 +30,7 @@
<template #footer>
<!-- 底部多档位抽赏按钮 -->
<view class="bottom-actions">
<view class="bottom-actions" v-if="!isDevMode">
<view class="tier-btn" @tap="openPayment(1)">
<text class="tier-price">¥{{ (pricePerDraw * 1).toFixed(2) }}</text>
<text class="tier-label">抽1发</text>
@ -48,6 +48,30 @@
<text class="tier-label">抽10发</text>
</view>
</view>
<!-- 开发者模式 -->
<view class="bottom-actions dev-actions" v-else>
<view class="dev-input-wrapper">
<text class="dev-label">自定义发数:</text>
<input
class="dev-input"
type="number"
v-model="customDrawCount"
placeholder="输入数量"
/>
</view>
<view class="tier-btn tier-hot" @tap="openPayment(customDrawCount)">
<text class="tier-price">Go</text>
<text class="tier-label">{{ customDrawCount }}</text>
</view>
<view class="tier-btn" @tap="isDevMode = false">
<text class="tier-price">Exit</text>
<text class="tier-label">退出</text>
</view>
</view>
<!-- Dev Toggle Button -->
<view class="dev-fab" @tap="isDevMode = !isDevMode">Dev</view>
</template>
<template #modals>
@ -141,6 +165,9 @@ const showResultPopup = ref(false)
const drawResults = ref([])
const drawLoading = ref(false)
const isDevMode = ref(false)
const customDrawCount = ref(1)
//
const paymentVisible = ref(false)
const paymentAmount = ref('0.00')
@ -517,4 +544,61 @@ watch(currentIssueId, (newId) => {
border: none;
}
}
.dev-actions {
display: flex;
align-items: center;
gap: 20rpx;
}
.dev-input-wrapper {
flex: 2;
display: flex;
flex-direction: column;
justify-content: center;
background: #f5f5f5;
border-radius: 20rpx;
padding: 10rpx 20rpx;
height: 100%;
}
.dev-label {
font-size: 20rpx;
color: #666;
margin-bottom: 4rpx;
}
.dev-input {
font-size: 32rpx;
font-weight: bold;
color: #333;
color: #333;
height: 40rpx;
}
.dev-fab {
position: fixed;
right: 32rpx;
bottom: 280rpx;
width: 80rpx;
height: 80rpx;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(10rpx);
color: #fff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 24rpx;
font-weight: bold;
z-index: 990;
box-shadow: 0 4rpx 12rpx rgba(0,0,0,0.15);
border: 1rpx solid rgba(255,255,255,0.2);
transition: all 0.3s;
&:active {
transform: scale(0.9);
background: rgba(0, 0, 0, 0.7);
}
}
</style>

210
pages/address/submit.vue Normal file
View File

@ -0,0 +1,210 @@
<template>
<view class="container">
<view class="header glass-card">
<view class="title">填写收货信息</view>
<view class="desc">好友正在为您申请奖品发货请填写您的准确收货地址</view>
</view>
<view class="form glass-card">
<view class="form-item">
<text class="label">收货人</text>
<input v-model="form.name" placeholder="请输入姓名" class="input" />
</view>
<view class="form-item">
<text class="label">手机号码</text>
<input v-model="form.mobile" type="number" maxlength="11" placeholder="请输入手机号" class="input" />
</view>
<view class="form-item">
<text class="label">地区</text>
<picker mode="region" @change="onRegionChange" class="input">
<view class="picker-value" v-if="form.province">
{{ form.province }} {{ form.city }} {{ form.district }}
</view>
<view class="picker-placeholder" v-else>请选择省市区</view>
</picker>
</view>
<view class="form-item">
<text class="label">详细地址</text>
<textarea v-model="form.address" placeholder="街道、楼牌号等" class="textarea" />
</view>
</view>
<view class="footer-btn">
<button class="submit-btn" :loading="loading" @tap="onSubmit">确认提交</button>
</view>
<view class="tip-section">
<text class="tip-text">* 请确保信息准确提交后无法修改</text>
<text class="tip-text" v-if="isLoggedIn">* 您已登录提交后该奖品将转移至您的账户下</text>
<text class="tip-text" v-else>* 您当前未登录提交后资产仍归属于原发起人</text>
</view>
</view>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { request } from '@/utils/request'
const token = ref('')
const loading = ref(false)
const isLoggedIn = ref(!!uni.getStorageSync('token'))
const form = reactive({
name: '',
mobile: '',
province: '',
city: '',
district: '',
address: ''
})
onLoad((options) => {
if (options.token) {
token.value = options.token
} else {
uni.showToast({ title: '参数错误', icon: 'none' })
}
})
function onRegionChange(e) {
const [p, c, d] = e.detail.value
form.province = p
form.city = c
form.district = d
}
async function onSubmit() {
if (!token.value) return
if (!form.name || !form.mobile || !form.province || !form.address) {
uni.showToast({ title: '请完善收货信息', icon: 'none' })
return
}
if (!/^1\d{10}$/.test(form.mobile)) {
uni.showToast({ title: '手机号格式错误', icon: 'none' })
return
}
loading.value = true
try {
const res = await request({
url: '/api/app/address-share/submit',
method: 'POST',
data: {
share_token: token.value,
...form
},
// token request
header: {
'Authorization': uni.getStorageSync('token') || ''
}
})
uni.showModal({
title: '提交成功',
content: '收货信息已提交,请等待发货。' + (isLoggedIn.value ? '资产已转移至您的盒柜。' : ''),
showCancel: false,
success: () => {
uni.switchTab({ url: '/pages/index/index' })
}
})
} catch (e) {
uni.showToast({ title: e.message || '提交失败', icon: 'none' })
} finally {
loading.value = false
}
}
</script>
<style lang="scss" scoped>
.container {
padding: 30rpx;
min-height: 100vh;
background: $bg-page;
}
.header {
padding: 40rpx;
margin-bottom: 30rpx;
animation: fadeInDown 0.5s ease-out;
.title {
font-size: 36rpx;
font-weight: 700;
color: $text-main;
margin-bottom: 16rpx;
}
.desc {
font-size: 26rpx;
color: $text-sub;
line-height: 1.5;
}
}
.form {
padding: 20rpx 40rpx;
animation: fadeInUp 0.5s ease-out 0.1s backwards;
}
.form-item {
padding: 30rpx 0;
border-bottom: 1rpx solid rgba(0,0,0,0.05);
&:last-child { border-bottom: none; }
.label {
display: block;
font-size: 28rpx;
color: $text-main;
margin-bottom: 20rpx;
font-weight: 600;
}
.input, .textarea {
width: 100%;
font-size: 28rpx;
color: $text-main;
}
.textarea {
height: 160rpx;
padding: 0;
}
.picker-placeholder { color: $text-tertiary; }
}
.footer-btn {
margin-top: 60rpx;
padding: 0 40rpx;
}
.submit-btn {
height: 88rpx;
background: $gradient-brand;
color: #fff;
border-radius: $radius-round;
font-size: 32rpx;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
box-shadow: $shadow-warm;
&:active { transform: scale(0.98); opacity: 0.9; }
}
.tip-section {
margin-top: 40rpx;
padding: 0 40rpx;
display: flex;
flex-direction: column;
gap: 12rpx;
}
.tip-text {
font-size: 22rpx;
color: $text-tertiary;
}
</style>

View File

@ -37,6 +37,7 @@
<text class="item-name">{{ item.name || '未命名道具' }}</text>
<text class="item-price" v-if="item.price">单价: ¥{{ item.price }}</text>
<view class="item-actions">
<text class="invite-btn" v-if="!item.selected" @tap.stop="onInvite(item)">邀请填写</text>
<text class="item-count" v-if="!item.selected">x{{ item.count || 1 }}</text>
<view class="stepper" v-else @tap.stop>
<text class="step-btn minus" @tap.stop="changeCount(item, -1)">-</text>
@ -133,17 +134,44 @@
<view v-if="loading && shippedList.length > 0" class="loading-more">加载更多...</view>
<view v-if="!hasMore && shippedList.length > 0" class="no-more">没有更多了</view>
</block>
<!-- 分享弹窗 -->
<view class="share-mask" v-if="showSharePopup" @tap="showSharePopup = false" @touchmove.stop></view>
<view class="share-popup glass-card" :class="{ 'show': showSharePopup }">
<view class="share-header">
<text class="share-title">邀请好友填写地址</text>
<text class="share-close" @tap="showSharePopup = false">×</text>
</view>
<view class="share-body">
<view class="share-item-preview">
<image class="preview-img" :src="sharingItem.image" mode="aspectFit"></image>
<view class="preview-info">
<text class="preview-name">{{ sharingItem.name }}</text>
<text class="preview-desc">邀请好友填写地址后该奖品将发货至好友手中并认领归属于分享账号</text>
</view>
</view>
<view class="share-actions">
<!-- #ifdef MP-WEIXIN -->
<button class="action-btn share-card-btn" open-type="share">发送给微信好友</button>
<!-- #endif -->
<button class="action-btn copy-link-btn" @tap="onCopyShareLink">复制分享链接</button>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, computed } from 'vue'
import { onShow, onReachBottom } from '@dcloudio/uni-app'
import { getInventory, getProductDetail, redeemInventory, requestShipping, cancelShipping, listAddresses, getShipments } from '@/api/appUser'
import { onShow, onReachBottom, onShareAppMessage } from '@dcloudio/uni-app'
import { getInventory, getProductDetail, redeemInventory, requestShipping, cancelShipping, listAddresses, getShipments, createAddressShare } from '@/api/appUser'
const currentTab = ref(0)
const aggregatedList = ref([])
const shippedList = ref([])
const showSharePopup = ref(false)
const sharingItem = ref({})
const currentShareToken = ref('')
const currentShortLink = ref('')
const loading = ref(false)
const page = ref(1)
const pageSize = ref(100)
@ -750,11 +778,73 @@ function onCancelShipping(shipment) {
} catch (e) {
uni.showToast({ title: e?.message || '取消失败', icon: 'none' })
} finally {
uni.hideLoading()
}
}
})
}
//
onShareAppMessage((res) => {
showSharePopup.value = false
return {
title: `送你一个好礼,快来填写地址领走吧!`,
path: `/pages/address/submit?token=${currentShareToken.value}`,
imageUrl: sharingItem.value.image || '/static/logo.png'
}
})
async function onInvite(item) {
uni.vibrateShort({ type: 'medium' })
const user_id = uni.getStorageSync('user_id')
if (!user_id) {
uni.navigateTo({ url: '/pages/login/index' })
return
}
// inventory id
const invId = item.original_ids && item.original_ids[0]
if (!invId) {
uni.showToast({ title: '无效的资产', icon: 'none' })
return
}
uni.showLoading({ title: '准备分享...' })
try {
const res = await createAddressShare(user_id, invId)
//
currentShareToken.value = res.data?.share_token || res.share_token
currentShortLink.value = res.data?.short_link || res.short_link || ''
//
sharingItem.value = {
id: invId,
name: item.name,
image: item.image,
count: item.count
}
//
showSharePopup.value = true
} catch (e) {
uni.showToast({ title: e.message || '生成失败', icon: 'none' })
} finally {
uni.hideLoading()
}
}
function onCopyShareLink() {
let url = currentShortLink.value
if (!url) {
url = `${window?.location?.origin || ''}/pages/address/submit?token=${currentShareToken.value}`
}
uni.setClipboardData({
data: url,
success: () => {
uni.showToast({ title: '已复制链接', icon: 'success' })
showSharePopup.value = false
}
})
}
</script>
<style lang="scss" scoped>
@ -967,9 +1057,22 @@ function onCancelShipping(shipment) {
.item-actions {
margin-top: auto;
display: flex;
justify-content: flex-end;
justify-content: space-between;
align-items: center;
}
.invite-btn {
font-size: 22rpx;
color: $brand-primary;
background: rgba($brand-primary, 0.1);
padding: 6rpx 16rpx;
border-radius: 20rpx;
font-weight: 500;
&:active {
opacity: 0.7;
background: rgba($brand-primary, 0.2);
}
}
.item-count {
font-size: 28rpx;
color: $text-main;
@ -1248,4 +1351,120 @@ function onCancelShipping(shipment) {
.bottom-spacer {
height: 120rpx;
}
.share-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 998;
backdrop-filter: blur(4rpx);
}
.share-popup {
position: fixed;
left: 0;
right: 0;
bottom: 0;
background: #fff;
border-radius: 40rpx 40rpx 0 0;
z-index: 999;
padding: 40rpx;
transform: translateY(100%);
transition: transform 0.3s cubic-bezier(0.25, 0.1, 0.25, 1);
&.show {
transform: translateY(0);
}
}
.share-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 40rpx;
.share-title {
font-size: 32rpx;
font-weight: 700;
color: $text-main;
}
.share-close {
font-size: 40rpx;
color: $text-sub;
padding: 10rpx;
}
}
.share-item-preview {
display: flex;
background: #f8f9fa;
padding: 30rpx;
border-radius: 24rpx;
margin-bottom: 50rpx;
.preview-img {
width: 120rpx;
height: 120rpx;
border-radius: 16rpx;
margin-right: 20rpx;
background: #fff;
}
.preview-info {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
.preview-name {
font-size: 28rpx;
font-weight: 600;
color: $text-main;
margin-bottom: 8rpx;
}
.preview-desc {
font-size: 20rpx;
color: $text-sub;
line-height: 1.4;
}
}
}
.share-actions {
display: flex;
flex-direction: column;
gap: 20rpx;
.action-btn {
height: 90rpx;
border-radius: 45rpx;
font-size: 28rpx;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
border: none;
&::after { border: none; }
&.share-card-btn {
background: #07c160;
color: #fff;
}
&.copy-link-btn {
background: #f0f0f0;
color: $text-main;
}
&:active {
opacity: 0.8;
}
}
}
</style>