2025-12-21 14:38:42 +08:00

679 lines
16 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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="page-container">
<!-- 加载状态 -->
<view v-if="loading" class="loading-state">
<view class="loading-spinner"></view>
<text class="loading-text">加载中...</text>
</view>
<!-- 错误状态 -->
<view v-else-if="error" class="error-state">
<view class="error-icon"></view>
<text class="error-text">{{ error }}</text>
<button class="retry-btn" @tap="loadOrder">重试</button>
</view>
<!-- 订单内容 -->
<view v-else-if="order" class="content">
<!-- 状态头部背景 -->
<view class="status-header-bg" :class="getStatusClass(order)">
<view class="bg-circle c1"></view>
<view class="bg-circle c2"></view>
</view>
<!-- 状态卡片 -->
<view class="status-card">
<view class="status-content">
<view class="status-icon-wrap">
<text class="status-icon">{{ getStatusIcon(order) }}</text>
</view>
<view class="status-info">
<text class="status-title">{{ statusText(order) }}</text>
<text class="status-desc" v-if="order.status === 1">请在 15 分钟内完成支付</text>
<text class="status-desc" v-else-if="order.status === 3">订单已取消</text>
<text class="status-desc" v-else>感谢您的购买期待再次光临</text>
</view>
</view>
</view>
<!-- 奖品/商品列表 -->
<view class="section-card product-section">
<view class="section-header">
<text class="section-title">商品清单</text>
<text class="item-count"> {{ order.items ? order.items.length : 0 }} </text>
</view>
<view class="order-items">
<view v-for="(item, index) in order.items" :key="index" class="item-card">
<view class="item-image-wrap">
<image class="item-image" :src="getProductImage(item)" mode="aspectFill" />
<!-- 购买标识 -->
<view class="winner-tag" v-if="order.is_winner && order.source_type === 2">
<text class="tag-text">已开启</text>
</view>
<view class="level-tag" v-if="order.reward_level">
<text class="tag-text">{{ order.reward_level }}</text>
</view>
</view>
<view class="item-info">
<text class="item-title">{{ item.title || '商品' }}</text>
<view class="item-tags" v-if="order.activity_name">
<text class="tag">{{ order.activity_name }}</text>
</view>
<view class="item-meta">
<view class="price-wrap">
<text class="currency">¥</text>
<text class="price">{{ formatPrice(item.price) }}</text>
</view>
<text class="item-quantity">x{{ item.quantity }}</text>
</view>
</view>
</view>
</view>
</view>
<!-- 订单信息 -->
<view class="section-card info-section">
<view class="info-row">
<text class="label">订单编号</text>
<view class="value-wrap">
<text class="value mono">{{ order.order_no }}</text>
<view class="copy-btn" @tap="copyText(order.order_no)">复制</view>
</view>
</view>
<view class="info-row">
<text class="label">下单时间</text>
<text class="value">{{ formatTime(order.created_at) }}</text>
</view>
<view class="info-row" v-if="order.paid_at">
<text class="label">支付时间</text>
<text class="value">{{ formatTime(order.paid_at) }}</text>
</view>
<view class="info-row" v-if="order.cancelled_at">
<text class="label">取消时间</text>
<text class="value">{{ formatTime(order.cancelled_at) }}</text>
</view>
<view class="info-row">
<text class="label">订单来源</text>
<text class="value">{{ getSourceTypeText(order.source_type) }}</text>
</view>
</view>
<!-- 金额明细 -->
<view class="section-card amount-section">
<view class="info-row">
<text class="label">商品总额</text>
<text class="value">¥{{ formatPrice(order.total_amount) }}</text>
</view>
<view class="info-row" v-if="order.discount_amount">
<text class="label">优惠金额</text>
<text class="value discount">-¥{{ formatPrice(order.discount_amount) }}</text>
</view>
<view class="divider"></view>
<view class="total-row">
<text class="total-label">实付款</text>
<view class="total-price-wrap">
<text class="currency">¥</text>
<text class="total-price">{{ formatPrice(order.actual_amount) }}</text>
</view>
</view>
</view>
</view>
<!-- 底部操作栏 -->
<view class="footer-actions safe-area-bottom" v-if="order && order.status === 1">
<view class="action-btn secondary" @tap="handleCancel">取消订单</view>
<view class="action-btn primary" @tap="handlePay">立即支付</view>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { getOrderDetail, cancelOrder, createWechatOrder } from '../../api/appUser'
const orderId = ref('')
const order = ref(null)
const loading = ref(true)
const error = ref('')
const defaultImage = 'https://keaiya-1259195914.cos.ap-shanghai.myqcloud.com/images/default-product.png'
onLoad((options) => {
if (options.id) {
orderId.value = options.id
loadOrder()
} else {
error.value = '参数错误'
loading.value = false
}
})
async function loadOrder() {
loading.value = true
error.value = ''
try {
const res = await getOrderDetail(orderId.value)
order.value = res
} catch (e) {
error.value = e.message || '获取订单详情失败'
} finally {
loading.value = false
}
}
function handleCancel() {
uni.showModal({
title: '确认取消',
content: '确定要取消这个订单吗?',
confirmColor: '#FF6B00',
success: async (res) => {
if (res.confirm) {
uni.showLoading({ title: '取消中...' })
try {
await cancelOrder(orderId.value, '用户主动取消')
uni.hideLoading()
uni.showToast({ title: '订单已取消', icon: 'success' })
loadOrder()
} catch (e) {
uni.hideLoading()
uni.showToast({ title: e.message || '取消失败', icon: 'none' })
}
}
}
})
}
function handlePay() {
const openid = uni.getStorageSync('openid')
if (!openid) {
uni.showToast({ title: '缺少OpenID请重新登录', icon: 'none' })
return
}
const ord = order.value
if (!ord || !ord.order_no) return
uni.showLoading({ title: '拉起支付...' })
createWechatOrder({ openid, order_no: ord.order_no })
.then((payRes) => new Promise((resolve, reject) => {
uni.requestPayment({
provider: 'wxpay',
timeStamp: payRes.timeStamp || payRes.timestamp,
nonceStr: payRes.nonceStr || payRes.noncestr,
package: payRes.package,
signType: payRes.signType || 'MD5',
paySign: payRes.paySign,
success: resolve,
fail: reject
})
}))
.then(async () => {
uni.hideLoading()
uni.showToast({ title: '支付成功', icon: 'success' })
await loadOrder()
})
.catch((e) => {
uni.hideLoading()
if (e?.errMsg && String(e.errMsg).includes('cancel')) {
uni.showToast({ title: '支付已取消', icon: 'none' })
return
}
uni.showToast({ title: e?.message || '支付失败', icon: 'none' })
})
}
function copyText(text) {
if (!text) return
uni.setClipboardData({
data: String(text),
success: () => {
uni.showToast({ title: '已复制', icon: 'none' })
}
})
}
function formatPrice(price) {
if (price === undefined || price === null) return '0.00'
return (Number(price) / 100).toFixed(2)
}
function formatTime(t) {
if (!t) return ''
const date = new Date(t)
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
const hh = String(date.getHours()).padStart(2, '0')
const mm = String(date.getMinutes()).padStart(2, '0')
const ss = String(date.getSeconds()).padStart(2, '0')
return `${y}-${m}-${d} ${hh}:${mm}:${ss}`
}
function getProductImage(item) {
if (item.product_images) {
try {
const parsed = JSON.parse(item.product_images)
if (Array.isArray(parsed) && parsed.length > 0) {
return parsed[0]
}
} catch (e) {
if (typeof item.product_images === 'string' && item.product_images.startsWith('http')) {
return item.product_images
}
}
}
return defaultImage
}
function statusText(item) {
const status = item.status
if (status === 1) return '待付款'
if (status === 2) return '已完成'
if (status === 3) return '已取消'
return '进行中'
}
function getStatusClass(item) {
const status = item.status
if (status === 1) return 'status-pending'
if (status === 2) return 'status-completed'
if (status === 3) return 'status-cancelled'
return ''
}
function getStatusIcon(item) {
const status = item.status
if (status === 1) return '🕒'
if (status === 2) return '🎉'
if (status === 3) return '🚫'
return '📦'
}
function getSourceTypeText(type) {
if (type === 1) return '商城订单'
if (type === 2) return '一番赏'
if (type === 3) return '发奖记录'
return '其他'
}
</script>
<style lang="scss" scoped>
.page-container {
min-height: 100vh;
background: $bg-page;
padding-bottom: calc(140rpx + env(safe-area-inset-bottom));
position: relative;
}
/* 状态头部背景 */
.status-header-bg {
height: 360rpx;
position: relative;
overflow: hidden;
border-radius: 0 0 40rpx 40rpx;
&.status-pending { background: linear-gradient(135deg, #FF9F43 0%, #FF6B6B 100%); }
&.status-completed { background: linear-gradient(135deg, #2ECC71 0%, #27AE60 100%); }
&.status-cancelled { background: linear-gradient(135deg, #95A5A6 0%, #7F8C8D 100%); }
.bg-circle {
position: absolute;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
&.c1 { width: 300rpx; height: 300rpx; top: -100rpx; right: -50rpx; }
&.c2 { width: 200rpx; height: 200rpx; bottom: 50rpx; left: -50rpx; }
}
}
/* 状态卡片 */
.status-card {
margin: -160rpx $spacing-lg 0;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-radius: $radius-xl;
padding: $spacing-xl;
box-shadow: $shadow-card;
position: relative;
z-index: 10;
.status-content {
display: flex;
align-items: center;
gap: $spacing-lg;
}
.status-icon-wrap {
width: 88rpx;
height: 88rpx;
background: $bg-secondary;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.status-icon {
font-size: 44rpx;
}
.status-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 4rpx;
}
.status-title {
font-size: 36rpx;
font-weight: 800;
color: $text-main;
}
.status-desc {
font-size: $font-sm;
color: $text-sub;
}
}
/* 通用卡片样式 */
.section-card {
margin: $spacing-lg;
background: $bg-card;
border-radius: $radius-lg;
padding: $spacing-lg;
box-shadow: $shadow-sm;
animation: slideUp 0.4s ease-out;
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(20rpx); }
to { opacity: 1; transform: translateY(0); }
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: $spacing-lg;
padding-bottom: $spacing-sm;
border-bottom: 2rpx dashed $border-color-light;
}
.section-title {
font-size: 30rpx;
font-weight: 700;
color: $text-main;
position: relative;
padding-left: 20rpx;
&::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 6rpx;
height: 24rpx;
background: $brand-primary;
border-radius: 4rpx;
}
}
.item-count {
font-size: $font-sm;
color: $text-sub;
}
/* 商品列表 */
.item-card {
display: flex;
gap: $spacing-md;
margin-bottom: $spacing-lg;
&:last-child { margin-bottom: 0; }
}
.item-image-wrap {
position: relative;
width: 160rpx;
height: 160rpx;
border-radius: $radius-md;
overflow: hidden;
background: $bg-secondary;
}
.item-image {
width: 100%;
height: 100%;
}
.winner-tag {
position: absolute;
top: 0;
left: 0;
background: $gradient-gold;
padding: 4rpx 12rpx;
border-radius: 0 0 $radius-md 0;
z-index: 1;
.tag-text {
color: #fff;
font-size: 18rpx;
font-weight: 700;
}
}
.level-tag {
position: absolute;
bottom: 0;
right: 0;
background: rgba(0, 0, 0, 0.6);
padding: 2rpx 10rpx;
border-radius: $radius-sm 0 0 0;
.tag-text {
color: #fff;
font-size: 18rpx;
font-weight: 600;
}
}
.item-info {
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 4rpx 0;
}
.item-title {
font-size: 28rpx;
color: $text-main;
font-weight: 600;
line-height: 1.4;
@include text-ellipsis(2);
}
.item-tags {
margin-top: 8rpx;
display: flex;
.tag {
font-size: 20rpx;
color: $brand-primary;
background: rgba($brand-primary, 0.08);
padding: 2rpx 10rpx;
border-radius: 6rpx;
}
}
.item-meta {
display: flex;
justify-content: space-between;
align-items: flex-end;
margin-top: auto;
}
.price-wrap {
display: flex;
align-items: baseline;
color: $text-main;
.currency { font-size: 24rpx; font-weight: 600; }
.price { font-size: 32rpx; font-weight: 700; font-family: 'DIN Alternate', sans-serif; }
}
.item-quantity {
font-size: 24rpx;
color: $text-sub;
}
/* 信息行 */
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12rpx 0;
.label {
font-size: 26rpx;
color: $text-sub;
}
.value {
font-size: 26rpx;
color: $text-main;
&.mono { font-family: monospace; }
&.discount { color: $uni-color-error; font-weight: 600; }
}
.value-wrap {
display: flex;
align-items: center;
gap: 12rpx;
}
.copy-btn {
font-size: 20rpx;
color: $text-sub;
border: 1rpx solid $border-color;
padding: 2rpx 12rpx;
border-radius: 20rpx;
&:active {
opacity: 0.6;
background: $bg-secondary;
}
}
}
.divider {
height: 1rpx;
background: $border-color-light;
margin: 20rpx 0;
}
.total-row {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 16rpx;
padding-top: 10rpx;
.total-label {
font-size: 28rpx;
font-weight: 600;
color: $text-main;
}
.total-price-wrap {
color: $brand-primary;
display: flex;
align-items: baseline;
.currency { font-size: 28rpx; font-weight: 600; }
.total-price { font-size: 40rpx; font-weight: 800; font-family: 'DIN Alternate', sans-serif; }
}
}
/* 底部操作栏 */
.footer-actions {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20rpx);
padding: 24rpx 32rpx;
padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
display: flex;
justify-content: flex-end;
gap: 24rpx;
box-shadow: 0 -4rpx 24rpx rgba(0, 0, 0, 0.06);
z-index: 100;
}
.action-btn {
height: 80rpx;
display: flex;
align-items: center;
justify-content: center;
padding: 0 48rpx;
border-radius: 40rpx;
font-size: 28rpx;
font-weight: 600;
transition: all 0.2s;
&:active { transform: scale(0.96); }
&.secondary {
background: #fff;
color: $text-main;
border: 2rpx solid $border-color;
}
&.primary {
background: $gradient-brand;
color: #fff;
box-shadow: $shadow-warm;
}
}
/* Loading & Error */
.loading-state, .error-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 80vh;
.loading-text, .error-text {
margin-top: 24rpx;
color: $text-sub;
font-size: 28rpx;
}
}
.loading-spinner {
width: 64rpx;
height: 64rpx;
border: 6rpx solid rgba($brand-primary, 0.2);
border-top-color: $brand-primary;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.retry-btn {
margin-top: 32rpx;
background: $brand-primary;
color: #fff;
font-size: 28rpx;
padding: 12rpx 48rpx;
border-radius: 32rpx;
}
</style>