Browse Source

feat: 重构云商城用户端界面

现代化商品列表、详情侧滑下单、订单列表与详情;含优惠码结算与用户卡片样式。

Co-authored-by: Cursor <cursoragent@cursor.com>
Pchen. 3 weeks ago
parent
commit
44a2789997

+ 130 - 100
src/components/userCard/userCard.vue

@@ -1,32 +1,68 @@
 <template>
-  <a-card class="card">
-    <a-space size="large">
-      <a-statistic title="剩余乐跑次数" :value="userCount?.lepao_count" animation show-group-separator />
-      <a-button type="primary" size="large" @click="$router.push('/store/goodsList')" style="margin-left: 20px;"
-        v-if="props.type === 'lepao'">
-        <span><icon-fire /> 去购买</span>
-      </a-button>
-      <a-button type="primary" size="large" @click="$router.push('/lepao/accountList')" style="margin-left: 20px;"
-        v-else>
-        <span><icon-thunderbolt /> 去乐跑</span>
-      </a-button>
-      <a-button v-if="hasPermission('action.goods.sendCount')" type="primary" size="large" @click="SendCount()">
-        <span><icon-gift /> 赠送次数</span>
-      </a-button>
-      <a-button size="large" @click="goSendCountRecords">
-        <span><icon-list /> 赠送记录</span>
-      </a-button>
-    </a-space>
-  </a-card>
-
-  <a-modal v-model:visible="visible" title="赠送乐跑次数" @cancel="handleCancel" @before-ok="handleBeforeOk" draggable
-    :ok-loading="ok_loading" esc-to-close closable>
-    <a-form :model="sendform">
-      <a-form-item field="username" label="接收人">
-        <a-input v-model="sendform.username" placeholder="请填写接收人用户名,如:用户adfg45g" :max-length="20" />
+  <div class="user-card" :class="{ 'user-card--goods': props.type === 'goods' }">
+    <div class="user-card__main">
+      <div class="user-card__stat">
+        <span class="user-card__label">剩余乐跑次数</span>
+        <a-statistic
+          :value="userCount?.lepao_count ?? 0"
+          :value-style="{ fontSize: '2rem', fontWeight: 700, color: 'var(--stat-color)' }"
+          animation
+          show-group-separator
+        />
+      </div>
+      <div class="user-card__actions">
+        <a-button
+          v-if="props.type === 'lepao'"
+          type="primary"
+          size="large"
+          class="action-btn action-btn--primary"
+          @click="$router.push('/store/goodsList')"
+        >
+          <icon-fire /> 去购买
+        </a-button>
+        <a-button
+          v-else
+          type="primary"
+          size="large"
+          class="action-btn action-btn--primary"
+          @click="$router.push('/lepao/accountList')"
+        >
+          <icon-thunderbolt /> 去乐跑
+        </a-button>
+        <a-button
+          v-if="hasPermission('action.goods.sendCount')"
+          size="large"
+          class="action-btn"
+          @click="SendCount"
+        >
+          <icon-gift /> 赠送
+        </a-button>
+        <a-button size="large" class="action-btn" @click="goSendCountRecords">
+          <icon-list /> 记录
+        </a-button>
+      </div>
+    </div>
+  </div>
+
+  <a-modal
+    v-model:visible="visible"
+    title="赠送乐跑次数"
+    @cancel="handleCancel"
+    @before-ok="handleBeforeOk"
+    :ok-loading="ok_loading"
+    unmount-on-close
+  >
+    <a-form :model="sendform" layout="vertical">
+      <a-form-item field="username" label="接收人用户名">
+        <a-input
+          v-model="sendform.username"
+          placeholder="如:用户adfg45g"
+          :max-length="20"
+          allow-clear
+        />
       </a-form-item>
       <a-form-item field="count" label="赠送次数">
-        <a-input-number v-model="sendform.count" placeholder="请填写要赠送的次数" mode="button" />
+        <a-input-number v-model="sendform.count" :min="1" :max="9999" mode="button" />
       </a-form-item>
     </a-form>
   </a-modal>
@@ -40,65 +76,40 @@ import { Notification, Message } from '@arco-design/web-vue'
 import { hasPermission } from '@/utils/permission'
 
 const props = defineProps({
-  "type": {
-    type: String,
-    default: 'lepao'
-  }
+  type: { type: String, default: 'lepao' }
 })
 
-const userCount = ref({
-  lepao_count: 0
-})
-const loading = ref(false)
+const userCount = ref({ lepao_count: 0 })
 const router = useRouter()
-
 const visible = ref(false)
 const ok_loading = ref(false)
-const sendform = reactive({
-  username: '',
-  count: 1
-})
+const sendform = reactive({ username: '', count: 1 })
 
 const handleBeforeOk = async (done) => {
   if (!hasPermission('action.goods.sendCount')) {
-    Notification.warning({
-      title: '无权限',
-      content: '当前账号暂无赠送乐跑次数权限'
-    })
+    Notification.warning({ title: '无权限', content: '当前账号暂无赠送权限' })
     return false
   }
   try {
     ok_loading.value = true
     if (!sendform.username) {
-      Message.error('请填写接收人用户名')
+      Message.error('请填写接收人用户名')
       return false
     }
-
     if (!sendform.count || sendform.count < 1 || sendform.count > 9999) {
-      Notification.error({
-        title: '赠送次数失败!',
-        content: '超出赠送的次数范围,请重新选择赠送次数!'
-      })
+      Notification.error({ title: '赠送失败', content: '赠送次数需在 1~9999 之间' })
       return false
     }
-
     const res = await sendCount({ username: sendform.username, count: sendform.count })
     if (!res || res.code !== 0) {
-      Notification.error({
-        title: '赠送次数失败!',
-        content: res?.msg ?? '请稍后再试'
-      })
+      Notification.error({ title: '赠送失败', content: res?.msg ?? '请稍后再试' })
       return false
     }
-
-    Message.success(res.msg || '操作成功')
+    Message.success(res.msg || '赠送成功')
     done()
     GetCount()
   } catch (error) {
-    Notification.error({
-      title: '赠送次数失败!',
-      content: error.message || '请稍后再试'
-    })
+    Notification.error({ title: '赠送失败', content: error.message || '请稍后再试' })
     return false
   } finally {
     ok_loading.value = false
@@ -109,67 +120,86 @@ const handleCancel = () => {
   visible.value = false
 }
 
-const SendCount = async () => {
+const SendCount = () => {
   sendform.username = ''
   sendform.count = 1
   visible.value = true
 }
 
-const goSendCountRecords = () => {
-  router.push('/store/sendCountRecords')
-}
+const goSendCountRecords = () => router.push('/store/sendCountRecords')
 
 const GetCount = async () => {
   try {
     const res = await getCount()
-    if (!res || res.code !== 0)
-      return Notification.error({
-        title: '获取用户数据失败!',
-        content: res?.msg ?? '请稍后再试'
-      })
+    if (!res || res.code !== 0) return
     userCount.value = res.data
-  } catch (error) {
-    Notification.error({
-      title: '获取用户数据失败!',
-      content: error.message || '请稍后再试'
-    })
+  } catch {
+    /* 静默失败,避免轮询刷屏 */
   }
 }
 
 let timer = null
-
-// 轮询
 const startPolling = () => {
-    if (!timer) {
-        timer = setInterval(async () => {
-            await GetCount()
-        }, 5000)
-    }
+  if (!timer) timer = setInterval(GetCount, 8000)
 }
-
-// 停止轮询
 const stopPolling = () => {
-    if (timer) {
-        clearInterval(timer)
-        timer = null
-    }
+  if (timer) {
+    clearInterval(timer)
+    timer = null
+  }
 }
 
 onMounted(async () => {
-    loading.value = true
-    await GetCount()
-    loading.value = false
-    startPolling()
-})
-
-// 组件销毁时停止轮询
-onUnmounted(() => {
-    stopPolling()
+  await GetCount()
+  startPolling()
 })
+onUnmounted(stopPolling)
 </script>
 
-<style scoped>
-.card {
-  font-family: -apple-system, BlinkMacSystemFont;
+<style scoped lang="less">
+@import '@/styles/store-theme.less';
+
+.user-card {
+  --stat-color: rgb(var(--primary-6));
+  background: @store-card-bg;
+  border: 1px solid @store-card-border;
+  border-radius: @store-radius;
+  box-shadow: @store-shadow;
+  padding: 20px 24px;
+
+  &--goods {
+    background: linear-gradient(135deg, #f4faf6 0%, #e8f5ec 100%);
+    --stat-color: @store-primary;
+  }
+
+  &__main {
+    display: flex;
+    flex-wrap: wrap;
+    align-items: center;
+    justify-content: space-between;
+    gap: 20px;
+  }
+
+  &__label {
+    display: block;
+    font-size: 0.85rem;
+    color: @store-text-muted;
+    margin-bottom: 4px;
+  }
+
+  &__actions {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 10px;
+  }
+}
+
+.action-btn {
+  border-radius: 999px;
+
+  &--primary {
+    background: @store-primary !important;
+    border-color: @store-primary !important;
+  }
 }
-</style>
+</style>

+ 431 - 118
src/pages/store/goodsDetail/index.vue

@@ -1,166 +1,479 @@
 <template>
-    <div class="container">
-        <Breadcrumb />
-        <a-card class="goodsdetail">
-            <div class="title">
-                {{ data?.name }} <a-tag v-if="data?.limit > 0" color="orange" size="large">限购{{ data?.limit }}件</a-tag>
-            </div>
-            <div class="price">
-                ¥ {{ data?.price }}
-            </div>
-            <div class="num">
-                剩余库存:{{ data?.num > 99 ? '充足' : data?.num }}
-            </div>
+  <div class="store-page goods-detail-page">
+    <Breadcrumb />
 
-            <a-button class="buy-button" type="primary" size="large" @click="buy">立即购买</a-button>
-
-            <a-divider orientation="center" style="margin-top: 30px;"><span
-                    style="font-size: 1.2em;">商品详情</span></a-divider>
-            <a-skeleton animation :loading="loading">
-                <a-space direction="vertical" :style="{ width: '100%' }" size="large">
-                    <a-skeleton-shape />
-                    <a-skeleton-line :rows="5" />
-                </a-space>
-            </a-skeleton>
-            <div class="content">
-                <div v-html="content"></div>
+    <a-spin :loading="loading" class="store-spin">
+      <div v-if="loadError" class="load-error">
+        <a-empty description="商品加载失败">
+          <a-button type="primary" @click="getGoodsDetail">重新加载</a-button>
+        </a-empty>
+      </div>
+      <div v-else-if="data?.id" class="detail-layout">
+          <aside class="detail-summary">
+            <div class="summary-card">
+              <div class="summary-visual">
+                <GoodsEmoji :icon="data?.icon" size="xl" :aria-label="data?.name" />
+              </div>
+              <h1 class="summary-title">
+                {{ data?.name }}
+                <a-tag v-if="data?.limit > 0" color="orange">限购 {{ data.limit }} 件</a-tag>
+                <a-tag v-if="data?.isHot" color="orangered">热销</a-tag>
+              </h1>
+              <p v-if="data?.description" class="summary-desc">{{ data.description }}</p>
+              <ul v-if="featureList.length" class="summary-features">
+                <li v-for="(f, i) in featureList" :key="i">
+                  <icon-check-circle-fill /> {{ f }}
+                </li>
+              </ul>
+              <div class="summary-meta">
+                <span class="price">¥{{ data?.price }}</span>
+                <a-tag :color="(data?.num ?? 0) > 0 ? 'green' : 'red'">
+                  库存 {{ stockLabel(data?.num) }}
+                </a-tag>
+              </div>
+              <a-button
+                type="primary"
+                size="large"
+                long
+                class="buy-btn"
+                :disabled="!(data?.num > 0)"
+                @click="openCheckout"
+              >
+                {{ data?.num > 0 ? '立即购买' : '暂时缺货' }}
+              </a-button>
             </div>
-        </a-card>
+          </aside>
+
+          <main class="detail-content">
+            <a-card :bordered="false" class="content-card">
+              <template #title>
+                <span class="content-card__title">商品详情</span>
+              </template>
+              <div class="rich-content" v-html="content" />
+            </a-card>
+          </main>
+        </div>
+    </a-spin>
+
+    <div v-if="!loading && data?.num > 0" class="mobile-buy-bar">
+      <span class="mobile-price">¥{{ payablePrice }}</span>
+      <a-button type="primary" size="large" @click="openCheckout">立即购买</a-button>
     </div>
 
-    <!--下单对话框 -->
-    <a-modal v-model:visible="visible" title="确认订单" @cancel="handleCancel" @before-ok="handleBeforeOk" draggable
-        :ok-loading="ok_loading" esc-to-close closable>
-        <a-form :model="form">
-            <a-form-item field="name" label="商品名称">
-                <b>{{ form.name }}</b>
-            </a-form-item>
-            <a-form-item field="price" label="支付价格">
-                <b>¥ {{ form.price }}</b>
-            </a-form-item>
-            <a-form-item field="pay_type" label="支付方式">
-                <a-select v-model="form.pay_type" placeholder="请选择支付方式">
-                    <a-option value="alipay"><icon-alipay-circle /> 支付宝</a-option>
-                    <!-- <a-option value="qqpay"><icon-qq /> QQ支付</a-option> -->
-                    <a-option value="wxpay"><icon-wechatpay /> 微信支付</a-option>
-                </a-select>
-            </a-form-item>
-        </a-form>
-    </a-modal>
+    <a-drawer
+      v-model:visible="checkoutVisible"
+      title="确认订单"
+      :width="420"
+      unmount-on-close
+      class="checkout-drawer"
+    >
+      <div class="checkout-order">
+        <div class="checkout-order__row">
+          <span class="label">商品</span>
+          <span class="value">{{ data?.name }}</span>
+        </div>
+        <div class="checkout-order__row">
+          <span class="label">商品原价</span>
+          <span class="value">¥{{ data?.price }}</span>
+        </div>
+        <div v-if="couponApplied" class="checkout-order__row checkout-order__row--discount">
+          <span class="label">优惠码 {{ couponApplied.code }}</span>
+          <span class="value discount">-¥{{ couponApplied.discountAmount }}</span>
+        </div>
+        <div class="checkout-order__row checkout-order__row--price">
+          <span class="label">应付金额</span>
+          <span class="value price">¥{{ payablePrice }}</span>
+        </div>
+      </div>
+
+      <div class="checkout-section">
+        <div class="checkout-section__title">优惠码(选填)</div>
+        <div class="coupon-row">
+          <a-input
+            v-model="couponCode"
+            placeholder="输入优惠码"
+            allow-clear
+            :max-length="32"
+            @press-enter="applyCoupon"
+          />
+          <a-button type="outline" :loading="couponLoading" @click="applyCoupon">应用</a-button>
+        </div>
+        <p v-if="couponError" class="coupon-error">{{ couponError }}</p>
+        <p v-else-if="couponApplied" class="coupon-success">
+          已应用:{{ couponApplied.displayDiscount }}
+        </p>
+      </div>
+
+      <div class="checkout-section">
+        <div class="checkout-section__title">选择支付方式</div>
+        <PayMethodPicker v-model="payType" />
+      </div>
+
+      <template #footer>
+        <a-button long size="large" @click="checkoutVisible = false">取消</a-button>
+        <a-button
+          type="primary"
+          long
+          size="large"
+          :loading="submitting"
+          class="submit-btn"
+          @click="submitOrder"
+        >
+          提交订单
+        </a-button>
+      </template>
+    </a-drawer>
+  </div>
 </template>
 
 <script setup>
-import { ref, reactive } from 'vue'
+import { ref, computed } from 'vue'
 import { getGoods } from '@/api/goods'
 import { createOrder } from '@/api/order'
+import { validateCoupon } from '@/api/coupon'
 import { useRoute, useRouter } from 'vue-router'
 import { Notification, Message } from '@arco-design/web-vue'
+import PayMethodPicker from '@/components/store/PayMethodPicker.vue'
+import GoodsEmoji from '@/components/store/GoodsEmoji.vue'
+import { stockLabel, parseFeatures, decodeGoodsContent } from '@/utils/storeFormat'
 
 const route = useRoute()
 const router = useRouter()
 const { id } = route.params
 
 const loading = ref(true)
+const loadError = ref(false)
 const data = ref({})
 const content = ref('')
+const checkoutVisible = ref(false)
+const payType = ref('')
+const submitting = ref(false)
+const couponCode = ref('')
+const couponApplied = ref(null)
+const couponLoading = ref(false)
+const couponError = ref('')
 
-const visible = ref(false)
-const form = reactive({
-    name: "",
-    price: 0,
-    pay_type: ''
+const featureList = computed(() => parseFeatures(data.value?.features))
+
+const payablePrice = computed(() => {
+  if (couponApplied.value?.finalPrice != null) return couponApplied.value.finalPrice
+  return data.value?.price
 })
 
-const handleBeforeOk = async (done) => {
-    try {
-        if (!form.pay_type) {
-            Message.error('请选择支付方式!')
-            return false
-        }
-
-        const res = await createOrder({ goods_id: id, pay_type: form.pay_type })
-        if (!res || res.code !== 0) {
-            Notification.error({
-                title: '创建订单失败!',
-                content: res?.msg ?? '请稍后再试'
-            })
-            return false
-        }
-
-        router.push(`/store/orderDetail/${res.id}`)
-    } catch (error) {
-        Notification.error({
-            title: '创建订单失败!',
-            content: error.message || '请稍后再试'
-        })
-        return false
-    }
+const openCheckout = () => {
+  payType.value = ''
+  couponCode.value = ''
+  couponApplied.value = null
+  couponError.value = ''
+  checkoutVisible.value = true
 }
 
-const handleCancel = () => {
-    visible.value = false;
+const applyCoupon = async () => {
+  const code = couponCode.value.trim()
+  if (!code) {
+    couponApplied.value = null
+    couponError.value = ''
+    return
+  }
+  try {
+    couponLoading.value = true
+    couponError.value = ''
+    const res = await validateCoupon({ code, goods_id: id })
+    if (!res || res.code !== 0) {
+      couponApplied.value = null
+      couponError.value = res?.msg ?? '优惠码无效'
+      return
+    }
+    couponApplied.value = res.data
+    couponCode.value = res.data.code
+    Message.success('优惠码已应用')
+  } catch (e) {
+    couponApplied.value = null
+    couponError.value = e.message || '校验失败'
+  } finally {
+    couponLoading.value = false
+  }
 }
 
-const getGoodsDetail = async () => {
-    try {
-        loading.value = true
-        const res = await getGoods({ id })
-        if (!res || res.code !== 0)
-            return Notification.error({
-                title: '获取商品列表失败!',
-                content: res?.msg ?? '请稍后再试'
-            })
-        data.value = res.data
-        content.value = decodeURI(atob(res.data.content || ''))
-        form.name = data.value.name
-        form.price = data.value.price
-    } catch (error) {
-        Notification.error({
-            title: '获取商品列表失败!',
-            content: error.message || '请稍后再试'
-        })
-    } finally {
-        loading.value = false
+const submitOrder = async () => {
+  if (!payType.value) {
+    Message.warning('请选择支付方式')
+    return
+  }
+  try {
+    submitting.value = true
+    const payload = { goods_id: id, pay_type: payType.value }
+    if (couponApplied.value?.code) payload.coupon_code = couponApplied.value.code
+    const res = await createOrder(payload)
+    if (!res || res.code !== 0) {
+      Notification.error({
+        title: '创建订单失败',
+        content: res?.msg ?? '请稍后再试'
+      })
+      return
     }
+    checkoutVisible.value = false
+    Message.success('订单已创建,请完成支付')
+    router.push(`/store/orderDetail/${res.id}`)
+  } catch (error) {
+    Notification.error({
+      title: '创建订单失败',
+      content: error.message || '请稍后再试'
+    })
+  } finally {
+    submitting.value = false
+  }
 }
 
-const buy = () => {
-    visible.value = true
+const getGoodsDetail = async () => {
+  try {
+    loading.value = true
+    loadError.value = false
+    const res = await getGoods({ id })
+    if (!res || res.code !== 0) {
+      loadError.value = true
+      Notification.error({
+        title: '获取商品详情失败',
+        content: res?.msg ?? '请稍后再试'
+      })
+      return
+    }
+    data.value = res.data ?? {}
+    content.value = decodeGoodsContent(res.data?.content)
+  } catch (error) {
+    loadError.value = true
+    Notification.error({
+      title: '获取商品详情失败',
+      content: error.message || '请稍后再试'
+    })
+  } finally {
+    loading.value = false
+  }
 }
 
 getGoodsDetail()
 </script>
 
 <style lang="less" scoped>
-.container {
-    padding: 0 20px 20px 20px;
+@import '@/styles/store-theme.less';
+
+.goods-detail-page {
+  padding-bottom: 80px;
+
+  @media (min-width: 768px) {
+    padding-bottom: 32px;
+  }
 }
 
-.goodsdetail {
-    border-radius: 5px;
+.load-error {
+  padding: 48px 0;
+}
 
-    .title {
-        font-size: 1.8em;
-    }
+.detail-layout {
+  display: grid;
+  grid-template-columns: 1fr;
+  gap: 24px;
+  width: 100%;
+
+  @media (min-width: 900px) {
+    grid-template-columns: 340px 1fr;
+    align-items: start;
+  }
+}
+
+.summary-card {
+  background: @store-card-bg;
+  border: 1px solid @store-card-border;
+  border-radius: @store-radius;
+  box-shadow: @store-shadow;
+  padding: 24px;
+  position: sticky;
+  top: 16px;
+}
+
+.summary-visual {
+  height: 160px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: linear-gradient(135deg, #e8f5ec, #d4f0dc);
+  border-radius: @store-radius-sm;
+  margin-bottom: 20px;
+}
 
-    .price {
-        font-size: 2em;
-        color: rgb(22, 93, 255);
+.summary-title {
+  margin: 0 0 12px;
+  font-size: 1.35rem;
+  font-weight: 600;
+  color: @store-primary;
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: 8px;
+}
+
+.summary-desc {
+  margin: 0 0 16px;
+  color: @store-text-muted;
+  font-size: 0.9rem;
+  line-height: 1.6;
+}
+
+.summary-features {
+  list-style: none;
+  margin: 0 0 20px;
+  padding: 0;
+
+  li {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    font-size: 0.9rem;
+    color: @store-text-muted;
+    margin-bottom: 8px;
+
+    svg {
+      color: @store-accent;
+      flex-shrink: 0;
     }
+  }
+}
+
+.summary-meta {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 20px;
 
-    .num {
-        font-size: 1.1em;
+  .price {
+    font-size: 2rem;
+    font-weight: 700;
+    color: @store-price;
+  }
+}
+
+.buy-btn {
+  border-radius: 999px;
+  background: @store-primary !important;
+  border-color: @store-primary !important;
+}
+
+.content-card {
+  border-radius: @store-radius;
+  border: 1px solid @store-card-border;
+  box-shadow: @store-shadow;
+
+  &__title {
+    font-weight: 600;
+    color: @store-primary;
+  }
+}
+
+.rich-content {
+  line-height: 1.75;
+  color: @store-text-muted;
+
+  :deep(img) {
+    max-width: 100%;
+  }
+}
+
+.mobile-buy-bar {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 16px;
+  position: fixed;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  padding: 12px 20px;
+  background: @store-card-bg;
+  border-top: 1px solid @store-card-border;
+  box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.06);
+  z-index: 100;
+
+  @media (min-width: 900px) {
+    display: none;
+  }
+
+  .mobile-price {
+    font-size: 1.5rem;
+    font-weight: 700;
+    color: @store-price;
+  }
+}
+
+.checkout-order {
+  background: @store-bg;
+  border-radius: @store-radius-sm;
+  padding: 16px;
+  margin-bottom: 24px;
+
+  &__row {
+    display: flex;
+    justify-content: space-between;
+    gap: 12px;
+    padding: 8px 0;
+    font-size: 0.95rem;
+
+    .label {
+      color: @store-text-muted;
     }
 
-    .buy-button {
-        position: relative;
-        left: 50%;
-        translate: (-50%);
-        margin-top: 20px;
+    .value {
+      font-weight: 500;
+      color: @store-primary;
+      text-align: right;
     }
 
-    .content {
-        padding: 0 20px;
+    &--price .price {
+      font-size: 1.25rem;
+      font-weight: 700;
+      color: @store-price;
     }
+  }
+}
+
+.checkout-section {
+  margin-bottom: 20px;
+}
+
+.checkout-section__title {
+  font-weight: 600;
+  margin-bottom: 12px;
+  color: @store-primary;
+}
+
+.coupon-row {
+  display: flex;
+  gap: 8px;
+
+  .arco-input-wrapper {
+    flex: 1;
+  }
+}
+
+.coupon-error {
+  margin: 8px 0 0;
+  font-size: 12px;
+  color: rgb(var(--red-6));
+}
+
+.coupon-success {
+  margin: 8px 0 0;
+  font-size: 12px;
+  color: @store-accent;
+}
+
+.checkout-order__row--discount .discount {
+  color: rgb(var(--green-6));
+  font-weight: 600;
+}
+
+.submit-btn {
+  margin-top: 8px;
+  background: @store-primary !important;
+  border-color: @store-primary !important;
 }
-</style>
+</style>

+ 206 - 71
src/pages/store/goodsList/index.vue

@@ -1,74 +1,109 @@
 <template>
-    <div class="container">
-        <Breadcrumb />
-
-        <userCard type="goods"/>
-
-        <a-card title="商品列表" style="margin-top: 20px;">
-            <a-alert v-if="notice" style="margin-bottom: 15px;">{{ notice }}</a-alert>
-            <a-list hoverable :loading="loading">
-                <a-list-item v-for="(item, index) in data" :key="index">
-                    <div class="list">
-                        <div class="icon">
-                            <icon-gift :size="25" />
-                        </div>
-                        <div class="info">
-                            <span class="title">
-                                {{ item.name }}
-                            </span>
-                            <span class="label">
-                                <a-tag size="small">库存:{{ item.num > 99 ? '充足' : item.num }}</a-tag>
-                                ¥ {{ item.price }}
-                            </span>
-                        </div>
-                        <div class="button">
-                            <a-button type="primary" size="small"
-                                @click="$router.push(`/store/goodsDetail/${item.id}`)">查看详情</a-button>
-                        </div>
-                    </div>
-                </a-list-item>
-            </a-list>
-        </a-card>
-    </div>
+  <div class="store-page">
+    <Breadcrumb />
+
+    <userCard type="goods" class="user-card-wrap" />
+
+    <a-alert v-if="notice" type="info" closable class="notice">{{ notice }}</a-alert>
+
+    <header class="page-header">
+      <div>
+        <h1 class="store-section-title">云商城</h1>
+        <p class="store-section-desc">选购乐跑次数套餐,支付后自动到账</p>
+      </div>
+      <a-input-search
+        v-model="keyword"
+        placeholder="搜索商品名称"
+        allow-clear
+        class="search"
+        @search="getGoods"
+        @clear="getGoods"
+      />
+    </header>
+
+    <a-spin :loading="loading" class="store-spin goods-spin">
+      <a-empty v-if="!loading && filteredData.length === 0" description="暂无商品" />
+      <div v-else class="goods-grid">
+        <article
+          v-for="item in filteredData"
+          :key="item.id"
+          class="goods-card"
+          @click="goDetail(item.id)"
+        >
+          <div class="goods-card__visual">
+            <GoodsEmoji :icon="item.icon" size="lg" :aria-label="item.name" />
+            <a-tag v-if="item.isHot" color="orangered" size="small" class="goods-card__hot">热销</a-tag>
+          </div>
+          <div class="goods-card__body">
+            <h3 class="goods-card__name">{{ item.name }}</h3>
+            <p v-if="item.description" class="goods-card__desc">{{ item.description }}</p>
+            <ul v-if="parseFeatures(item.features).length" class="goods-card__features">
+              <li v-for="(f, i) in parseFeatures(item.features).slice(0, 3)" :key="i">{{ f }}</li>
+            </ul>
+            <div class="goods-card__footer">
+              <span class="goods-card__price">¥{{ item.price }}</span>
+              <a-tag size="small" :color="item.num > 0 ? 'green' : 'red'">
+                库存 {{ stockLabel(item.num) }}
+              </a-tag>
+            </div>
+          </div>
+          <a-button type="primary" class="goods-card__btn" @click.stop="goDetail(item.id)">
+            立即选购
+          </a-button>
+        </article>
+      </div>
+    </a-spin>
+  </div>
 </template>
 
 <script setup>
-import { ref } from 'vue'
+import { ref, computed } from 'vue'
+import { useRouter, useRoute } from 'vue-router'
 import { getGoodsList } from '@/api/goods'
 import { Notification } from '@arco-design/web-vue'
 import userCard from '@/components/userCard/userCard.vue'
-import { useRoute } from 'vue-router'
+import GoodsEmoji from '@/components/store/GoodsEmoji.vue'
 import { getNotice } from '@/utils/util'
+import { stockLabel, parseFeatures } from '@/utils/storeFormat'
 
+const router = useRouter()
 const notice = ref('')
-
-const GetNotice = async () => {
-  const { path } = useRoute()
-  const res = await getNotice(path)
-  notice.value = res
-}
-
 const data = ref([])
 const loading = ref(false)
+const keyword = ref('')
+
+const filteredData = computed(() => {
+  const k = keyword.value.trim().toLowerCase()
+  if (!k) return data.value
+  return data.value.filter((item) => (item.name ?? '').toLowerCase().includes(k))
+})
+
+const goDetail = (id) => router.push(`/store/goodsDetail/${id}`)
 
 const getGoods = async () => {
-    try {
-        loading.value = true
-        const res = await getGoodsList()
-        if (!res || res.code !== 0)
-            return Notification.error({
-                title: '获取商品列表失败!',
-                content: res?.msg ?? '请稍后再试'
-            })
-        data.value = res.data
-    } catch (error) {
-        Notification.error({
-            title: '获取商品列表失败!',
-            content: error.message || '请稍后再试'
-        })
-    } finally {
-        loading.value = false
+  try {
+    loading.value = true
+    const res = await getGoodsList()
+    if (!res || res.code !== 0) {
+      return Notification.error({
+        title: '获取商品列表失败',
+        content: res?.msg ?? '请稍后再试'
+      })
     }
+    data.value = res.data ?? []
+  } catch (error) {
+    Notification.error({
+      title: '获取商品列表失败',
+      content: error.message || '请稍后再试'
+    })
+  } finally {
+    loading.value = false
+  }
+}
+
+const GetNotice = async () => {
+  const { path } = useRoute()
+  notice.value = await getNotice(path)
 }
 
 getGoods()
@@ -76,29 +111,129 @@ GetNotice()
 </script>
 
 <style lang="less" scoped>
-.container {
-    padding: 0 20px 20px 20px;
+@import '@/styles/store-theme.less';
+
+.user-card-wrap {
+  margin-bottom: 20px;
+}
+
+.notice {
+  margin-top: 16px;
+  border-radius: @store-radius-sm;
 }
 
-.list {
+.page-header {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: flex-end;
+  justify-content: space-between;
+  gap: 16px;
+  margin-top: 16px;
+  margin-bottom: 24px;
+}
+
+.search {
+  width: min(280px, 100%);
+}
+
+.goods-spin {
+  min-height: 200px;
+}
+
+.goods-grid {
+  width: 100%;
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
+  gap: 20px;
+}
+
+.goods-card {
+  background: @store-card-bg;
+  border: 1px solid @store-card-border;
+  border-radius: @store-radius;
+  box-shadow: @store-shadow;
+  overflow: hidden;
+  cursor: pointer;
+  transition: transform 0.2s, box-shadow 0.2s;
+  display: flex;
+  flex-direction: column;
+
+  &:hover {
+    transform: translateY(-4px);
+    box-shadow: @store-shadow-hover;
+  }
+
+  &__visual {
+    position: relative;
+    height: 140px;
+    background: linear-gradient(135deg, #e8f5ec 0%, #d4f0dc 100%);
     display: flex;
-    min-height: 60px;
     align-items: center;
+    justify-content: center;
+  }
+
+  &__hot {
+    position: absolute;
+    top: 12px;
+    right: 12px;
+  }
+
+  &__body {
+    padding: 16px 16px 8px;
+    flex: 1;
+  }
 
-    .info {
-        display: flex;
-        flex-direction: column;
-        margin-left: 15px;
-        margin-right: 15px;
+  &__name {
+    margin: 0 0 8px;
+    font-size: 1.1rem;
+    font-weight: 600;
+    color: @store-primary;
+    line-height: 1.4;
+  }
 
+  &__desc {
+    margin: 0 0 8px;
+    font-size: 0.85rem;
+    color: @store-text-muted;
+    display: -webkit-box;
+    -webkit-line-clamp: 2;
+    -webkit-box-orient: vertical;
+    overflow: hidden;
+  }
 
-        .title {
-            font-size: 1.3em;
-        }
+  &__features {
+    margin: 0 0 12px;
+    padding-left: 18px;
+    font-size: 0.8rem;
+    color: @store-text-muted;
+
+    li {
+      margin-bottom: 2px;
     }
+  }
+
+  &__footer {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 8px;
+  }
+
+  &__price {
+    font-size: 1.35rem;
+    font-weight: 700;
+    color: @store-price;
+  }
+
+  &__btn {
+    margin: 0 16px 16px;
+    border-radius: 999px;
+    background: @store-primary !important;
+    border-color: @store-primary !important;
 
-    .button {
-        margin-left: auto;
+    &:hover {
+      background: @store-primary-light !important;
     }
+  }
 }
-</style>
+</style>

+ 262 - 173
src/pages/store/orders/orderDetail/index.vue

@@ -1,37 +1,88 @@
 <template>
-  <div class="container">
+  <div class="store-page order-detail-page">
     <Breadcrumb />
-    <a-space direction="vertical" :size="16" fill>
-      <a-card class="general-card" title="订单状态">
-        <div class="step">
-          <a-steps :current="current" :status="status">
-            <a-step :description="oneDes">{{ oneText }}</a-step>
-            <a-step :description="twoDes">{{ twoText }}</a-step>
-            <a-step :description="threeDes">{{ threeText }}</a-step>
+
+    <a-spin :loading="loading" class="store-spin">
+      <div class="detail-stack">
+        <!-- 待支付横幅 -->
+        <div v-if="data?.state === 0 && hasPay" class="pay-banner">
+          <div class="pay-banner__info">
+            <icon-clock-circle class="pay-banner__icon" />
+            <div>
+              <div class="pay-banner__title">等待支付</div>
+              <div class="pay-banner__desc">{{ paymentCountdownText }}</div>
+            </div>
+          </div>
+          <a-button type="primary" size="large" class="pay-banner__btn" @click="pay">
+            立即支付 ¥{{ data?.price }}
+          </a-button>
+        </div>
+
+        <a-card :bordered="false" class="status-card">
+          <a-steps :current="stepCurrent" :status="stepStatus" label-placement="vertical" class="steps">
+            <a-step :description="stepDescriptions[0]">{{ stepLabels[0] }}</a-step>
+            <a-step :description="stepDescriptions[1]">{{ stepLabels[1] }}</a-step>
+            <a-step :description="stepDescriptions[2]">{{ stepLabels[2] }}</a-step>
           </a-steps>
+          <div class="status-badge-wrap">
+            <OrderStateTag :state="data?.state ?? 0" />
+          </div>
+        </a-card>
+
+        <a-card :bordered="false" class="info-card" title="订单信息">
+          <a-descriptions :column="1" bordered size="medium">
+            <a-descriptions-item label="商品名称">{{ data?.name || '-' }}</a-descriptions-item>
+            <a-descriptions-item v-if="data?.original_price && data?.discount_amount > 0" label="商品原价">
+              ¥{{ data.original_price }}
+            </a-descriptions-item>
+            <a-descriptions-item v-if="data?.coupon_code" label="优惠码">
+              {{ data.coupon_code }}
+              <span v-if="data?.discount_amount > 0" class="discount-tag">-¥{{ data.discount_amount }}</span>
+            </a-descriptions-item>
+            <a-descriptions-item label="支付金额">
+              <span class="price-text">¥{{ data?.price }}</span>
+            </a-descriptions-item>
+            <a-descriptions-item label="支付方式">
+              <span class="pay-row">
+                <icon-wechatpay v-if="data?.pay_type === 'wxpay'" />
+                <icon-alipay-circle v-else-if="data?.pay_type === 'alipay'" />
+                {{ getPayTypeLabel(data?.pay_type) }}
+              </span>
+            </a-descriptions-item>
+            <a-descriptions-item label="订单号">{{ data?.orderId }}</a-descriptions-item>
+            <a-descriptions-item v-if="data?.pay_id" label="支付平台单号">{{ data.pay_id }}</a-descriptions-item>
+            <a-descriptions-item label="下单时间">{{ formatStoreTimeFull(data?.create_time) }}</a-descriptions-item>
+            <a-descriptions-item v-if="data?.pay_time" label="支付时间">
+              {{ formatStoreTimeFull(data.pay_time) }}
+            </a-descriptions-item>
+          </a-descriptions>
+        </a-card>
+
+        <a-card v-if="content" :bordered="false" class="info-card" title="商品说明">
+          <div class="rich-content" v-html="content" />
+        </a-card>
+
+        <div class="detail-actions">
+          <a-button @click="$router.push('/store/myOrder')">返回订单列表</a-button>
+          <a-button type="outline" @click="$router.push('/store/goodsList')">继续购物</a-button>
         </div>
-        <a-button type="primary" size="large" class="paybutton" @click="pay"
-          v-if="data?.state === 0 && payData != {}">立即支付</a-button>
-
-      </a-card>
-      <a-card class="general-card" title="订单详情">
-        <a-descriptions :data="orderData" size="large" :column="1" />
-      </a-card>
-      <a-card class="general-card" title="商品详情">
-        <div v-html="content"></div>
-      </a-card>
-
-    </a-space>
+      </div>
+    </a-spin>
   </div>
 </template>
 
 <script setup>
-import { ref, onUnmounted, onMounted, h } from 'vue'
+import { ref, computed, onUnmounted, onMounted } from 'vue'
 import { orderDeatil } from '@/api/order'
 import { useRoute } from 'vue-router'
-import { Notification } from '@arco-design/web-vue'
-import { Message } from '@arco-design/web-vue'
-import { IconQq, IconWechatpay, IconAlipayCircle } from '@arco-design/web-vue/es/icon'
+import { Notification, Message } from '@arco-design/web-vue'
+import OrderStateTag from '@/components/store/OrderStateTag.vue'
+import {
+  formatStoreTimeFull,
+  getPayTypeLabel,
+  decodeGoodsContent,
+  hasPayData
+} from '@/utils/storeFormat'
 
 const route = useRoute()
 const { id } = route.params
@@ -39,88 +90,105 @@ const { id } = route.params
 const loading = ref(true)
 const data = ref({})
 const payData = ref({})
+const content = ref('')
+const paymentCountdownText = ref('')
 
-const current = ref(1)
-const status = ref('process')
-const oneText = ref('待支付')
-const twoText = ref('待处理')
-const threeText = ref('已完成')
-const oneDes = ref('')
-const twoDes = ref('')
-const threeDes = ref('')
+const hasPay = computed(() => hasPayData(payData.value))
 
-const orderData = ref([])
-const content = ref('')
+const stepCurrent = ref(1)
+const stepStatus = ref('process')
+const stepLabels = ref(['待支付', '待处理', '已完成'])
+const stepDescriptions = ref(['', '', ''])
 
-const stramptoTime = (time) => {
-  return new Date(time).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' })
+const PAY_TIMEOUT_SEC = 300
+let timer = null
+
+const updateStepState = () => {
+  const state = data.value?.state
+  stepDescriptions.value = ['', '', '']
+
+  switch (state) {
+    case 0:
+      stepCurrent.value = 1
+      stepStatus.value = 'process'
+      stepLabels.value = ['待支付', '待处理', '已完成']
+      updatePaymentCountdown()
+      break
+    case 1:
+      stepCurrent.value = 2
+      stepStatus.value = 'process'
+      stepDescriptions.value[1] = '支付成功,等待系统处理'
+      stopPolling()
+      break
+    case 2:
+      stepCurrent.value = 3
+      stepStatus.value = 'finish'
+      stepDescriptions.value[2] = '权益已发放至账户'
+      stopPolling()
+      break
+    case 3:
+      stepCurrent.value = 1
+      stepStatus.value = 'error'
+      stepLabels.value = ['已关闭', '待处理', '已完成']
+      stepDescriptions.value[0] = '订单超时或已取消'
+      stopPolling()
+      break
+    default:
+      break
+  }
 }
 
-function getPayType(type) {
-  switch (type) {
-    case 'alipay':
-      return [h(IconAlipayCircle), ' 支付宝']
-    case 'qqpay':
-      return [h(IconQq), ' QQ支付']
-    case 'wxpay':
-      return [h(IconWechatpay), ' 微信支付']
+const updatePaymentCountdown = () => {
+  const ts = data.value?.create_time
+  if (!ts) {
+    paymentCountdownText.value = '请在规定时间内完成支付'
+    return
   }
+  const diffSec = Math.floor((Date.now() - ts) / 1000)
+  if (diffSec > PAY_TIMEOUT_SEC) {
+    paymentCountdownText.value = '支付已超时,订单可能已关闭'
+    stepDescriptions.value[0] = '支付超时'
+    return
+  }
+  const remaining = PAY_TIMEOUT_SEC - diffSec
+  const m = Math.floor(remaining / 60)
+  const s = String(remaining % 60).padStart(2, '0')
+  paymentCountdownText.value = `请在 ${m} 分 ${s} 秒内完成支付`
+  stepDescriptions.value[0] = paymentCountdownText.value
 }
 
-const getOrderDeatil = async () => {
+const getOrderDetail = async () => {
   try {
     const res = await orderDeatil({ orderId: id })
-    if (!res || res.code !== 0)
+    if (!res || res.code !== 0) {
       return Notification.error({
-        title: '获取订单详情失败!',
+        title: '获取订单详情失败',
         content: res?.msg ?? '请稍后再试'
       })
-    data.value = res.data
-    content.value = decodeURI(atob(res.data.content || ''))
-
-    let detail = []
-    if (res.data.name)
-      detail.push({ label: '商品名称', value: res.data.name })
-    if (res.data.price)
-      detail.push({ label: '支付价格', value: '¥' + res.data.price })
-    if (res.data.pay_type)
-      detail.push({ label: '支付方式', value: getPayType(res.data.pay_type) })
-    if (res.data.orderId)
-      detail.push({ label: '平台订单号', value: res.data.orderId })
-    if (res.data.pay_id)
-      detail.push({ label: '支付平台订单号', value: res.data.pay_id })
-    if (res.data.create_time)
-      detail.push({ label: '下单时间', value: stramptoTime(res.data.create_time) })
-    if (res.data.pay_time)
-      detail.push({ label: '支付时间', value: stramptoTime(res.data.pay_time) })
-
-    orderData.value = detail
-
-    if (res.payData)
-      payData.value = res.payData
-    else
-      payData.value = {}
+    }
+    data.value = res.data ?? {}
+    content.value = decodeGoodsContent(res.data?.content)
+    payData.value = res.payData && hasPayData(res.payData) ? res.payData : {}
+    updateStepState()
   } catch (error) {
     Notification.error({
-      title: '获取订单详情失败',
+      title: '获取订单详情失败',
       content: error.message || '请稍后再试'
     })
   }
 }
 
-let timer = null
-
-// 轮询
 const startPolling = () => {
-  if (!timer) {
-    timer = setInterval(async () => {
-      await getOrderDeatil()
-      getOrderState()
-    }, 1000)
-  }
+  if (timer) return
+  timer = setInterval(async () => {
+    if (data.value?.state !== 0) {
+      stopPolling()
+      return
+    }
+    await getOrderDetail()
+  }, 3000)
 }
 
-// 停止轮询
 const stopPolling = () => {
   if (timer) {
     clearInterval(timer)
@@ -130,119 +198,140 @@ const stopPolling = () => {
 
 onMounted(async () => {
   loading.value = true
-  await getOrderDeatil()
+  await getOrderDetail()
   loading.value = false
-  startPolling()
+  if (data.value?.state === 0) startPolling()
 })
 
-// 组件销毁时停止轮询
-onUnmounted(() => {
-  stopPolling()
-})
+onUnmounted(stopPolling)
 
-function getOrderState() {
-  if (data.value) {
-
-    switch (data.value.state) {
-      case 0:
-        current.value = 1
-        getPaymentStatus(data.value.create_time)
-        break
-
-      case 1:
-        current.value = 2
-        twoDes.value = '支付完成,等待系统处理'
-        oneDes.value = ''
-        break
-
-      case 2:
-        current.value = 3
-        threeDes.value = '订单处理完毕'
-        status.value = 'finish'
-        oneDes.value = ''
-        twoDes.value = ''
-        stopPolling()
-        break
-
-      case 3:
-        current.value = 1
-        status.value = 'error'
-        oneText.value = '支付超时'
-        oneDes.value = '订单已关闭'
-        stopPolling()
-        break
+const pay = () => {
+  if (!hasPay.value) {
+    Message.warning('暂无支付信息,请刷新页面重试')
+    return
+  }
+  Message.success('正在跳转支付页面,请在新页面完成支付')
+  openPaymentWindow(payData.value.payUrl, payData.value.payData)
+}
 
+function openPaymentWindow(payUrl, formData) {
+  const form = document.createElement('form')
+  form.method = 'POST'
+  form.action = payUrl
+  form.style.display = 'none'
+  for (const key in formData) {
+    if (Object.prototype.hasOwnProperty.call(formData, key)) {
+      const input = document.createElement('input')
+      input.type = 'hidden'
+      input.name = key
+      input.value = formData[key]
+      form.appendChild(input)
     }
   }
+  document.body.appendChild(form)
+  form.submit()
+  document.body.removeChild(form)
+}
+</script>
+
+<style lang="less" scoped>
+@import '@/styles/store-theme.less';
+
+.detail-stack {
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+  width: 100%;
+  max-width: 900px;
 }
 
-function getPaymentStatus(timestamp) {
-  const now = Date.now();
-  const diffMs = now - timestamp; // 毫秒差值
-  const diffSeconds = Math.floor(diffMs / 1000); // 秒差值
+.pay-banner {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  justify-content: space-between;
+  gap: 16px;
+  padding: 20px 24px;
+  background: linear-gradient(135deg, #fff7e6 0%, #ffe7ba 100%);
+  border: 1px solid #ffd591;
+  border-radius: @store-radius;
+
+  &__info {
+    display: flex;
+    align-items: center;
+    gap: 12px;
+  }
 
-  const timeout = 300;
+  &__icon {
+    font-size: 32px;
+    color: rgb(var(--orange-6));
+  }
 
-  if (diffSeconds > timeout) {
-    oneDes.value = "支付超时";
-  } else {
-    const remaining = timeout - diffSeconds;
-    const minutes = Math.floor(remaining / 60);
-    const seconds = remaining % 60;
-    oneDes.value = `请在${minutes}分${seconds.toString().padStart(2, '0')}秒内完成支付`;
+  &__title {
+    font-weight: 600;
+    font-size: 1.1rem;
+    color: @store-primary;
   }
-}
 
-const pay = () => {
-  Message.success('正在跳转支付页面...请在新页面内完成支付')
-  if (payData.value != {}) {
-    openPaymentWindow(payData.value.payUrl, payData.value.payData)
+  &__desc {
+    font-size: 0.85rem;
+    color: @store-text-muted;
+    margin-top: 4px;
   }
-}
 
-function openPaymentWindow(payUrl, payData) {
-  const form = document.createElement('form');
-  form.method = 'POST';
-  form.action = payUrl;
-  // form.target = '_blank'
-  form.style.display = 'none';
-
-  // 遍历 payData,将每一项添加为隐藏的 input
-  for (const key in payData) {
-    if (payData.hasOwnProperty(key)) {
-      const input = document.createElement('input');
-      input.type = 'hidden';
-      input.name = key;
-      input.value = payData[key];
-      form.appendChild(input);
-    }
+  &__btn {
+    border-radius: 999px;
+    flex-shrink: 0;
   }
+}
 
-  document.body.appendChild(form);
-  form.submit();
-  document.body.removeChild(form);
+.status-card,
+.info-card {
+  border-radius: @store-radius;
+  border: 1px solid @store-card-border;
+  box-shadow: @store-shadow;
 }
 
-</script>
+.steps {
+  max-width: 100%;
+  margin: 8px 0 16px;
+}
 
-<style lang="less" scoped>
-.container {
-  padding: 0 20px 20px 20px;
+.status-badge-wrap {
+  text-align: center;
 }
 
-.general-card {
-  border-radius: 5px;
+.price-text {
+  font-size: 1.15rem;
+  font-weight: 700;
+  color: @store-price;
+}
 
-  .step {
-    width: 900px;
-    margin: 30px auto;
-  }
+.discount-tag {
+  margin-left: 8px;
+  color: rgb(var(--green-6));
+  font-size: 0.9rem;
+}
 
-  .paybutton {
-    position: relative;
-    left: 50%;
-    translate: (-50%);
-    margin-top: 10px;
+.pay-row {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+}
+
+.rich-content {
+  line-height: 1.75;
+  color: @store-text-muted;
+
+  :deep(img) {
+    max-width: 100%;
   }
 }
-</style>
+
+.detail-actions {
+  display: flex;
+  gap: 12px;
+  flex-wrap: wrap;
+  margin-top: 8px;
+}
+</style>

+ 201 - 145
src/pages/store/orders/orderList/index.vue

@@ -1,126 +1,119 @@
 <template>
-
-  <div class="container">
-    <Breadcrumb />
-    <a-card title="我的订单">
-      <a-alert v-if="notice" style="margin-bottom: 15px;">{{ notice }}</a-alert>
-
-      <a-table :bordered="false" :data="data" stripe hoverable column-resizable class="table" :loading="loading"
-        :pagination="{ showPageSize: true, showJumper: true, defaultPageSize: 15 }">
-
-        <template #name-filter="{ filterValue, setFilterValue, handleFilterConfirm, handleFilterReset }">
-          <div class="custom-filter">
-            <a-space direction="vertical">
-              <a-input :model-value="filterValue[0]" @input="(value) => setFilterValue([value])" />
-              <div class="custom-filter-footer">
-                <a-button @click="handleFilterReset">重置</a-button>
-                <a-button @click="handleFilterConfirm">确定</a-button>
-              </div>
-            </a-space>
-          </div>
-        </template>
-
-        <template #columns>
-          <a-table-column title="订单号" :width="180" data-index="orderId" ellipsis tooltip :filterable="{
-            filter: (value, record) => (record.orderId ?? '').includes(value),
-            slotName: 'name-filter',
-            icon: () => h(IconSearch)
-          }"></a-table-column>
-          <a-table-column title="商品名称" :filterable="{
-            filter: (value, record) => (record.name ?? '').includes(value),
-            slotName: 'name-filter',
-            icon: () => h(IconSearch)
-          }">
-            <template #cell="{ record }">
-              {{ record.name }}
-            </template>
-          </a-table-column>
-          <a-table-column title="订单金额" ellipsis tooltip :sortable="{
-            sortDirections: ['ascend', 'descend']
-          }">
-            <template #cell="{ record }">
-              ¥ {{ record.price }}
-            </template>
-          </a-table-column>
-          <a-table-column title="支付方式" ellipsis tooltip>
-            <template #cell="{ record }">
-              <span>
-                <icon-wechatpay v-if="record.pay_type === 'wxpay'"/>
-                <icon-alipay-circle v-else-if="record.pay_type === 'alipay'"/>
-                <icon-qq v-else/>
-              </span>
-              {{ getPayType(record.pay_type) }}
-            </template>
-          </a-table-column>
-          <a-table-column title="订单状态" ellipsis tooltip>
-            <template #cell="{ record }">
-              <div v-if="record.state === 0" class="state">
-                <div class="circle zero"></div>待支付
-              </div>
-              <div v-else-if="record.state === 1" class="state">
-                <div class="circle one"></div>待处理
-              </div>
-              <div v-else-if="record.state === 2" class="state">
-                <div class="circle one"></div>已完成
+  <div class="store-page order-page">
+    <div class="order-page__inner">
+      <Breadcrumb />
+
+      <header class="page-header">
+        <div>
+          <h1 class="store-section-title">我的订单</h1>
+          <p class="store-section-desc">查看购买记录与支付状态</p>
+        </div>
+        <a-button type="outline" @click="$router.push('/store/goodsList')">
+          <template #icon><icon-gift /></template>
+          去商城
+        </a-button>
+      </header>
+
+      <a-alert v-if="notice" type="info" closable class="notice">{{ notice }}</a-alert>
+
+      <a-tabs v-model:active-key="statusTab" class="status-tabs">
+        <a-tab-pane v-for="tab in statusTabs" :key="tab.key" :title="tab.title" />
+      </a-tabs>
+
+      <a-spin :loading="loading" class="store-spin">
+        <a-empty v-if="!loading && displayList.length === 0" description="暂无相关订单">
+          <a-button type="primary" @click="$router.push('/store/goodsList')">去选购</a-button>
+        </a-empty>
+
+        <div v-else class="order-list">
+          <article
+            v-for="record in displayList"
+            :key="record.orderId"
+            class="order-card"
+            @click="goDetail(record.orderId)"
+          >
+            <div class="order-card__head">
+              <span class="order-id">{{ record.orderId }}</span>
+              <OrderStateTag :state="record.state" />
+            </div>
+            <div class="order-card__body">
+              <h3 class="goods-name">{{ record.name }}</h3>
+              <div class="order-card__meta">
+                <span class="price">¥{{ record.price }}</span>
+                <span class="pay-type">
+                  <icon-wechatpay v-if="record.pay_type === 'wxpay'" />
+                  <icon-alipay-circle v-else-if="record.pay_type === 'alipay'" />
+                  <icon-qq v-else-if="record.pay_type === 'qqpay'" />
+                  {{ getPayTypeLabel(record.pay_type) }}
+                </span>
               </div>
-              <div v-else class="state">
-                <div class="circle else"></div>已关闭
-              </div>
-              <!-- {{ getOrderState(record.state) }} -->
-            </template>
-          </a-table-column>
-          <a-table-column title="创建时间" ellipsis tooltip :sortable="{
-            sortDirections: ['ascend', 'descend']
-          }">
-            <template #cell="{ record }">
-              {{ stramptoTime(record.create_time) }}
-            </template>
-          </a-table-column>
-          <a-table-column title="" fixed="right" :width="100">
-            <template #cell="{ record }">
-              <a-button @click="$router.push(`/store/orderDetail/${record.orderId}`)">详情</a-button>
-            </template>
-
-          </a-table-column>
-        </template>
-      </a-table>
-    </a-card>
+              <time class="time">{{ formatStoreTime(record.create_time) }}</time>
+            </div>
+            <div class="order-card__actions">
+              <a-button
+                v-if="record.state === 0"
+                type="primary"
+                size="small"
+                @click.stop="goDetail(record.orderId)"
+              >
+                去支付
+              </a-button>
+              <a-button v-else type="text" size="small" @click.stop="goDetail(record.orderId)">
+                查看详情
+              </a-button>
+            </div>
+          </article>
+        </div>
+      </a-spin>
+    </div>
   </div>
-
 </template>
 
 <script setup>
-import { ref, h } from 'vue'
+import { ref, computed } from 'vue'
+import { useRouter, useRoute } from 'vue-router'
 import { getMyOrder } from '@/api/order'
 import { Notification } from '@arco-design/web-vue'
-import { IconSearch } from '@arco-design/web-vue/es/icon'
-import { useRoute } from 'vue-router'
+import OrderStateTag from '@/components/store/OrderStateTag.vue'
 import { getNotice } from '@/utils/util'
+import { formatStoreTime, getPayTypeLabel } from '@/utils/storeFormat'
 
+const router = useRouter()
 const notice = ref('')
-
-const GetNotice = async () => {
-  const { path } = useRoute()
-  const res = await getNotice(path)
-  notice.value = res
-}
-
 const data = ref([])
 const loading = ref(false)
+const statusTab = ref('all')
+
+const statusTabs = [
+  { key: 'all', title: '全部' },
+  { key: '0', title: '待支付' },
+  { key: '1', title: '待处理' },
+  { key: '2', title: '已完成' },
+  { key: '3', title: '已关闭' }
+]
+
+const displayList = computed(() => {
+  if (statusTab.value === 'all') return data.value
+  const state = Number(statusTab.value)
+  return data.value.filter((r) => r.state === state)
+})
+
+const goDetail = (orderId) => router.push(`/store/orderDetail/${orderId}`)
 
 const GetMyOrder = async () => {
   try {
     loading.value = true
     const res = await getMyOrder()
-    if (!res || res.code !== 0)
+    if (!res || res.code !== 0) {
       return Notification.error({
-        title: '获取订单列表失败!',
+        title: '获取订单列表失败',
         content: res?.msg ?? '请稍后再试'
       })
-    data.value = res.data
+    }
+    data.value = res.data ?? []
   } catch (error) {
     Notification.error({
-      title: '获取订单列表失败!',
+      title: '获取订单列表失败',
       content: error.message || '请稍后再试'
     })
   } finally {
@@ -128,70 +121,133 @@ const GetMyOrder = async () => {
   }
 }
 
-const stramptoTime = (time) => {
-  return new Date(time).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
-}
-
-function getPayType(type) {
-  switch (type) {
-    case 'alipay':
-      return '支付宝'
-    case 'qqpay':
-      return 'QQ支付'
-    case 'wxpay':
-      return '微信支付'
-  }
+const GetNotice = async () => {
+  const { path } = useRoute()
+  notice.value = await getNotice(path)
 }
 
 GetMyOrder()
 GetNotice()
 </script>
 
-<style scoped lang="less">
-.container {
-  padding: 0 20px 20px 20px;
+<style lang="less" scoped>
+@import '@/styles/store-theme.less';
+
+.order-page {
+  width: 100%;
+  max-width: none;
+  padding-left: 24px;
+  padding-right: 24px;
 }
 
-.table {
-  margin-top: 15px;
+.order-page__inner {
+  width: 100%;
+  max-width: 720px;
+  margin: 0 auto;
+}
+
+.page-header {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: flex-end;
+  justify-content: space-between;
+  gap: 16px;
+  margin-bottom: 20px;
+}
 
-  .state {
+.notice {
+  margin-bottom: 16px;
+  border-radius: @store-radius-sm;
+}
+
+.status-tabs {
+  width: 100%;
+
+  :deep(.arco-tabs-nav) {
+    justify-content: center;
+  }
+
+  :deep(.arco-tabs-content) {
+    width: 100%;
+  }
+}
+
+.order-list {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+  width: 100%;
+}
+
+.order-card {
+  width: 100%;
+  box-sizing: border-box;
+  background: @store-card-bg;
+  border: 1px solid @store-card-border;
+  border-radius: @store-radius;
+  padding: 16px 20px;
+  cursor: pointer;
+  transition: box-shadow 0.2s, border-color 0.2s;
+
+  &:hover {
+    box-shadow: @store-shadow;
+    border-color: fade(@store-accent, 40%);
+  }
+
+  &__head {
     display: flex;
     align-items: center;
+    justify-content: space-between;
+    margin-bottom: 12px;
+    padding-bottom: 12px;
+    border-bottom: 1px dashed @store-card-border;
+  }
 
-    .circle {
-      border-radius: 50%;
-      height: 8px;
-      min-height: 8px;
-      width: 8px;
-      min-width: 8px;
-      margin-right: 5px;
-    }
+  .order-id {
+    font-size: 0.8rem;
+    color: @store-text-muted;
+    font-family: ui-monospace, monospace;
+  }
 
-    .zero {
-      background-color: rgb(var(--orange-6));
+  &__body {
+    .goods-name {
+      margin: 0 0 8px;
+      font-size: 1.05rem;
+      font-weight: 600;
+      color: @store-primary;
     }
+  }
 
-    .one {
-      background-color: rgb(var(--green-6));
+  &__meta {
+    display: flex;
+    align-items: center;
+    gap: 16px;
+    margin-bottom: 6px;
+
+    .price {
+      font-size: 1.2rem;
+      font-weight: 700;
+      color: @store-price;
     }
 
-    .else {
-      background-color: rgb(var(--red-6));
+    .pay-type {
+      display: inline-flex;
+      align-items: center;
+      gap: 4px;
+      font-size: 0.85rem;
+      color: @store-text-muted;
     }
   }
-}
 
-.custom-filter {
-  padding: 20px;
-  background: var(--color-bg-5);
-  border: 1px solid var(--color-neutral-3);
-  border-radius: var(--border-radius-medium);
-  box-shadow: 0 2px 5px rgb(0 0 0 / 10%);
-}
+  .time {
+    font-size: 0.8rem;
+    color: @store-text-muted;
+  }
 
-.custom-filter-footer {
-  display: flex;
-  justify-content: space-between;
+  &__actions {
+    margin-top: 12px;
+    display: flex;
+    justify-content: flex-end;
+  }
 }
-</style>
+</style>