| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695 |
- <template>
- <view class="order-page client-theme">
- <view class="order-top sq-hero-header sq-hero-header--compact sq-hero-header--client">
- <view class="order-top-content">
- <text class="order-page-title">我的订单</text>
- <scroll-view scroll-x class="order-tabs-scroll" :show-scrollbar="false" :enable-flex="true">
- <view class="order-tabs-inner">
- <view
- v-for="tab in TAB_KEYS"
- :key="tab"
- class="tab-item"
- :class="{ active: activeTab === tab }"
- hover-class="tab-item--hover"
- :hover-stay-time="100"
- @click="switchTab(tab)"
- >
- <text>
- {{
- tab === 'all'
- ? '全部'
- : tab === 'pending_payment'
- ? '待支付'
- : tab === 'pending_confirm'
- ? '待收货'
- : tab === 'completed'
- ? '已完成'
- : '已取消'
- }}
- </text>
- </view>
- </view>
- </scroll-view>
- </view>
- </view>
- <scroll-view
- class="order-list"
- scroll-y
- :enable-flex="true"
- :lower-threshold="100"
- @scrolltolower="onScrollToLower"
- >
- <ClientSkeleton
- v-if="activeTabState.loading && activeTabState.list.length === 0"
- variant="list"
- :rows="3"
- />
- <view
- v-else
- v-for="order in activeTabState.list"
- :key="order.id"
- class="order-card"
- hover-class="order-card--pressed"
- :hover-stay-time="120"
- @click="goToDetail(order.id)"
- >
- <view class="order-header">
- <view class="order-no-wrap">
- <image class="order-no-icon" :src="ICON_ORDER_NO" mode="aspectFit" />
- <text class="order-no">订单号:{{ order.orderNo }}</text>
- <SqCopyBtn class="order-no-copy" :text="order.orderNo" label="订单号" icon-only />
- </view>
- <view :class="['status-tag', 'status-' + getOrderStatusClass(order)]">
- <text>{{ getOrderStatusText(order) }}</text>
- </view>
- </view>
- <!-- 不用横向 scroll-view:H5 嵌套「纵 scroll-view + 横 scroll-view」时 uni 会写 scrollLeft,DOM 未就绪会报错 -->
- <view v-if="(order.items || []).length" class="product-scroll">
- <view class="product-scroll-inner">
- <view
- v-for="item in (order.items || [])"
- :key="item.id"
- class="product-card"
- >
- <ClientImageLoader
- class="product-image"
- :src="getItemImageUrl(item)"
- mode="aspectFill"
- radius="20rpx"
- />
- <view class="product-meta">
- <text class="product-name">{{ item.productName }}</text>
- <text class="product-qty">x{{ item.quantity }}</text>
- </view>
- </view>
- </view>
- </view>
- <view class="order-footer">
- <text class="order-time">{{ formatDate(order.createAt) }}</text>
- <view class="order-amount">
- <text class="amount-count">共 {{ getOrderTotalQuantity(order) }} 件</text>
- <text class="sum-line">
- 合计:<text class="sum-value">¥{{ formatPrice(order.totalAmount) }}</text>
- </text>
- </view>
- </view>
- </view>
- <view v-if="activeTabState.list.length === 0 && !activeTabState.loading" class="empty-state">
- <image class="empty-image" :src="IMG_EMPTY" mode="aspectFit" />
- <text class="empty-text">暂无订单</text>
- </view>
- <view v-if="activeTabState.loading && activeTabState.list.length > 0" class="loading-more">
- <text>加载中...</text>
- </view>
- <view
- v-else-if="
- activeTabState.initialLoaded &&
- activeTabState.total > 0 &&
- (activeTabState.totalPages > 0
- ? activeTabState.page >= activeTabState.totalPages
- : activeTabState.list.length >= activeTabState.total)
- "
- class="nomore"
- >
- <text>没有更多了</text>
- </view>
- </scroll-view>
- <!-- #ifdef H5 -->
- <ClientTabBar active="order" />
- <!-- #endif -->
- </view>
- </template>
- <script setup>
- import { computed, reactive, ref } from 'vue'
- import { onShow } from '@dcloudio/uni-app'
- import { useUserStore } from '@/stores/user'
- import { TOKEN_KEY } from '@/config/api'
- import { request, parseResponseJson } from '@/utils/request'
- import { formatYuan } from '@/utils/format'
- import { formatDateTime } from '@/utils/date'
- import { getClientOrderUnifiedStatus } from '@/utils/client-order-status'
- import { IMG_DEFAULT_PRODUCT, IMG_EMPTY, staticAssetUrl } from '@/utils/staticAsset'
- import ClientTabBar from '@/components/client/client-tab-bar.vue'
- import ClientSkeleton from '@/components/client/client-skeleton.vue'
- import ClientImageLoader from '@/components/client/client-image-loader.vue'
- import SqCopyBtn from '@/components/sq-copy-btn.vue'
- const userStore = useUserStore()
- const ICON_ORDER_NO = staticAssetUrl('dingdanxinxi.svg')
- const TAB_KEYS = ['all', 'pending_payment', 'pending_confirm', 'completed', 'cancelled']
- const PAGE_SIZE = 10
- function emptyTabState() {
- return {
- list: [],
- page: 0,
- total: 0,
- totalPages: 0,
- loading: false,
- initialLoaded: false,
- needsRefresh: true,
- }
- }
- const tabStates = reactive({
- all: emptyTabState(),
- pending_payment: emptyTabState(),
- pending_confirm: emptyTabState(),
- completed: emptyTabState(),
- cancelled: emptyTabState(),
- })
- const activeTab = ref('all')
- const activeTabState = computed(() => tabStates[activeTab.value])
- function getUnifiedStatus(order) {
- return getClientOrderUnifiedStatus({
- cancelStatus: order.cancelStatus,
- orderStatus: order.orderStatus,
- deliveryStatus: order.deliveryStatus,
- payStatus: order.payStatus,
- status: order.status
- })
- }
- async function fetchTabPage(tab, reset) {
- const state = tabStates[tab]
- if (state.loading) return
- if (!reset) {
- if (!state.initialLoaded) return
- const tp = state.totalPages || (state.total > 0 ? Math.ceil(state.total / PAGE_SIZE) : 0)
- if (tp > 0 && state.page >= tp) return
- if (state.list.length >= state.total) return
- }
- if (reset) {
- state.list = []
- state.page = 0
- state.total = 0
- state.totalPages = 0
- state.initialLoaded = false
- }
- state.loading = true
- const nextPage = reset ? 1 : state.page + 1
- try {
- const token = uni.getStorageSync(TOKEN_KEY)
- if (!token) return
- const data = {
- page: nextPage,
- pageSize: PAGE_SIZE,
- }
- if (tab !== 'all') {
- data.listTab = tab
- data.orderStatus = tab
- }
- const res = await request({
- url: '/mini/client/orders',
- method: 'GET',
- data
- })
- const parsed = parseResponseJson(res)
- if (parsed.code !== 200) return
- const body = parsed.data || {}
- const list = body.list || []
- const total = Number(body?.total ?? 0)
- const totalPages = Number(body?.totalPages ?? 0)
- if (reset) {
- state.list = list
- } else {
- const ids = new Set(state.list.map((o) => o.id))
- for (const o of list) {
- if (o.id && !ids.has(o.id)) {
- state.list.push(o)
- ids.add(o.id)
- }
- }
- }
- state.page = nextPage
- state.total = total
- state.totalPages = totalPages
- state.initialLoaded = true
- state.needsRefresh = false
- } catch (error) {
- console.error('获取订单失败:', error)
- } finally {
- state.loading = false
- }
- }
- function onScrollToLower() {
- const tab = activeTab.value
- const s = tabStates[tab]
- if (!s.initialLoaded || s.loading) return
- const tp = s.totalPages || (s.total > 0 ? Math.ceil(s.total / PAGE_SIZE) : 0)
- if (tp > 0 && s.page >= tp) return
- if (s.list.length >= s.total) return
- void fetchTabPage(tab, false)
- }
- function switchTab(tab) {
- activeTab.value = tab
- if (!tabStates[tab].initialLoaded || tabStates[tab].needsRefresh) {
- void fetchTabPage(tab, true)
- }
- }
- function formatPrice(price) {
- return formatYuan(price)
- }
- function formatDate(dateStr) {
- return formatDateTime(dateStr)
- }
- function getOrderTotalQuantity(order) {
- if (typeof order.totalQuantity === 'number') return order.totalQuantity
- const items = order.items
- if (Array.isArray(items) && items.length) {
- const sum = items.reduce((acc, it) => acc + (Number(it.quantity) || 0), 0)
- return sum || items.length
- }
- if (typeof order.itemCount === 'number') return order.itemCount
- return 0
- }
- function getItemImageUrl(item) {
- return item?.imageUrl || IMG_DEFAULT_PRODUCT
- }
- function getStatusText(status) {
- const statusMap = {
- pending_payment: '待支付',
- pending_review_cancel: '待审核取消',
- pending_delivery: '待分配配送',
- delivering: '配送中',
- partial_delivered: '部分送达',
- pending_confirm: '待确认收货',
- completed: '已完成',
- cancelled: '已取消',
- refunding: '退款中',
- }
- return statusMap[status] || status
- }
- function getStatusClass(status) {
- const classMap = {
- pending_payment: 'warning',
- pending_review_cancel: 'warning',
- pending_delivery: 'primary',
- delivering: 'primary',
- partial_delivered: 'warning',
- pending_confirm: 'primary',
- completed: 'success',
- cancelled: 'info',
- }
- return classMap[status] || 'info'
- }
- function getOrderStatusText(order) {
- return getStatusText(getUnifiedStatus(order))
- }
- function getOrderStatusClass(order) {
- return getStatusClass(getUnifiedStatus(order))
- }
- function goToDetail(orderId) {
- uni.navigateTo({ url: `/pages/client/order/detail/detail?id=${orderId}` })
- }
- function ensureClientAndLoad() {
- userStore.checkLoginStatus()
- if (!userStore.isLoggedIn) {
- uni.reLaunch({ url: '/pages/login/login' })
- return false
- }
- if (!userStore.isClient) {
- uni.showToast({ title: '请使用客户账号登录', icon: 'none' })
- setTimeout(() => userStore.navigateToHome(), 1500)
- return false
- }
- return true
- }
- // 首次进入、从详情/支付返回时刷新当前 Tab(避免与 onMounted 重复请求)
- onShow(() => {
- if (!ensureClientAndLoad()) return
- TAB_KEYS.forEach((k) => {
- tabStates[k].needsRefresh = true
- })
- void fetchTabPage(activeTab.value, true)
- })
- </script>
- <style lang="scss" scoped>
- .order-page {
- height: 100%;
- min-height: 0;
- display: flex;
- flex-direction: column;
- background: var(--c-bg);
- overflow: hidden;
- box-sizing: border-box;
- }
- .order-top {
- flex-shrink: 0;
- overflow: hidden;
- }
- .order-top-content {
- padding: 8rpx 0 8rpx 0;
- }
- .order-page-title {
- display: block;
- font-size: 36rpx;
- font-weight: 700;
- color: #ffffff;
- margin-bottom: 20rpx;
- line-height: 1.25;
- }
- .order-tabs-scroll {
- width: 100%;
- white-space: nowrap;
- }
- .order-tabs-inner {
- display: inline-flex;
- align-items: center;
- gap: 16rpx;
- padding: 4rpx 0 2rpx;
- box-sizing: border-box;
- }
- .tab-item {
- flex-shrink: 0;
- min-height: 68rpx;
- padding: 0 24rpx;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- font-size: 24rpx;
- color: rgba(255, 255, 255, 0.95);
- border: 1rpx solid rgba(255, 255, 255, 0.35);
- border-radius: 34rpx;
- box-sizing: border-box;
- transition: opacity 0.15s ease;
- &.active {
- background: #ffffff;
- color: var(--c-brand-green);
- font-weight: 600;
- border-color: #ffffff;
- }
- }
- .tab-item--hover {
- opacity: 0.88;
- }
- .order-list {
- flex: 1;
- flex-basis: 0;
- min-height: 0;
- height: 0;
- padding: 24rpx 32rpx 32rpx;
- width: 100%;
- box-sizing: border-box;
- overflow-x: hidden;
- }
- .order-card {
- background: var(--c-card);
- border-radius: 28rpx;
- padding: 24rpx;
- margin-bottom: 20rpx;
- border: 1rpx solid var(--c-border);
- box-shadow: none;
- width: 100%;
- max-width: 100%;
- box-sizing: border-box;
- transition: opacity 0.15s ease;
- .order-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 16rpx;
- .order-no-wrap {
- flex: 0 1 auto;
- min-width: 0;
- display: flex;
- align-items: center;
- gap: 4rpx;
- margin-right: 16rpx;
- max-width: calc(100% - 170rpx);
- }
- .order-no {
- flex: 0 1 auto;
- min-width: 0;
- font-size: 26rpx;
- font-weight: 600;
- line-height: 1.2;
- color: var(--c-text);
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- width: auto;
- max-width: 100%;
- }
- .order-no-icon {
- width: 32rpx;
- height: 32rpx;
- flex: 0 0 auto;
- }
- .order-no-copy {
- flex: 0 0 auto;
- display: inline-flex;
- align-items: center;
- align-self: center;
- margin-left: 10rpx;
- background: transparent !important;
- border: none !important;
- box-shadow: none !important;
- padding: 0 !important;
- width: 32rpx !important;
- height: 32rpx !important;
- min-width: 32rpx !important;
- min-height: 32rpx !important;
- border-radius: 0 !important;
- justify-content: center;
- }
- /* 覆盖 SqCopyBtn 组件 icon-only 默认尺寸 */
- .order-no-copy :deep(.sq-copy-btn__icon) {
- width: 22rpx !important;
- height: 22rpx !important;
- }
- .status-tag {
- padding: 0 20rpx;
- min-height: 48rpx;
- border-radius: 24rpx;
- font-size: 22rpx;
- display: inline-flex;
- align-items: center;
- &.status-primary {
- background: #fff7e2;
- color: #b7791f;
- }
- &.status-warning {
- background: #fffbeb;
- color: #d97706;
- }
- &.status-success {
- background: #ecfdf5;
- color: #059669;
- }
- &.status-info {
- background: #f1f5f9;
- color: #64748b;
- }
- }
- }
- .product-scroll {
- margin-top: 0;
- padding-bottom: 16rpx;
- width: 100%;
- overflow-x: auto;
- overflow-y: hidden;
- -webkit-overflow-scrolling: touch;
- .product-scroll-inner {
- display: flex;
- flex-direction: row;
- flex-wrap: nowrap;
- gap: 16rpx;
- padding: 0 2rpx;
- width: max-content;
- max-width: none;
- }
- .product-card {
- width: 132rpx;
- height: 132rpx;
- border-radius: 20rpx;
- overflow: hidden;
- background: #f8fcfa;
- border: 1rpx solid rgba(220, 235, 228, 0.65);
- flex: 0 0 auto;
- position: relative;
- }
- .product-image {
- width: 100%;
- height: 100%;
- background: linear-gradient(145deg, #f4fbf8, #e4f6ef);
- display: block;
- }
- .product-meta {
- display: flex;
- align-items: center;
- padding: 8rpx 10rpx;
- gap: 6rpx;
- position: absolute;
- left: 0;
- right: 0;
- bottom: 0;
- background: linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0.35) 100%);
- }
- .product-name {
- flex: 1;
- min-width: 0;
- font-size: 24rpx;
- color: #ffffff;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- .product-qty {
- font-size: 22rpx;
- color: rgba(255, 255, 255, 0.92);
- flex: 0 0 auto;
- }
- }
- .order-footer {
- display: flex;
- justify-content: space-between;
- align-items: flex-end;
- margin-top: 0;
- padding-top: 16rpx;
- border-top: 1rpx solid #edf5f1;
- .order-time {
- font-size: 22rpx;
- color: var(--c-muted);
- line-height: 1.45;
- max-width: 62%;
- }
- .order-amount {
- display: flex;
- flex-direction: column;
- align-items: flex-end;
- gap: 4rpx;
- .amount-count {
- font-size: 22rpx;
- color: var(--c-muted);
- }
- .sum-line {
- font-size: 24rpx;
- color: var(--c-muted);
- line-height: 1.4;
- }
- .sum-value {
- color: var(--c-danger);
- font-weight: 700;
- font-size: 26rpx;
- }
- }
- }
- }
- .order-card--pressed {
- opacity: 0.94;
- }
- .empty-state {
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- width: 100%;
- padding: 72rpx 0;
- .empty-image {
- width: 160rpx;
- height: 160rpx;
- margin-bottom: 12rpx;
- }
- .empty-text {
- width: 100%;
- display: block;
- text-align: center;
- font-size: 28rpx;
- color: #909399;
- }
- }
- .loading-state,
- .loading-more,
- .nomore {
- display: flex;
- align-items: center;
- justify-content: center;
- padding: 32rpx 0;
- color: #909399;
- font-size: 26rpx;
- }
- </style>
- <style lang="scss">
- /* 与 disableScroll 配合:本页根节点占满导航栏以下区域,内部仅 scroll-view 纵向滚动 */
- page {
- height: 100%;
- }
- </style>
|