feat(订单): 实现订单详情页功能

添加订单详情页路由配置
开发订单详情页UI及交互逻辑
对接订单详情和取消订单API
更新文档记录开发进度
优化订单状态显示逻辑
This commit is contained in:
邹方成 2025-12-18 15:06:32 +08:00
parent 09ca0c252d
commit d930756130
5 changed files with 662 additions and 1 deletions

View File

@ -21,6 +21,7 @@
<view class="form-item">
<text class="label">优惠券</text>
<picker
class="picker-full"
mode="selector"
:range="coupons"
range-key="name"
@ -40,6 +41,7 @@
<view class="form-item" v-if="showCards">
<text class="label">道具卡</text>
<picker
class="picker-full"
mode="selector"
:range="propCards"
range-key="name"
@ -259,6 +261,11 @@ function handleConfirm() {
margin-bottom: $spacing-xs;
}
.picker-full {
width: 100%;
display: block;
}
.picker-display {
border: 2rpx solid $border-color-light;
border-radius: $radius-md;

View File

@ -48,6 +48,12 @@
"navigationBarTitleText": "我的订单"
}
},
{
"path": "pages/orders/detail",
"style": {
"navigationBarTitleText": "订单详情"
}
},
{
"path": "pages/address/index",
"style": {

645
pages/orders/detail.vue Normal file
View File

@ -0,0 +1,645 @@
<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 } 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() {
uni.showToast({ title: '支付功能开发中', 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>

View File

@ -247,7 +247,8 @@ function switchTab(tab) {
}
function apiStatus() {
return currentTab.value === 'pending' ? 'pending' : 'completed'
// 1: , 2:
return currentTab.value === 'pending' ? 1 : 2
}
// source_type=3

View File

@ -31,5 +31,7 @@
* [x] 2025-12-17: 修复 `pages/login/index.vue` 等多处 `$border-color` 未定义错误,在 `uni.scss` 中增加变量别名。
* [x] 2025-12-17: 修复 `pages/mine/index.vue` 编译错误,在 `api/appUser.js` 中补充 `getUserInfo`, `getUserTasks`, `getInviteRecords` 导出。
* [x] 2025-12-17: 将 dev 分支代码强制推送至 main 分支 (Deployment/Sync)。
* [x] 2025-12-18: 实现订单详情 API 与取消订单 API (后端接口对接)。
* [x] 2025-12-18: 开发订单详情页 UI 及交互逻辑。
* [ ] 2025-12-17: 进行中 - 优化 `pages/activity/yifanshang/index.vue` 及相关组件。
* [ ] 2025-12-17: 待开始 - 优化 `pages/login/index.vue` 视觉细节。