This commit is contained in:
yml2213
2026-08-20 14:17:59 +08:00
commit d591df5033
34 changed files with 7555 additions and 0 deletions
+193
View File
@@ -0,0 +1,193 @@
<template>
<div class="max-w-site mx-auto px-4 py-8 space-y-6">
<!-- Title -->
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<h1 class="text-2xl font-bold text-gray-900">我的购物车</h1>
<span class="text-xs text-gray-400">({{ cartStore.validList.length }} 件商品)</span>
</div>
<router-link to="/products" class="text-xs text-primary font-semibold hover:underline">
&lt; 继续选购
</router-link>
</div>
<!-- Cart Main Content -->
<div v-if="cartStore.validList.length > 0" class="space-y-4">
<!-- Cart Table -->
<div class="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden">
<!-- Table Header -->
<div class="grid grid-cols-12 gap-4 px-6 py-4 bg-gray-50 border-b border-gray-100 text-xs font-semibold text-gray-500">
<div class="col-span-1 flex items-center">
<el-checkbox v-model="isAllSelected" @change="toggleSelectAll">全选</el-checkbox>
</div>
<div class="col-span-5">商品信息</div>
<div class="col-span-2 text-center">单价</div>
<div class="col-span-2 text-center">数量</div>
<div class="col-span-1 text-center">小计</div>
<div class="col-span-1 text-right">操作</div>
</div>
<!-- Table Rows -->
<div class="divide-y divide-gray-100">
<div
v-for="item in cartStore.validList"
:key="item.id"
class="grid grid-cols-12 gap-4 px-6 py-5 items-center hover:bg-gray-50/50 transition-colors"
>
<!-- Checkbox -->
<div class="col-span-1">
<el-checkbox v-model="item.selected" />
</div>
<!-- Product info -->
<div class="col-span-5 flex items-center space-x-4">
<router-link :to="`/product/${item.product_id}`" class="w-20 h-20 rounded-xl overflow-hidden bg-gray-50 border border-gray-100 flex-shrink-0">
<img :src="item.productInfo?.attrInfo?.image || item.productInfo?.image" class="w-full h-full object-cover" />
</router-link>
<div class="space-y-1">
<router-link :to="`/product/${item.product_id}`" class="text-xs font-bold text-gray-800 hover:text-primary line-clamp-2 leading-relaxed">
{{ item.productInfo?.store_name }}
</router-link>
<p v-if="item.productInfo?.attrInfo?.suk" class="text-[11px] text-gray-400 bg-gray-100 px-2 py-0.5 rounded inline-block">
规格{{ item.productInfo?.attrInfo?.suk }}
</p>
</div>
</div>
<!-- Unit Price -->
<div class="col-span-2 text-center text-xs font-semibold text-gray-700">
¥{{ item.truePrice || item.productInfo?.attrInfo?.price || item.productInfo?.price }}
</div>
<!-- Quantity Stepper -->
<div class="col-span-2 flex justify-center">
<el-input-number
v-model="item.cart_num"
:min="1"
:max="item.productInfo?.attrInfo?.stock || item.productInfo?.stock || 99"
size="small"
@change="(val) => handleNumChange(item.id, val)"
/>
</div>
<!-- Subtotal -->
<div class="col-span-1 text-center text-xs font-bold text-primary">
¥{{ ((item.truePrice || item.productInfo?.attrInfo?.price || item.productInfo?.price) * item.cart_num).toFixed(2) }}
</div>
<!-- Delete Action -->
<div class="col-span-1 text-right">
<button @click="handleDelete(item.id)" class="text-xs text-gray-400 hover:text-primary transition-colors">
删除
</button>
</div>
</div>
</div>
</div>
<!-- Bottom Summary Bar -->
<div class="bg-white rounded-2xl p-6 shadow-sm border border-gray-100 flex items-center justify-between sticky bottom-4 z-30">
<div class="flex items-center space-x-6 text-xs text-gray-500">
<el-checkbox v-model="isAllSelected" @change="toggleSelectAll">全选</el-checkbox>
<button @click="batchDelete" class="hover:text-primary transition-colors">
删除选中商品
</button>
</div>
<div class="flex items-center space-x-6">
<div class="text-right">
<span class="text-xs text-gray-500">已选择 <strong class="text-primary font-bold">{{ selectedCount }}</strong> 件商品合计</span>
<span class="text-2xl font-extrabold text-primary ml-2">¥{{ totalPrice.toFixed(2) }}</span>
</div>
<button
:disabled="selectedCount === 0"
@click="handleCheckout"
class="px-8 py-3.5 bg-primary hover:bg-primary-hover disabled:bg-gray-300 text-white font-bold text-sm rounded-xl shadow-lg shadow-red-200 transition-all"
>
去结算 ({{ selectedCount }})
</button>
</div>
</div>
</div>
<!-- Empty State -->
<div v-else class="bg-white rounded-2xl p-20 text-center shadow-sm border border-gray-100">
<div class="text-6xl mb-4">🛒</div>
<h3 class="text-lg font-bold text-gray-800">您的购物车还是空的</h3>
<p class="text-xs text-gray-400 mt-1 mb-6">快去挑选您心仪的优质好物吧</p>
<router-link to="/products" class="inline-block px-8 py-3 bg-primary text-white text-xs font-bold rounded-xl hover:bg-primary-hover shadow-md shadow-red-200 transition-all">
立即去逛逛
</router-link>
</div>
</div>
</template>
<script setup>
import { computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useCartStore } from '@/store/cart'
import { ElMessageBox, ElMessage } from 'element-plus'
const router = useRouter()
const cartStore = useCartStore()
const isAllSelected = computed({
get() {
return cartStore.validList.length > 0 && cartStore.validList.every(i => i.selected)
},
set(val) {
cartStore.validList.forEach(i => i.selected = val)
}
})
const selectedItems = computed(() => cartStore.validList.filter(i => i.selected))
const selectedCount = computed(() => selectedItems.value.reduce((acc, cur) => acc + cur.cart_num, 0))
const totalPrice = computed(() => {
return selectedItems.value.reduce((acc, cur) => {
const price = cur.truePrice || cur.productInfo?.attrInfo?.price || cur.productInfo?.price || 0
return acc + (price * cur.cart_num)
}, 0)
})
const toggleSelectAll = (val) => {
cartStore.validList.forEach(i => i.selected = val)
}
const handleNumChange = async (id, num) => {
await cartStore.updateCartNum(id, num)
}
const handleDelete = (id) => {
ElMessageBox.confirm('确定要从购物车中移除该商品吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
await cartStore.removeCartItems([id])
})
}
const batchDelete = () => {
const ids = selectedItems.value.map(i => i.id)
if (!ids.length) {
ElMessage.warning('请选择需要删除的商品')
return
}
ElMessageBox.confirm(`确定要删除选中的 ${ids.length} 件商品吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
await cartStore.removeCartItems(ids)
})
}
const handleCheckout = () => {
const cartIds = selectedItems.value.map(i => i.id).join(',')
router.push(`/checkout?cartId=${cartIds}`)
}
onMounted(() => {
cartStore.fetchCartList()
})
</script>
+253
View File
@@ -0,0 +1,253 @@
<template>
<div class="max-w-site mx-auto px-4 py-8 space-y-6">
<h1 class="text-2xl font-bold text-gray-900">填写并核对订单信息</h1>
<div v-loading="loading" class="space-y-6">
<!-- 1. Address Section -->
<div class="bg-white rounded-2xl p-6 shadow-sm border border-gray-100 space-y-4">
<div class="flex items-center justify-between pb-3 border-b border-gray-100">
<h3 class="text-sm font-bold text-gray-900 flex items-center space-x-2">
<span>📍 收货地址</span>
</h3>
<button @click="openAddressModal" class="text-xs text-primary font-semibold hover:underline">
+ 新增收货地址
</button>
</div>
<div v-if="addressList.length > 0" class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div
v-for="addr in addressList"
:key="addr.id"
@click="selectedAddressId = addr.id"
:class="selectedAddressId === addr.id ? 'border-primary bg-red-50/40 ring-2 ring-red-100' : 'border-gray-200 hover:border-gray-300'"
class="p-4 rounded-xl border-2 cursor-pointer transition-all relative space-y-1.5 text-xs"
>
<div class="flex items-center justify-between font-bold text-gray-800">
<span>{{ addr.real_name }} ({{ addr.phone }})</span>
<span v-if="addr.is_default" class="bg-red-100 text-primary text-[10px] px-1.5 py-0.5 rounded">默认</span>
</div>
<p class="text-gray-500 leading-relaxed">
{{ addr.province }} {{ addr.city }} {{ addr.district }} {{ addr.detail }}
</p>
</div>
</div>
<div v-else class="text-xs text-gray-400 py-2">
暂无可用收货地址请先点击右上角新增
</div>
</div>
<!-- 2. Order Items List -->
<div class="bg-white rounded-2xl p-6 shadow-sm border border-gray-100 space-y-4">
<h3 class="text-sm font-bold text-gray-900 pb-3 border-b border-gray-100">
🛍 商品清单
</h3>
<div class="divide-y divide-gray-100">
<div
v-for="item in cartInfo"
:key="item.id"
class="py-3 flex items-center justify-between text-xs"
>
<div class="flex items-center space-x-4">
<img :src="item.productInfo?.attrInfo?.image || item.productInfo?.image" class="w-14 h-14 rounded-lg object-cover border border-gray-100" />
<div>
<h4 class="font-bold text-gray-800">{{ item.productInfo?.store_name }}</h4>
<p v-if="item.productInfo?.attrInfo?.suk" class="text-gray-400 mt-0.5">规格{{ item.productInfo?.attrInfo?.suk }}</p>
</div>
</div>
<div class="text-right space-y-1">
<span class="font-bold text-gray-900">¥{{ item.truePrice || item.productInfo?.price }} x {{ item.cart_num }}</span>
<p class="text-primary font-bold">小计¥{{ ((item.truePrice || item.productInfo?.price) * item.cart_num).toFixed(2) }}</p>
</div>
</div>
</div>
</div>
<!-- 3. Payment Method -->
<div class="bg-white rounded-2xl p-6 shadow-sm border border-gray-100 space-y-4">
<h3 class="text-sm font-bold text-gray-900 pb-3 border-b border-gray-100">
💳 支付方式
</h3>
<div class="flex flex-wrap gap-4 text-xs">
<button
@click="payType = 'weixin'"
:class="payType === 'weixin' ? 'border-primary bg-red-50/40 text-primary font-bold' : 'border-gray-200 text-gray-700'"
class="px-6 py-3 rounded-xl border-2 flex items-center space-x-2 transition-all"
>
<span>🟢 微信扫码支付</span>
</button>
<button
@click="payType = 'yue'"
:class="payType === 'yue' ? 'border-primary bg-red-50/40 text-primary font-bold' : 'border-gray-200 text-gray-700'"
class="px-6 py-3 rounded-xl border-2 flex items-center space-x-2 transition-all"
>
<span>💰 余额支付</span>
</button>
</div>
</div>
<!-- 4. Summary & Submit -->
<div class="bg-white rounded-2xl p-6 shadow-sm border border-gray-100 flex flex-col items-end space-y-4">
<div class="space-y-2 text-xs text-gray-600 text-right w-64">
<div class="flex justify-between">
<span>商品总金额</span>
<span class="font-bold text-gray-900">¥{{ (orderData.total_price || 0).toFixed(2) }}</span>
</div>
<div class="flex justify-between">
<span>运费</span>
<span>+ ¥{{ (orderData.pay_postage || 0).toFixed(2) }}</span>
</div>
<div class="flex justify-between pt-2 border-t border-gray-100 text-sm font-bold text-gray-900">
<span>应付总额</span>
<span class="text-2xl font-extrabold text-primary">¥{{ (orderData.pay_price || orderData.total_price || 0).toFixed(2) }}</span>
</div>
</div>
<button
:disabled="submitting || !selectedAddressId"
@click="submitOrder"
class="px-10 py-4 bg-primary hover:bg-primary-hover disabled:bg-gray-300 text-white font-bold text-base rounded-xl shadow-xl shadow-red-200 transition-all flex items-center space-x-2"
>
<span v-if="submitting" class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></span>
<span>立即提交订单并支付</span>
</button>
</div>
</div>
<!-- Payment QR Code Modal -->
<el-dialog
v-model="showPayModal"
title="微信收银台"
width="380px"
:align-center="true"
destroy-on-close
>
<div class="text-center py-4 space-y-4">
<div class="text-2xl font-extrabold text-primary">¥{{ (orderData.pay_price || orderData.total_price || 0).toFixed(2) }}</div>
<div class="w-48 h-48 mx-auto bg-gray-50 border border-gray-200 rounded-xl p-2 flex items-center justify-center">
<img v-if="payQrUrl" :src="payQrUrl" class="w-full h-full object-contain" />
<span v-else class="text-xs text-gray-400">正在生成支付二维码...</span>
</div>
<p class="text-xs text-gray-500">请使用微信扫一扫完成支付</p>
<button @click="handlePayFinished" class="w-full py-2.5 bg-primary text-white font-bold text-xs rounded-lg hover:bg-primary-hover">
我已完成支付
</button>
</div>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { orderConfirm, getAddressList, orderCreate, checkOrderStatus } from '@/api'
import { ElMessage } from 'element-plus'
const route = useRoute()
const router = useRouter()
const cartId = route.query.cartId
const loading = ref(false)
const submitting = ref(false)
const orderKey = ref('')
const cartInfo = ref([])
const orderData = ref({})
const addressList = ref([])
const selectedAddressId = ref(0)
const payType = ref('weixin')
const showPayModal = ref(false)
const payQrUrl = ref('')
let payPollTimer = null
const fetchOrderConfirm = async () => {
if (!cartId) {
ElMessage.warning('缺少订单商品信息')
router.push('/cart')
return
}
loading.value = true
try {
const res = await orderConfirm(cartId)
orderKey.value = res.orderKey
cartInfo.value = res.cartInfo || []
orderData.value = res.priceGroup || {}
addressList.value = res.addressInfo ? [res.addressInfo] : []
// Fetch all addresses
const addrRes = await getAddressList({ page: 1, limit: 20 })
if (addrRes && addrRes.list) {
addressList.value = addrRes.list
const def = addrRes.list.find(i => i.is_default) || addrRes.list[0]
if (def) selectedAddressId.value = def.id
}
} catch (e) {
// ignore
} finally {
loading.value = false
}
}
const openAddressModal = () => {
router.push('/user?tab=address')
}
const submitOrder = async () => {
if (!selectedAddressId.value) {
ElMessage.warning('请选择收货地址')
return
}
submitting.value = true
try {
const res = await orderCreate(orderKey.value, {
addressId: selectedAddressId.value,
payType: payType.value,
from: 'pc'
})
if (payType.value === 'yue') {
ElMessage.success('余额支付成功!')
router.push('/user?tab=orders')
} else {
// WeChat QR Code
const code = res.result?.jsConfig?.code_url || res.result?.code_url
payQrUrl.value = code ? `https://api.qrserver.com/v1/create-qr-code/?size=220x220&data=${encodeURIComponent(code)}` : ''
showPayModal.value = true
// Poll order status
const orderId = res.result?.order_id
if (orderId) {
payPollTimer = setInterval(async () => {
try {
const check = await checkOrderStatus(orderId, Date.now())
if (check && check.status) {
clearInterval(payPollTimer)
ElMessage.success('支付成功!')
showPayModal.value = false
router.push('/user?tab=orders')
}
} catch (err) {
// ignore
}
}, 3000)
}
}
} catch (e) {
// handled in interceptor
} finally {
submitting.value = false
}
}
const handlePayFinished = () => {
clearInterval(payPollTimer)
showPayModal.value = false
router.push('/user?tab=orders')
}
onMounted(() => {
fetchOrderConfirm()
})
</script>
+523
View File
@@ -0,0 +1,523 @@
<template>
<div class="space-y-4 sm:space-y-8 pb-12 sm:pb-16">
<!-- Hero Section -->
<section class="max-w-site mx-auto px-3 sm:px-4 pt-2 sm:pt-4">
<!-- Desktop 3-Column Grid -->
<div class="hidden lg:grid grid-cols-12 gap-4 h-[440px]">
<!-- Left: Cascading Categories Sidebar -->
<div class="col-span-3 bg-white rounded-2xl shadow-sm border border-gray-100 p-3 h-full flex flex-col justify-between relative group/cat z-20">
<div class="space-y-1 overflow-y-auto pr-1">
<div
v-for="cat in categories.slice(0, 10)"
:key="cat.id"
@mouseenter="hoverCat = cat"
class="flex items-center justify-between px-3 py-2 rounded-xl text-sm text-gray-700 hover:text-primary hover:bg-red-50/60 cursor-pointer transition-colors"
>
<div class="flex items-center space-x-2.5">
<img v-if="cat.pic" :src="cat.pic" class="w-5 h-5 object-contain rounded" />
<span class="font-medium text-xs">{{ cat.cate_name }}</span>
</div>
<svg class="w-3.5 h-3.5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg>
</div>
</div>
<router-link to="/products" class="text-xs text-center py-2 text-primary font-semibold hover:underline border-t border-gray-100 flex items-center justify-center space-x-1">
<span>查看全部分类</span>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3"></path></svg>
</router-link>
<!-- Flyout Subcategory Panel on Hover -->
<div
v-if="hoverCat && hoverCat.children && hoverCat.children.length"
class="absolute left-full top-0 ml-2 w-[480px] h-[440px] bg-white rounded-2xl shadow-2xl border border-gray-100 p-6 z-30 overflow-y-auto hidden group-hover/cat:block"
>
<h4 class="font-bold text-gray-900 mb-4 pb-2 border-b border-gray-100 text-sm flex items-center justify-between">
<span>{{ hoverCat.cate_name }}</span>
<router-link :to="`/products?cid=${hoverCat.id}`" class="text-xs text-primary font-normal hover:underline">进入分类列表 &gt;</router-link>
</h4>
<div class="grid grid-cols-3 gap-3">
<router-link
v-for="sub in hoverCat.children"
:key="sub.id"
:to="`/products?sid=${sub.id}`"
class="p-2.5 rounded-xl border border-gray-100 hover:border-red-200 hover:bg-red-50/40 text-center transition-all group flex flex-col items-center"
>
<img :src="sub.pic || 'https://via.placeholder.com/60x60'" class="w-12 h-12 object-contain mb-1.5 group-hover:scale-110 transition-transform" />
<span class="text-xs text-gray-700 font-medium group-hover:text-primary">{{ sub.cate_name }}</span>
</router-link>
</div>
</div>
</div>
<!-- Middle: Banner Carousel -->
<div class="col-span-6 h-full rounded-2xl overflow-hidden shadow-sm">
<el-carousel trigger="click" height="440px" class="h-full rounded-2xl">
<el-carousel-item v-for="(item, idx) in banners" :key="idx">
<div class="w-full h-full relative cursor-pointer" @click="handleBannerClick(item)">
<img :src="item.pic || item.img" class="w-full h-full object-cover" />
<div class="absolute inset-0 bg-gradient-to-t from-black/50 via-transparent to-transparent flex items-end p-8">
<div>
<span class="px-2.5 py-1 bg-primary text-white text-xs font-bold rounded-lg mb-2 inline-block">精选推荐</span>
<h3 class="text-2xl font-bold text-white tracking-wide">{{ item.name || '大锤网络 · 数码狂欢' }}</h3>
</div>
</div>
</div>
</el-carousel-item>
</el-carousel>
</div>
<!-- Right: User Quick Widget & Bulletins -->
<div class="col-span-3 bg-white rounded-2xl shadow-sm border border-gray-100 p-5 h-full flex flex-col justify-between">
<!-- User Profile Brief -->
<div class="text-center pb-4 border-b border-gray-100">
<img
:src="userStore.userInfo?.avatar || 'https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png'"
class="w-16 h-16 rounded-full mx-auto object-cover border-2 border-red-100 shadow-sm"
/>
<p class="text-sm font-bold text-gray-800 mt-2">
{{ userStore.token ? (userStore.userInfo?.nickname || userStore.userInfo?.account || '尊贵会员') : 'Hi,欢迎来到 大锤网络' }}
</p>
<p class="text-xs text-gray-400 mt-0.5">正品大牌 · 全场包邮 · 售后无忧</p>
<div class="flex items-center justify-center gap-3 mt-3">
<template v-if="userStore.token">
<router-link to="/user" class="px-4 py-1.5 bg-red-50 text-primary text-xs font-semibold rounded-full hover:bg-red-100 transition-colors">
个人中心
</router-link>
<router-link to="/cart" class="px-4 py-1.5 bg-gray-100 text-gray-700 text-xs font-semibold rounded-full hover:bg-gray-200 transition-colors">
购物车 ({{ cartStore.cartCount }})
</router-link>
</template>
<template v-else>
<button @click="userStore.openAuthModal('login')" class="px-4 py-1.5 bg-primary text-white text-xs font-bold rounded-full hover:bg-primary-hover transition-colors shadow-sm">
登录
</button>
<button @click="userStore.openAuthModal('register')" class="px-4 py-1.5 bg-gray-100 text-gray-700 text-xs font-bold rounded-full hover:bg-gray-200 transition-colors">
注册
</button>
</template>
</div>
</div>
<!-- Fast Features Icons -->
<div class="grid grid-cols-3 gap-2 py-3 border-b border-gray-100 text-center">
<router-link to="/user?tab=orders" class="p-2 rounded-xl hover:bg-gray-50 transition-colors flex flex-col items-center">
<span class="text-lg">📦</span>
<span class="text-[11px] text-gray-600 mt-1">待发货</span>
</router-link>
<router-link to="/user?tab=collect" class="p-2 rounded-xl hover:bg-gray-50 transition-colors flex flex-col items-center">
<span class="text-lg"></span>
<span class="text-[11px] text-gray-600 mt-1">收藏夹</span>
</router-link>
<router-link to="/user?tab=coupons" class="p-2 rounded-xl hover:bg-gray-50 transition-colors flex flex-col items-center">
<span class="text-lg">🎟</span>
<span class="text-[11px] text-gray-600 mt-1">优惠券</span>
</router-link>
</div>
<!-- News / Bulletin -->
<div>
<div class="flex items-center justify-between text-xs font-bold text-gray-800 mb-2">
<span>商城快讯</span>
<router-link to="/news" class="text-gray-400 hover:text-primary font-normal">更多 &gt;</router-link>
</div>
<ul class="space-y-1.5 text-xs text-gray-600">
<li v-for="(item, idx) in newsList.slice(0, 3)" :key="idx" class="truncate">
<router-link :to="`/news/${item.id}`" class="hover:text-primary transition-colors flex items-center space-x-1">
<span class="w-1.5 h-1.5 rounded-full bg-primary flex-shrink-0"></span>
<span class="truncate">{{ item.title }}</span>
</router-link>
</li>
</ul>
</div>
</div>
</div>
<!-- Mobile Full-Width Banner (Visible on mobile/tablet) -->
<div class="lg:hidden">
<el-carousel trigger="click" height="175px" class="rounded-2xl overflow-hidden shadow-sm">
<el-carousel-item v-for="(item, idx) in banners" :key="idx">
<div class="w-full h-full relative cursor-pointer" @click="handleBannerClick(item)">
<img :src="item.pic || item.img" class="w-full h-full object-cover" />
<div class="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent flex items-end p-4">
<h3 class="text-sm font-bold text-white line-clamp-1">{{ item.name || '大锤网络 · 爆款数码狂欢' }}</h3>
</div>
</div>
</el-carousel-item>
</el-carousel>
<!-- Mobile 8-Grid Quick Navigation -->
<div class="grid grid-cols-4 gap-2.5 mt-3 bg-white p-3.5 rounded-2xl shadow-sm border border-gray-100 text-center">
<router-link to="/rental" class="flex flex-col items-center space-y-1">
<div class="w-10 h-10 rounded-xl bg-indigo-50 flex items-center justify-center text-lg">💻</div>
<span class="text-[11px] font-bold text-gray-800">租电脑</span>
</router-link>
<router-link to="/rental" class="flex flex-col items-center space-y-1">
<div class="w-10 h-10 rounded-xl bg-purple-50 flex items-center justify-center text-lg">📱</div>
<span class="text-[11px] font-bold text-gray-800">租手机</span>
</router-link>
<router-link to="/rental" class="flex flex-col items-center space-y-1">
<div class="w-10 h-10 rounded-xl bg-blue-50 flex items-center justify-center text-lg">📟</div>
<span class="text-[11px] font-bold text-gray-800">租iPad</span>
</router-link>
<router-link to="/rental" class="flex flex-col items-center space-y-1">
<div class="w-10 h-10 rounded-xl bg-orange-50 flex items-center justify-center text-lg">🎧</div>
<span class="text-[11px] font-bold text-gray-800">租影音</span>
</router-link>
<router-link to="/products?type=4" class="flex flex-col items-center space-y-1">
<div class="w-10 h-10 rounded-xl bg-red-50 flex items-center justify-center text-lg"></div>
<span class="text-[11px] font-medium text-gray-700">秒杀专区</span>
</router-link>
<router-link to="/products?type=1" class="flex flex-col items-center space-y-1">
<div class="w-10 h-10 rounded-xl bg-emerald-50 flex items-center justify-center text-lg">🌟</div>
<span class="text-[11px] font-medium text-gray-700">精品推荐</span>
</router-link>
<router-link to="/products?type=2" class="flex flex-col items-center space-y-1">
<div class="w-10 h-10 rounded-xl bg-amber-50 flex items-center justify-center text-lg">🔥</div>
<span class="text-[11px] font-medium text-gray-700">热门榜单</span>
</router-link>
<router-link to="/news" class="flex flex-col items-center space-y-1">
<div class="w-10 h-10 rounded-xl bg-slate-50 flex items-center justify-center text-lg">📰</div>
<span class="text-[11px] font-medium text-gray-700">商城资讯</span>
</router-link>
</div>
</div>
</section>
<!-- Equipment Rental Showcase (免押设备租赁专区) -->
<section class="max-w-site mx-auto px-3 sm:px-4">
<div class="bg-gradient-to-r from-slate-900 via-indigo-950 to-slate-900 rounded-2xl sm:rounded-3xl p-4 sm:p-7 text-white shadow-xl relative overflow-hidden">
<!-- Glow effects -->
<div class="absolute -right-10 -top-10 w-72 h-72 bg-indigo-500/20 rounded-full blur-3xl pointer-events-none"></div>
<div class="flex items-center justify-between pb-4 sm:pb-6 border-b border-indigo-900/60 gap-2">
<div class="space-y-0.5 sm:space-y-1">
<div class="flex items-center space-x-2 sm:space-x-3">
<span class="px-2 py-0.5 bg-gradient-to-r from-red-500 to-orange-500 text-white font-extrabold text-[10px] sm:text-xs rounded-md shadow-sm">
全新免押模式
</span>
<h2 class="text-base sm:text-2xl font-black tracking-tight text-white flex items-center">
💻 数码设备租赁专区
</h2>
</div>
<p class="hidden sm:block text-xs text-indigo-200/70">
免押金租电脑旗舰手机iPad平板 · 随租随还 · 企个两用 · 顺丰速达 · 租完即送
</p>
</div>
<router-link
to="/rental"
class="px-3 sm:px-5 py-1.5 sm:py-2 bg-indigo-600 hover:bg-indigo-500 text-white font-bold text-xs rounded-xl shadow-md shadow-indigo-600/30 transition-all whitespace-nowrap"
>
<span>进入大厅 &gt;</span>
</router-link>
</div>
<!-- Rental Items Grid (2 cols on mobile, 4 on desktop) -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-2.5 sm:gap-4 mt-4 sm:mt-6">
<div
v-for="item in featuredRentals"
:key="item.id"
class="bg-white/10 backdrop-blur-md rounded-xl sm:rounded-2xl p-2.5 sm:p-4 border border-white/15 hover:border-indigo-400 hover:bg-white/15 transition-all flex flex-col justify-between group"
>
<div>
<div class="relative aspect-video rounded-lg sm:rounded-xl overflow-hidden bg-slate-800/80 mb-2 sm:mb-3">
<img :src="item.image" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" />
<span class="absolute top-1.5 left-1.5 px-1.5 py-0.5 text-[9px] sm:text-[10px] font-bold bg-indigo-600 text-white rounded shadow-sm">
{{ item.tag }}
</span>
<span class="absolute bottom-1.5 right-1.5 px-1.5 py-0.5 text-[9px] sm:text-[10px] bg-black/60 text-white rounded backdrop-blur-sm">
{{ item.condition }}
</span>
</div>
<h3 class="text-xs font-bold text-white line-clamp-1 group-hover:text-indigo-300 transition-colors">
{{ item.title }}
</h3>
<p class="hidden sm:block text-[11px] text-gray-300 line-clamp-1 mt-1">{{ item.desc }}</p>
</div>
<div class="pt-2 sm:pt-3 mt-2 sm:mt-3 border-t border-white/10 flex items-center justify-between">
<div>
<span class="text-[9px] sm:text-[10px] text-gray-400">低至 </span>
<span class="text-sm sm:text-lg font-black text-orange-400">¥{{ item.dailyPrice }}</span>
<span class="text-[9px] sm:text-[10px] text-gray-300">/</span>
</div>
<router-link
to="/rental"
class="px-2 sm:px-3 py-1 sm:py-1.5 bg-gradient-to-r from-red-500 to-orange-500 text-white text-[10px] sm:text-[11px] font-bold rounded-lg transition-all"
>
立即租
</router-link>
</div>
</div>
</div>
</div>
</section>
<!-- Flash Sale / Hot Section -->
<section class="max-w-site mx-auto px-3 sm:px-4">
<div class="bg-gradient-to-r from-red-600 to-orange-500 rounded-2xl p-3.5 sm:p-6 text-white shadow-lg">
<div class="flex items-center justify-between mb-3 sm:mb-5">
<div class="flex items-center space-x-2 sm:space-x-3">
<span class="text-base sm:text-2xl font-black tracking-tight flex items-center">
限时秒杀 / 特惠抢购
</span>
<span class="hidden sm:inline-block bg-white/20 text-xs px-2.5 py-1 rounded-full backdrop-blur-sm">爆款抄底价</span>
</div>
<router-link to="/products?type=4" class="text-[11px] sm:text-xs bg-white text-primary font-bold px-3 sm:px-4 py-1 sm:py-1.5 rounded-full hover:bg-red-50 transition-colors shadow-sm">
查看更多 &gt;
</router-link>
</div>
<!-- Flash sale product cards -->
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-2.5 sm:gap-4">
<ProductCard
v-for="item in benefitProducts.slice(0, 5)"
:key="item.id"
:product="item"
/>
</div>
</div>
</section>
<!-- Tabbed Recommendations (精品推荐 / 热门榜单 / 首发新品) -->
<section class="max-w-site mx-auto px-3 sm:px-4">
<div class="bg-white rounded-2xl p-3.5 sm:p-6 shadow-sm border border-gray-100">
<div class="flex items-center justify-between mb-4 sm:mb-6 pb-3 sm:pb-4 border-b border-gray-100">
<div class="flex items-center space-x-3 sm:space-x-8">
<h3 class="text-base sm:text-xl font-bold text-gray-900">优选精选</h3>
<div class="flex items-center space-x-2 sm:space-x-4 text-xs sm:text-sm font-semibold">
<button
@click="switchRecommendTab(1)"
:class="recommendTab === 1 ? 'text-primary border-b-2 border-primary pb-1' : 'text-gray-400 hover:text-gray-700'"
>
精品推荐
</button>
<button
@click="switchRecommendTab(2)"
:class="recommendTab === 2 ? 'text-primary border-b-2 border-primary pb-1' : 'text-gray-400 hover:text-gray-700'"
>
热门榜单
</button>
<button
@click="switchRecommendTab(3)"
:class="recommendTab === 3 ? 'text-primary border-b-2 border-primary pb-1' : 'text-gray-400 hover:text-gray-700'"
>
首发新品
</button>
</div>
</div>
<router-link :to="`/products?type=${recommendTab}`" class="text-xs text-primary font-semibold hover:underline">
全部 &gt;
</router-link>
</div>
<div v-loading="recommendLoading" class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-2.5 sm:gap-4 min-h-[260px]">
<ProductCard
v-for="item in recommendList"
:key="item.id"
:product="item"
/>
</div>
</div>
</section>
<!-- Category Floors -->
<section v-for="floor in categoryFloors" :key="floor.id" class="max-w-site mx-auto px-3 sm:px-4">
<div class="bg-white rounded-2xl p-3.5 sm:p-6 shadow-sm border border-gray-100">
<div class="flex items-center justify-between mb-3 sm:mb-6">
<div class="flex items-center space-x-2 sm:space-x-3">
<div class="w-2 sm:w-2.5 h-4 sm:h-6 bg-primary rounded-full"></div>
<h3 class="text-sm sm:text-xl font-bold text-gray-900">{{ floor.cate_name }}</h3>
</div>
<router-link :to="`/products?cid=${floor.id}`" class="text-xs text-gray-400 hover:text-primary font-medium">
更多 &gt;
</router-link>
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-2.5 sm:gap-4">
<ProductCard
v-for="item in (floor.productList || floor.products || []).slice(0, 5)"
:key="item.id"
:product="item"
/>
</div>
</div>
</section>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/store/user'
import { useCartStore } from '@/store/cart'
import ProductCard from '@/components/ProductCard.vue'
import {
getBanner,
getCategory,
getRecommendList,
getCategoryProduct,
getNewsList
} from '@/api'
import {
mockCategories,
mockBanners,
mockProducts,
mockRecommendProducts
} from '@/api/mock'
const router = useRouter()
const userStore = useUserStore()
const cartStore = useCartStore()
const banners = ref(mockBanners)
const featuredRentals = ref([
{
id: 1,
title: 'MacBook Pro 14英寸 M3 Max 芯片 / 36G / 1TB 剪辑办公本',
desc: '剪辑渲染办公神器 · 顺丰包邮 · 芝麻免押',
dailyPrice: 18.8,
image: 'https://images.unsplash.com/photo-1517336714731-489689fd1ca8?auto=format&fit=crop&w=600&q=80',
tag: '租电脑',
condition: '99新'
},
{
id: 2,
title: 'Apple iPad Pro 13英寸 M4芯片 256G 深空黑 平板电脑',
desc: '原封/99新 · 双层OLED屏 · 支持二代Pencil',
dailyPrice: 8.5,
image: 'https://images.unsplash.com/photo-1544244015-0df4b3ffc6b0?auto=format&fit=crop&w=600&q=80',
tag: '租iPad',
condition: '全新'
},
{
id: 3,
title: 'iPhone 15 Pro Max 256GB 原色钛金属 5G旗舰手机',
desc: 'A17 Pro芯片 · 5倍光学变焦 · 随租随还',
dailyPrice: 6.8,
image: 'https://images.unsplash.com/photo-1695048133142-1a20484d2569?auto=format&fit=crop&w=600&q=80',
tag: '租手机',
condition: '99新'
},
{
id: 5,
title: '索尼 WH-1000XM5 无线降噪头戴式耳机 铂金银',
desc: '旗舰主动降噪 · 会议办公/差旅必备',
dailyPrice: 3.5,
image: 'https://images.unsplash.com/photo-1505740420928-5e560c06d30e?auto=format&fit=crop&w=600&q=80',
tag: '租影音',
condition: '99新'
}
])
const categories = ref(mockCategories)
const hoverCat = ref(null)
const benefitProducts = ref(mockProducts.slice(0, 5))
const recommendTab = ref(1)
const recommendList = ref(mockProducts.slice(0, 5))
const recommendLoading = ref(false)
const categoryFloors = ref([
{ id: 1, cate_name: '💻 电脑整机 / 高性能工作站', productList: mockProducts.slice(0, 4) },
{ id: 2, cate_name: '📱 智能手机 / 旗舰数码', productList: mockProducts.slice(2, 6) }
])
const newsList = ref([
{ id: 1, title: '关于 大锤网络 开启全国免押数码设备租赁业务的通知' },
{ id: 2, title: '数码设备长租与买断方案深度解读:怎样租最划算?' }
])
const fetchBanners = async () => {
try {
const res = await getBanner()
if (res && res.list && res.list.length) {
banners.value = res.list
}
} catch (e) {
// keep fallback
}
}
const fetchCategories = async () => {
try {
const res = await getCategory()
if (Array.isArray(res) && res.length) {
categories.value = res
}
} catch (e) {
// keep fallback
}
}
const switchRecommendTab = async (type) => {
recommendTab.value = type
recommendLoading.value = true
try {
const res = await getRecommendList(type)
if (res && res.list && res.list.length) {
recommendList.value = res.list
} else {
recommendList.value = mockRecommendProducts(type)
}
} catch (e) {
recommendList.value = mockRecommendProducts(type)
} finally {
recommendLoading.value = false
}
}
const fetchFloors = async () => {
try {
const res = await getCategoryProduct()
if (res && res.list && res.list.length) {
categoryFloors.value = res.list
} else if (Array.isArray(res) && res.length) {
categoryFloors.value = res
}
} catch (e) {
// keep fallback
}
}
const fetchBenefitProducts = async () => {
try {
const res = await getRecommendList(4) // 4: 促销特惠
if (res && res.list && res.list.length) {
benefitProducts.value = res.list
} else {
benefitProducts.value = mockRecommendProducts(4)
}
} catch (e) {
benefitProducts.value = mockRecommendProducts(4)
}
}
const fetchNews = async () => {
try {
const res = await getNewsList({ page: 1, limit: 5 })
if (res && res.list && res.list.length) {
newsList.value = res.list
}
} catch (e) {
// keep fallback
}
}
const handleBannerClick = (item) => {
if (item.url) {
window.location.href = item.url
} else {
router.push('/products')
}
}
onMounted(() => {
fetchBanners()
fetchCategories()
switchRecommendTab(1)
fetchBenefitProducts()
fetchFloors()
fetchNews()
})
</script>
+68
View File
@@ -0,0 +1,68 @@
<template>
<div class="max-w-site mx-auto px-4 py-8 space-y-6">
<div class="flex items-center space-x-2 text-xs text-gray-500">
<router-link to="/" class="hover:text-primary">首页</router-link>
<span>/</span>
<router-link to="/news" class="hover:text-primary">商城资讯</router-link>
<span>/</span>
<span class="text-gray-800 font-medium truncate max-w-xs">{{ detail.title }}</span>
</div>
<div v-loading="loading" class="bg-white rounded-2xl p-10 shadow-sm border border-gray-100 max-w-4xl mx-auto space-y-6">
<div class="text-center pb-6 border-b border-gray-100 space-y-3">
<h1 class="text-2xl font-black text-gray-900 leading-snug">{{ detail.title }}</h1>
<div class="text-xs text-gray-400 space-x-4">
<span>发布时间{{ detail.add_time || '近期' }}</span>
<span>浏览量{{ detail.visit || 1 }}</span>
</div>
</div>
<div
v-if="detail.content"
v-html="detail.content"
class="prose prose-red max-w-none text-sm text-gray-700 leading-relaxed rich-content"
></div>
<div class="pt-8 border-t border-gray-100 text-center">
<router-link to="/news" class="px-6 py-2 bg-gray-100 text-gray-700 hover:bg-gray-200 text-xs font-semibold rounded-lg transition-colors">
&lt; 返回资讯列表
</router-link>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { getNewsDetail } from '@/api'
const route = useRoute()
const detail = ref({})
const loading = ref(false)
const fetchDetail = async () => {
loading.value = true
try {
const res = await getNewsDetail(route.params.id)
detail.value = res || {}
} catch (e) {
// ignore
} finally {
loading.value = false
}
}
onMounted(() => {
fetchDetail()
})
</script>
<style scoped>
.rich-content :deep(img) {
max-width: 100% !important;
height: auto !important;
border-radius: 12px;
margin: 16px auto;
}
</style>
+86
View File
@@ -0,0 +1,86 @@
<template>
<div class="max-w-site mx-auto px-4 py-8 space-y-6">
<div class="flex items-center space-x-2 text-xs text-gray-500">
<router-link to="/" class="hover:text-primary">首页</router-link>
<span>/</span>
<span class="text-gray-800 font-medium">商城资讯</span>
</div>
<div class="bg-white rounded-2xl p-8 shadow-sm border border-gray-100 space-y-6">
<h1 class="text-2xl font-bold text-gray-900 pb-4 border-b border-gray-100">
📰 商城快讯 & 官方公告
</h1>
<div v-loading="loading" class="space-y-6">
<div
v-for="item in list"
:key="item.id"
@click="router.push(`/news/${item.id}`)"
class="flex items-center justify-between p-4 rounded-2xl border border-gray-100 hover:border-red-200 hover:bg-red-50/20 transition-all cursor-pointer group"
>
<div class="flex items-center space-x-4">
<img v-if="item.image_input" :src="item.image_input" class="w-24 h-16 rounded-xl object-cover" />
<div class="space-y-1">
<h3 class="text-sm font-bold text-gray-800 group-hover:text-primary transition-colors">
{{ item.title }}
</h3>
<p class="text-xs text-gray-400 line-clamp-1">{{ item.synopsis || '查看详细资讯内容...' }}</p>
</div>
</div>
<span class="text-xs text-gray-400 flex-shrink-0">{{ item.add_time || '近期发布' }}</span>
</div>
<div v-if="list.length === 0 && !loading" class="text-center py-16 text-gray-400 text-sm">
暂无相关资讯
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { getNewsList } from '@/api'
const router = useRouter()
const list = ref([
{
id: 1,
title: '关于 大锤网络 开启全国免押数码设备租赁业务的通知',
synopsis: '支持芝麻信用免押金快速起租,包含苹果MacBook、旗舰手机及平板...',
add_time: '2026-08-19'
},
{
id: 2,
title: '数码设备长租与买断方案深度解读:怎样租最划算?',
synopsis: '全品类数码设备租满 12 个月即可 0 元买断...',
add_time: '2026-08-18'
},
{
id: 3,
title: '企业办公设备批量租赁服务升级,尊享专属客户经理与上门维保',
synopsis: '为中小微企业量身定制办公电脑解决方案,降低一次性固定资产投入...',
add_time: '2026-08-15'
}
])
const loading = ref(false)
const fetchNews = async () => {
loading.value = true
try {
const res = await getNewsList({ page: 1, limit: 20 })
if (res && res.list && res.list.length) {
list.value = res.list
}
} catch (e) {
// ignore
} finally {
loading.value = false
}
}
onMounted(() => {
fetchNews()
})
</script>
+463
View File
@@ -0,0 +1,463 @@
<template>
<div class="max-w-site mx-auto px-3 sm:px-4 py-3 sm:py-6 space-y-4 sm:space-y-6 pb-24 lg:pb-6">
<!-- Breadcrumb -->
<div class="text-xs text-gray-500 flex items-center space-x-2">
<router-link to="/" class="hover:text-primary">首页</router-link>
<span>/</span>
<router-link to="/products" class="hover:text-primary">全部商品</router-link>
<span>/</span>
<span class="text-gray-800 font-medium truncate max-w-[180px] sm:max-w-xs">{{ storeInfo.store_name }}</span>
</div>
<!-- Main Detail Top Card -->
<div v-loading="loading" class="bg-white rounded-2xl p-4 sm:p-8 shadow-sm border border-gray-100 grid grid-cols-1 lg:grid-cols-12 gap-5 lg:gap-10">
<!-- Left Gallery -->
<div class="lg:col-span-5 space-y-3 sm:space-y-4">
<!-- Main Image -->
<div class="aspect-square rounded-2xl overflow-hidden bg-gray-50 border border-gray-100 relative group">
<img
:src="currentImage || storeInfo.image"
:alt="storeInfo.store_name"
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
/>
</div>
<!-- Thumbnails list -->
<div v-if="sliderImages.length > 1" class="flex space-x-2.5 sm:space-x-3 overflow-x-auto pb-1">
<button
v-for="(img, idx) in sliderImages"
:key="idx"
@mouseenter="currentImage = img"
@click="currentImage = img"
:class="currentImage === img ? 'border-primary ring-2 ring-red-100' : 'border-gray-200 hover:border-gray-400'"
class="w-14 h-14 sm:w-16 sm:h-16 rounded-xl overflow-hidden border-2 flex-shrink-0 bg-gray-50 transition-all"
>
<img :src="img" class="w-full h-full object-cover" />
</button>
</div>
</div>
<!-- Right Product Specs & Actions -->
<div class="lg:col-span-7 flex flex-col justify-between space-y-4 sm:space-y-6">
<div>
<!-- Title & Badges -->
<div class="flex items-start justify-between gap-3 sm:gap-4">
<h1 class="text-lg sm:text-2xl font-bold text-gray-900 leading-snug">
{{ storeInfo.store_name }}
</h1>
<!-- Collect button -->
<button
@click="toggleCollect"
:class="userCollect ? 'text-primary bg-red-50' : 'text-gray-400 hover:text-gray-600 bg-gray-50'"
class="flex items-center space-x-1 px-2.5 sm:px-3 py-1.5 rounded-full text-xs font-semibold flex-shrink-0 transition-colors"
>
<span>{{ userCollect ? '❤️ 已藏' : '🤍 收藏' }}</span>
</button>
</div>
<p v-if="storeInfo.store_info" class="text-xs sm:text-sm text-gray-500 mt-1.5 sm:mt-2">
{{ storeInfo.store_info }}
</p>
<!-- Mode Switcher: Buy vs Rent -->
<div class="flex items-center space-x-2 sm:space-x-3 p-1 bg-gray-100 rounded-xl my-3 sm:my-4 text-xs font-bold">
<button
@click="tradeMode = 'buy'"
:class="tradeMode === 'buy' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-800'"
class="flex-1 py-1.5 sm:py-2 rounded-lg transition-all flex items-center justify-center space-x-1"
>
<span>🛒 现货全款买</span>
</button>
<button
@click="tradeMode = 'rent'"
:class="tradeMode === 'rent' ? 'bg-gradient-to-r from-indigo-600 to-purple-600 text-white shadow-sm' : 'text-gray-500 hover:text-gray-800'"
class="flex-1 py-1.5 sm:py-2 rounded-lg transition-all flex items-center justify-center space-x-1"
>
<span>💻 信用免押租</span>
<span class="bg-yellow-400 text-gray-900 text-[9px] sm:text-[10px] px-1 py-0.2 rounded font-black">免押</span>
</button>
</div>
<!-- Price Box (Buy Mode) -->
<div v-if="tradeMode === 'buy'" class="bg-red-50/60 rounded-xl sm:rounded-2xl p-3.5 sm:p-5 mt-2 sm:mt-4 flex items-baseline justify-between">
<div class="flex items-baseline space-x-1.5 sm:space-x-2">
<span class="text-xs sm:text-sm font-bold text-primary">¥</span>
<span class="text-2xl sm:text-3xl font-extrabold text-primary tracking-tight">
{{ selectedSku ? selectedSku.price : storeInfo.price }}
</span>
<span v-if="storeInfo.ot_price" class="text-xs text-gray-400 line-through ml-2">
¥{{ selectedSku ? selectedSku.ot_price || storeInfo.ot_price : storeInfo.ot_price }}
</span>
</div>
<div class="text-[11px] sm:text-xs text-gray-500 space-x-2 sm:space-x-4">
<span>已售 <strong>{{ storeInfo.sales || 0 }}</strong> {{ storeInfo.unit_name || '件' }}</span>
<span>库存 <strong>{{ selectedSku ? selectedSku.stock : storeInfo.stock }}</strong></span>
</div>
</div>
<!-- Price Box (Rent Mode) -->
<div v-else class="bg-indigo-50/80 border border-indigo-100 rounded-xl sm:rounded-2xl p-3.5 sm:p-5 mt-2 sm:mt-4 space-y-2.5 sm:space-y-3">
<div class="flex items-baseline justify-between">
<div>
<span class="text-[11px] sm:text-xs text-gray-500">日租金低至 </span>
<span class="text-xs sm:text-sm font-bold text-primary">¥</span>
<span class="text-2xl sm:text-3xl font-extrabold text-primary tracking-tight">
{{ (Math.max(2.8, (selectedSku ? selectedSku.price : storeInfo.price || 1999) * 0.002)).toFixed(1) }}
</span>
<span class="text-xs text-primary font-bold">/</span>
<span class="text-[11px] sm:text-xs text-gray-500 ml-2">月租约 ¥{{ (Math.max(80, (selectedSku ? selectedSku.price : storeInfo.price || 1999) * 0.05)).toFixed(0) }}</span>
</div>
<span class="px-2 py-0.5 sm:px-2.5 sm:py-1 bg-indigo-600 text-white text-[10px] sm:text-[11px] font-bold rounded-lg shadow-sm">
芝麻分 650 免押
</span>
</div>
<!-- Lease Term options -->
<div class="space-y-1.5 pt-2 border-t border-indigo-100 text-xs">
<span class="font-bold text-gray-700">租赁周期</span>
<div class="grid grid-cols-4 gap-1.5 sm:gap-2">
<button
v-for="d in rentDurations"
:key="d.days"
@click="rentDays = d.days"
:class="rentDays === d.days ? 'bg-indigo-600 text-white font-bold' : 'bg-white text-gray-700 border border-gray-200'"
class="py-1 px-1.5 sm:py-1.5 sm:px-2 rounded-lg text-center text-[11px] sm:text-xs transition-all"
>
<div>{{ d.label }}</div>
</button>
</div>
</div>
</div>
<!-- SKU Attributes Selection -->
<div v-if="productAttr && productAttr.length" class="mt-4 sm:mt-6 space-y-3 sm:space-y-4">
<div v-for="(attr, aIdx) in productAttr" :key="aIdx" class="space-y-1.5 sm:space-y-2">
<span class="text-xs font-semibold text-gray-700">{{ attr.attr_name }}</span>
<div class="flex flex-wrap gap-1.5 sm:gap-2">
<button
v-for="(val, vIdx) in attr.attr_values"
:key="vIdx"
@click="selectAttrValue(attr.attr_name, val)"
:class="selectedAttrs[attr.attr_name] === val ? 'bg-primary text-white border-primary shadow-sm' : 'bg-gray-50 text-gray-700 border-gray-200 hover:border-gray-400'"
class="px-3 sm:px-4 py-1.5 sm:py-2 text-xs font-medium rounded-xl border transition-all"
>
{{ val }}
</button>
</div>
</div>
</div>
<!-- Quantity Stepper (Buy Mode) -->
<div v-if="tradeMode === 'buy'" class="mt-4 sm:mt-6 flex items-center space-x-3 sm:space-x-4">
<span class="text-xs font-semibold text-gray-700">数量</span>
<el-input-number
v-model="cartNum"
:min="1"
:max="selectedSku ? selectedSku.stock : storeInfo.stock || 999"
size="default"
/>
<span class="text-xs text-gray-400">
(库存 {{ storeInfo.stock || 0 }} {{ storeInfo.unit_name || '件' }})
</span>
</div>
</div>
<!-- Desktop Action Buttons (Buy Mode) -->
<div v-if="tradeMode === 'buy'" class="hidden lg:flex pt-6 border-t border-gray-100 items-center space-x-4">
<button
@click="handleAddToCart"
class="flex-1 py-3.5 bg-red-100 hover:bg-red-200 text-primary font-bold text-sm rounded-xl transition-colors flex items-center justify-center space-x-2"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z"></path></svg>
<span>加入购物车</span>
</button>
<button
@click="handleBuyNow"
class="flex-1 py-3.5 bg-primary hover:bg-primary-hover text-white font-bold text-sm rounded-xl shadow-lg shadow-red-200 transition-all flex items-center justify-center space-x-2"
>
<span>立即购买</span>
</button>
</div>
<!-- Desktop Action Buttons (Rent Mode) -->
<div v-else class="hidden lg:flex pt-6 border-t border-gray-100 items-center space-x-4">
<button
@click="handleApplyRental"
class="flex-1 py-3.5 bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-700 hover:to-purple-700 text-white font-bold text-sm rounded-xl shadow-lg shadow-indigo-200 transition-all flex items-center justify-center space-x-2"
>
<span> 立即申请免押租赁 ({{ rentDays }})</span>
</button>
<router-link
to="/rental"
class="px-6 py-3.5 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold text-sm rounded-xl transition-colors"
>
更多机型
</router-link>
</div>
</div>
</div>
<!-- Mobile Fixed Bottom Action Bar -->
<div class="lg:hidden fixed bottom-0 left-0 right-0 z-40 bg-white/95 backdrop-blur-md border-t border-gray-200 px-3 py-2 flex items-center space-x-2 shadow-2xl safe-bottom">
<router-link to="/cart" class="flex flex-col items-center justify-center text-gray-600 px-2 relative">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z"></path></svg>
<span class="text-[10px] mt-0.5">购物车</span>
<span v-if="cartStore.cartCount > 0" class="absolute -top-1 -right-0.5 bg-primary text-white text-[9px] px-1 rounded-full font-bold">
{{ cartStore.cartCount }}
</span>
</router-link>
<template v-if="tradeMode === 'buy'">
<button
@click="handleAddToCart"
class="flex-1 py-2.5 bg-red-100 text-primary font-bold text-xs rounded-xl"
>
加入购物车
</button>
<button
@click="handleBuyNow"
class="flex-1 py-2.5 bg-primary text-white font-bold text-xs rounded-xl shadow-md"
>
立即购买
</button>
</template>
<template v-else>
<button
@click="handleApplyRental"
class="flex-1 py-2.5 bg-gradient-to-r from-indigo-600 to-purple-600 text-white font-bold text-xs rounded-xl shadow-md"
>
立即申请免押租赁 ({{ rentDays }})
</button>
</template>
</div>
<!-- Product Description & Reviews Tabbed Section -->
<div class="bg-white rounded-2xl p-4 sm:p-8 shadow-sm border border-gray-100">
<el-tabs v-model="activeTab" class="product-tabs">
<!-- Tab 1: Description -->
<el-tab-pane label="商品详情介绍" name="desc">
<div class="pt-4 max-w-3xl mx-auto">
<div
v-if="storeInfo.description"
v-html="storeInfo.description"
class="prose prose-red max-w-none text-sm text-gray-700 leading-relaxed rich-content"
></div>
<div v-else class="text-center py-12 text-gray-400 text-sm">
暂无图文详情
</div>
</div>
</el-tab-pane>
<!-- Tab 2: Reviews -->
<el-tab-pane :label="`用户评价 (${replyCount})`" name="replies">
<div class="pt-4 space-y-6">
<!-- Review Summary Header -->
<div class="bg-gray-50 rounded-2xl p-6 flex items-center justify-between">
<div>
<span class="text-xs text-gray-500 font-medium">好评率</span>
<p class="text-3xl font-black text-primary mt-1">{{ replyRate }}%</p>
</div>
<div class="text-xs text-gray-500 space-x-4">
<span>总评价{{ replyCount }}</span>
<span>好评{{ replyConfig.good_count || 0 }}</span>
<span>差评{{ replyConfig.poor_count || 0 }}</span>
</div>
</div>
<!-- Review Items List -->
<div v-if="replyList.length" class="space-y-4">
<div
v-for="item in replyList"
:key="item.id"
class="p-5 rounded-2xl border border-gray-100 hover:border-gray-200 transition-colors space-y-3"
>
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<img :src="item.avatar || 'https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png'" class="w-8 h-8 rounded-full object-cover" />
<div>
<h5 class="text-xs font-bold text-gray-800">{{ item.nickname }}</h5>
<p class="text-[10px] text-gray-400">{{ item.add_time }}</p>
</div>
</div>
<el-rate :model-value="item.product_score || 5" disabled text-color="#ff9900" size="small" />
</div>
<p class="text-xs text-gray-700 leading-relaxed">{{ item.comment }}</p>
<!-- Review Pictures -->
<div v-if="item.pics && item.pics.length" class="flex gap-2">
<img
v-for="(p, pIdx) in item.pics"
:key="pIdx"
:src="p"
class="w-16 h-16 rounded-lg object-cover border border-gray-100"
/>
</div>
</div>
</div>
<div v-else class="text-center py-12 text-gray-400 text-sm">
暂无用户评价
</div>
</div>
</el-tab-pane>
</el-tabs>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '@/store/user'
import { useCartStore } from '@/store/cart'
import { getProductDetail, getReplyList, getReplyConfig, addCollect, delCollect } from '@/api'
import { ElMessage } from 'element-plus'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const cartStore = useCartStore()
const productId = route.params.id
const loading = ref(false)
const storeInfo = ref({})
const sliderImages = ref([])
const currentImage = ref('')
const productAttr = ref([])
const productValue = ref({})
const selectedAttrs = ref({})
const cartNum = ref(1)
const userCollect = ref(false)
const activeTab = ref('desc')
const tradeMode = ref('buy') // 'buy' | 'rent'
const rentDays = ref(30)
const rentDurations = [
{ label: '7 天试用', days: 7 },
{ label: '30 天月租', days: 30 },
{ label: '90 天季租', days: 90 },
{ label: '365 天年租', days: 365 }
]
const replyList = ref([])
const replyCount = ref(0)
const replyRate = ref('100')
const replyConfig = ref({})
// Find the corresponding SKU in productValue
const selectedSku = computed(() => {
if (!productAttr.value || productAttr.value.length === 0) {
return null
}
const key = Object.values(selectedAttrs.value).join(',')
return productValue.value[key] || null
})
const selectAttrValue = (attrName, val) => {
selectedAttrs.value[attrName] = val
if (selectedSku.value && selectedSku.value.image) {
currentImage.value = selectedSku.value.image
}
}
const fetchDetail = async () => {
loading.value = true
try {
const res = await getProductDetail(productId)
storeInfo.value = res.storeInfo || {}
sliderImages.value = res.storeInfo?.slider_image || [res.storeInfo?.image]
currentImage.value = sliderImages.value[0] || ''
productAttr.value = res.productAttr || []
productValue.value = res.productValue || {}
userCollect.value = !!res.userCollect
// Init default selection for attributes
if (productAttr.value.length > 0) {
const initial = {}
productAttr.value.forEach(attr => {
if (attr.attr_values && attr.attr_values.length) {
initial[attr.attr_name] = attr.attr_values[0]
}
})
selectedAttrs.value = initial
}
} catch (e) {
// ignore
} finally {
loading.value = false
}
}
const fetchReplies = async () => {
try {
const [cfgRes, listRes] = await Promise.all([
getReplyConfig(productId),
getReplyList(productId, { page: 1, limit: 10, type: 0 })
])
replyConfig.value = cfgRes || {}
replyRate.value = cfgRes?.reply_chance || '100'
replyCount.value = cfgRes?.sum_count || 0
replyList.value = listRes || []
} catch (e) {
// ignore
}
}
const toggleCollect = async () => {
if (!userStore.token) {
userStore.openAuthModal('login')
return
}
try {
if (userCollect.value) {
await delCollect(productId)
userCollect.value = false
ElMessage.success('已取消收藏')
} else {
await addCollect(productId)
userCollect.value = true
ElMessage.success('已加入收藏夹')
}
} catch (e) {
// ignore
}
}
const handleAddToCart = async () => {
const uniqueId = selectedSku.value?.unique || ''
await cartStore.addToCart(productId, cartNum.value, uniqueId, 0)
}
const handleBuyNow = async () => {
if (!userStore.token) {
userStore.openAuthModal('login')
return
}
const uniqueId = selectedSku.value?.unique || ''
const res = await cartStore.addToCart(productId, cartNum.value, uniqueId, 1)
if (res && res.cartId) {
router.push(`/checkout?cartId=${res.cartId}`)
}
}
const handleApplyRental = () => {
if (!userStore.token) {
userStore.openAuthModal('login')
return
}
ElMessage.success(`🎉 成功提交【${storeInfo.value.store_name}${rentDays.value}天免押租赁申请!客服将为您优先安排顺丰发货。`)
router.push('/user?tab=orders')
}
onMounted(() => {
fetchDetail()
fetchReplies()
})
</script>
<style scoped>
.rich-content :deep(img) {
max-width: 100% !important;
height: auto !important;
border-radius: 12px;
margin: 12px 0;
}
</style>
+243
View File
@@ -0,0 +1,243 @@
<template>
<div class="max-w-site mx-auto px-4 py-6 space-y-6">
<!-- Breadcrumbs -->
<div class="text-xs text-gray-500 flex items-center space-x-2">
<router-link to="/" class="hover:text-primary">首页</router-link>
<span>/</span>
<span class="text-gray-800 font-medium">全部商品</span>
<template v-if="currentCatName">
<span>/</span>
<span class="text-primary font-semibold">{{ currentCatName }}</span>
</template>
<template v-if="searchKeyword">
<span>/</span>
<span>关键词<strong class="text-primary">"{{ searchKeyword }}"</strong></span>
</template>
</div>
<!-- Category / Filter Box -->
<div class="bg-white rounded-2xl p-6 shadow-sm border border-gray-100 space-y-4">
<!-- Categories Filter -->
<div class="flex items-start space-x-4 pb-4 border-b border-gray-100 text-xs">
<span class="text-gray-400 font-semibold w-16 pt-1">全部分类</span>
<div class="flex-1 flex flex-wrap gap-2">
<button
@click="selectCategory(0)"
:class="!selectedCid && !selectedSid ? 'bg-primary text-white font-bold' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'"
class="px-3.5 py-1.5 rounded-lg transition-colors"
>
全部
</button>
<button
v-for="cat in categories"
:key="cat.id"
@click="selectCategory(cat.id)"
:class="selectedCid == cat.id ? 'bg-primary text-white font-bold' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'"
class="px-3.5 py-1.5 rounded-lg transition-colors"
>
{{ cat.cate_name }}
</button>
</div>
</div>
<!-- Sorting Bar -->
<div class="flex flex-wrap items-center justify-between gap-4 pt-1">
<div class="flex items-center space-x-2 text-xs">
<span class="text-gray-400 font-semibold mr-2">排序方式</span>
<button
@click="setSort('default')"
:class="sortType === 'default' ? 'bg-red-50 text-primary border border-red-200 font-bold' : 'border border-gray-200 text-gray-700 hover:border-gray-400'"
class="px-3.5 py-1.5 rounded-lg transition-all"
>
综合排序
</button>
<button
@click="setSort('sales')"
:class="sortType === 'sales' ? 'bg-red-50 text-primary border border-red-200 font-bold' : 'border border-gray-200 text-gray-700 hover:border-gray-400'"
class="px-3.5 py-1.5 rounded-lg transition-all"
>
销量优先
</button>
<button
@click="setSort('news')"
:class="sortType === 'news' ? 'bg-red-50 text-primary border border-red-200 font-bold' : 'border border-gray-200 text-gray-700 hover:border-gray-400'"
class="px-3.5 py-1.5 rounded-lg transition-all"
>
新品优先
</button>
<button
@click="togglePriceSort"
:class="sortType === 'price' ? 'bg-red-50 text-primary border border-red-200 font-bold' : 'border border-gray-200 text-gray-700 hover:border-gray-400'"
class="px-3.5 py-1.5 rounded-lg transition-all flex items-center space-x-1"
>
<span>价格</span>
<span>{{ priceOrder === 'asc' ? '↑' : priceOrder === 'desc' ? '↓' : '↕' }}</span>
</button>
</div>
<div class="text-xs text-gray-500">
共找到 <strong class="text-primary">{{ total }}</strong> 款商品
</div>
</div>
</div>
<!-- Product Grid -->
<div v-loading="loading" class="min-h-[400px]">
<div v-if="products.length > 0" class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
<ProductCard
v-for="item in products"
:key="item.id"
:product="item"
/>
</div>
<!-- Empty State -->
<div v-else-if="!loading" class="bg-white rounded-2xl p-16 text-center shadow-sm border border-gray-100">
<div class="text-6xl mb-4">🔍</div>
<h4 class="text-lg font-bold text-gray-800">暂未找到相关商品</h4>
<p class="text-xs text-gray-400 mt-1 mb-6">建议您尝试其他分类或缩短搜索关键词</p>
<button @click="resetFilters" class="px-6 py-2.5 bg-primary text-white text-xs font-bold rounded-lg hover:bg-primary-hover transition-colors shadow-sm">
查看所有商品
</button>
</div>
</div>
<!-- Pagination -->
<div v-if="total > limit" class="flex justify-center pt-6">
<el-pagination
v-model:current-page="page"
:page-size="limit"
:total="total"
layout="prev, pager, next, jumper"
background
@current-change="handlePageChange"
/>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import ProductCard from '@/components/ProductCard.vue'
import { getProducts, getCategory } from '@/api'
import { mockCategories, mockProducts } from '@/api/mock'
const route = useRoute()
const router = useRouter()
const categories = ref(mockCategories)
const products = ref(mockProducts)
const total = ref(mockProducts.length)
const page = ref(1)
const limit = ref(20)
const loading = ref(false)
const selectedCid = ref(Number(route.query.cid || 0))
const selectedSid = ref(Number(route.query.sid || 0))
const searchKeyword = ref(route.query.keyword || '')
const sortType = ref('default') // 'default' | 'sales' | 'news' | 'price'
const priceOrder = ref('')
const currentCatName = computed(() => {
if (selectedCid.value) {
const c = categories.value.find(item => item.id == selectedCid.value)
return c ? c.cate_name : ''
}
return ''
})
const fetchCategoryList = async () => {
try {
const res = await getCategory()
categories.value = res || []
} catch (e) {
// ignore
}
}
const fetchProductList = async () => {
loading.value = true
try {
const params = {
page: page.value,
limit: limit.value,
cid: selectedCid.value || undefined,
sid: selectedSid.value || undefined,
keyword: searchKeyword.value || undefined,
type: route.query.type || undefined
}
if (sortType.value === 'sales') {
params.salesOrder = 'desc'
} else if (sortType.value === 'news') {
params.news = 1
} else if (sortType.value === 'price') {
params.priceOrder = priceOrder.value
}
const res = await getProducts(params)
products.value = res.list || []
total.value = res.count || res.total || 0
} catch (e) {
// ignore
} finally {
loading.value = false
}
}
const selectCategory = (cid) => {
selectedCid.value = cid
selectedSid.value = 0
page.value = 1
fetchProductList()
}
const setSort = (type) => {
sortType.value = type
priceOrder.value = ''
page.value = 1
fetchProductList()
}
const togglePriceSort = () => {
sortType.value = 'price'
if (priceOrder.value === 'asc') {
priceOrder.value = 'desc'
} else {
priceOrder.value = 'asc'
}
page.value = 1
fetchProductList()
}
const handlePageChange = (p) => {
page.value = p
fetchProductList()
window.scrollTo({ top: 0, behavior: 'smooth' })
}
const resetFilters = () => {
selectedCid.value = 0
selectedSid.value = 0
searchKeyword.value = ''
sortType.value = 'default'
priceOrder.value = ''
page.value = 1
router.push('/products')
fetchProductList()
}
watch(() => route.query, (newQuery) => {
selectedCid.value = Number(newQuery.cid || 0)
selectedSid.value = Number(newQuery.sid || 0)
searchKeyword.value = newQuery.keyword || ''
page.value = 1
fetchProductList()
})
onMounted(() => {
fetchCategoryList()
fetchProductList()
})
</script>
+421
View File
@@ -0,0 +1,421 @@
<template>
<div class="space-y-4 sm:space-y-8 pb-16">
<!-- Rental Hero Banner -->
<section class="bg-gradient-to-r from-slate-900 via-indigo-950 to-slate-900 text-white py-6 sm:py-12 border-b border-indigo-900/40 relative overflow-hidden">
<!-- Background Ambient Glow -->
<div class="absolute -right-20 -top-20 w-96 h-96 bg-indigo-500/20 rounded-full blur-3xl pointer-events-none"></div>
<div class="absolute left-1/4 -bottom-20 w-80 h-80 bg-red-500/15 rounded-full blur-3xl pointer-events-none"></div>
<div class="max-w-site mx-auto px-3 sm:px-4 relative z-10 flex flex-col md:flex-row items-center justify-between gap-5 sm:gap-8">
<div class="space-y-2.5 sm:space-y-4 max-w-xl text-center md:text-left">
<div class="inline-flex items-center space-x-2 px-3 py-1 rounded-full bg-indigo-500/20 border border-indigo-400/30 text-indigo-300 text-[11px] sm:text-xs font-semibold backdrop-blur-sm">
<span>🛡 信用免押 · 随租随还 · 企个两用</span>
</div>
<h1 class="text-2xl sm:text-4xl font-extrabold tracking-tight text-white leading-tight">
💻 全品类数码设备免押租赁
</h1>
<p class="text-xs sm:text-sm text-indigo-200/80 leading-relaxed">
专业提供 <strong>苹果Mac/高性能电脑旗舰手机iPad平板会展测试机摄影器材</strong> 租赁日租低至 <span class="text-orange-400 font-bold text-base sm:text-lg">¥2.8/</span> 到期可还可买断
</p>
<div class="flex items-center justify-center md:justify-start space-x-3 pt-1">
<a href="#rental-grid" class="px-5 sm:px-6 py-2 sm:py-2.5 bg-gradient-to-r from-red-500 to-orange-500 text-white font-bold text-xs rounded-xl shadow-lg shadow-red-500/25 transition-all">
挑选设备
</a>
<button @click="openConsult" class="px-5 sm:px-6 py-2 sm:py-2.5 bg-white/10 text-white font-semibold text-xs rounded-xl border border-white/20 backdrop-blur-sm transition-colors">
企业批量租
</button>
</div>
</div>
<!-- 4 Step Guarantee Highlights -->
<div class="grid grid-cols-2 gap-2 sm:gap-3 w-full md:w-auto">
<div class="bg-white/10 backdrop-blur-md border border-white/15 p-2.5 sm:p-4 rounded-xl sm:rounded-2xl">
<span class="text-xl sm:text-2xl">💎</span>
<h4 class="font-bold text-xs sm:text-sm mt-0.5 text-white">芝麻免押</h4>
<p class="text-[10px] sm:text-[11px] text-gray-300">信用良好 0 押金</p>
</div>
<div class="bg-white/10 backdrop-blur-md border border-white/15 p-2.5 sm:p-4 rounded-xl sm:rounded-2xl">
<span class="text-xl sm:text-2xl"></span>
<h4 class="font-bold text-xs sm:text-sm mt-0.5 text-white">顺丰包邮</h4>
<p class="text-[10px] sm:text-[11px] text-gray-300">当日顺丰发出</p>
</div>
<div class="bg-white/10 backdrop-blur-md border border-white/15 p-2.5 sm:p-4 rounded-xl sm:rounded-2xl">
<span class="text-xl sm:text-2xl">🔄</span>
<h4 class="font-bold text-xs sm:text-sm mt-0.5 text-white">租完即送</h4>
<p class="text-[10px] sm:text-[11px] text-gray-300">满期可0元买断</p>
</div>
<div class="bg-white/10 backdrop-blur-md border border-white/15 p-2.5 sm:p-4 rounded-xl sm:rounded-2xl">
<span class="text-xl sm:text-2xl">🛠</span>
<h4 class="font-bold text-xs sm:text-sm mt-0.5 text-white">全程质保</h4>
<p class="text-[10px] sm:text-[11px] text-gray-300">故障免费换新</p>
</div>
</div>
</div>
</section>
<!-- Rental Category Tabs & Filter -->
<div id="rental-grid" class="max-w-site mx-auto px-3 sm:px-4 space-y-4 sm:space-y-6">
<div class="bg-white rounded-2xl p-3.5 sm:p-6 shadow-sm border border-gray-100 space-y-3 sm:space-y-4">
<!-- Device Category Tabs (Horizontal scroll on mobile) -->
<div class="flex items-center space-x-2 sm:space-x-3 pb-3 sm:pb-4 border-b border-gray-100 overflow-x-auto no-scrollbar text-xs font-semibold">
<span class="hidden sm:inline-block text-gray-400 flex-shrink-0 w-16">设备类别</span>
<button
v-for="cat in rentalCategories"
:key="cat.key"
@click="activeCat = cat.key"
:class="activeCat === cat.key ? 'bg-indigo-600 text-white shadow-sm' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'"
class="px-3 sm:px-4 py-1.5 sm:py-2 rounded-xl transition-all flex items-center space-x-1 sm:space-x-1.5 flex-shrink-0 whitespace-nowrap"
>
<span>{{ cat.icon }}</span>
<span>{{ cat.name }}</span>
</button>
</div>
<!-- Lease Term Filter -->
<div class="flex items-center space-x-2 sm:space-x-3 text-xs font-medium overflow-x-auto no-scrollbar">
<span class="hidden sm:inline-block text-gray-400 flex-shrink-0 w-16">租赁周期</span>
<button
v-for="term in leaseTerms"
:key="term.val"
@click="selectedTerm = term.val"
:class="selectedTerm === term.val ? 'text-indigo-600 bg-indigo-50 border-indigo-200 font-bold' : 'text-gray-600 border-gray-200 hover:border-gray-400'"
class="px-2.5 sm:px-3.5 py-1 sm:py-1.5 rounded-lg border transition-all flex-shrink-0 whitespace-nowrap text-[11px] sm:text-xs"
>
{{ term.label }}
</button>
</div>
</div>
<!-- Rental Products Grid (2 cols on mobile, 4 on desktop) -->
<div class="grid grid-cols-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-2.5 sm:gap-6">
<div
v-for="item in filteredRentalProducts"
:key="item.id"
class="bg-white rounded-xl sm:rounded-2xl border border-gray-100 overflow-hidden shadow-sm hover:shadow-xl hover:-translate-y-1 transition-all duration-300 flex flex-col justify-between group relative"
>
<!-- Badge -->
<div class="absolute top-2 left-2 sm:top-3 sm:left-3 z-10 flex gap-1">
<span class="px-1.5 sm:px-2.5 py-0.5 sm:py-1 text-[9px] sm:text-[10px] font-extrabold bg-gradient-to-r from-indigo-600 to-purple-600 text-white rounded-md sm:rounded-lg shadow-sm">
{{ item.tag || '免押租赁' }}
</span>
<span v-if="item.condition" class="hidden sm:inline-block px-2 py-1 text-[10px] font-bold bg-gray-900/80 text-white rounded-lg backdrop-blur-sm">
{{ item.condition }}
</span>
</div>
<!-- Product Image -->
<div class="aspect-square bg-gray-50 overflow-hidden relative cursor-pointer" @click="goDetail(item.id)">
<img :src="item.image" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" />
<!-- Term hint overlay -->
<div class="absolute bottom-1.5 right-1.5 sm:bottom-2 sm:right-2 bg-black/60 text-white text-[9px] sm:text-[10px] px-1.5 sm:px-2 py-0.5 rounded backdrop-blur-sm">
{{ item.termDesc }}
</div>
</div>
<!-- Product Info -->
<div class="p-2.5 sm:p-5 flex-1 flex flex-col justify-between space-y-2.5 sm:space-y-4">
<div>
<h3 @click="goDetail(item.id)" class="text-xs sm:text-sm font-bold text-gray-900 line-clamp-2 hover:text-primary cursor-pointer leading-snug">
{{ item.title }}
</h3>
<p class="hidden sm:block text-xs text-gray-400 line-clamp-1 mt-1">{{ item.specs }}</p>
</div>
<div class="pt-2 sm:pt-3 border-t border-gray-100 space-y-2 sm:space-y-3">
<!-- Price per day / month -->
<div class="flex items-baseline justify-between">
<div>
<span class="text-[9px] sm:text-[11px] text-gray-400">低至 </span>
<span class="text-xs font-bold text-primary">¥</span>
<span class="text-lg sm:text-2xl font-black text-primary tracking-tight">{{ item.dailyPrice }}</span>
<span class="text-[10px] sm:text-xs text-primary font-semibold">/</span>
</div>
<div class="hidden sm:block text-[11px] text-gray-400">
月租约 ¥{{ item.monthlyPrice }}
</div>
</div>
<!-- Action buttons -->
<div class="flex items-center space-x-1.5 sm:space-x-2">
<button
@click="openRentalDialog(item)"
class="flex-1 py-1.5 sm:py-2.5 bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-700 hover:to-purple-700 text-white font-bold text-[10px] sm:text-xs rounded-lg sm:rounded-xl shadow-sm transition-all"
>
立即申请免押
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Rental Application / Booking Modal -->
<el-dialog
v-model="showRentalDialog"
title="申请数码设备租赁 / 预订"
width="92%"
:align-center="true"
destroy-on-close
class="max-w-lg rounded-2xl"
>
<div v-if="currentRentalItem" class="space-y-5 text-xs">
<!-- Selected Item Brief -->
<div class="flex items-center space-x-4 p-3 bg-gray-50 rounded-xl border border-gray-100">
<img :src="currentRentalItem.image" class="w-16 h-16 rounded-lg object-cover bg-white" />
<div class="space-y-1">
<h4 class="font-bold text-gray-900 text-sm">{{ currentRentalItem.title }}</h4>
<p class="text-gray-400 text-[11px]">{{ currentRentalItem.specs }}</p>
<p class="text-primary font-bold">日租金¥{{ currentRentalItem.dailyPrice }}/ (月租约 ¥{{ currentRentalItem.monthlyPrice }})</p>
</div>
</div>
<!-- Rent Duration Selector -->
<div>
<label class="block font-bold text-gray-700 mb-2">选择租赁租期</label>
<div class="grid grid-cols-4 gap-2">
<button
v-for="d in durationOptions"
:key="d.days"
@click="selectedDays = d.days"
:class="selectedDays === d.days ? 'border-primary bg-red-50 text-primary font-bold ring-2 ring-red-100' : 'border-gray-200 text-gray-700 hover:border-gray-400'"
class="p-2.5 rounded-xl border text-center transition-all"
>
<div class="text-xs font-bold">{{ d.label }}</div>
<div class="text-[10px] text-gray-400 mt-0.5">{{ d.discount }}</div>
</button>
</div>
</div>
<!-- Rent Scheme Options -->
<div class="space-y-2">
<label class="block font-bold text-gray-700">到期方案</label>
<div class="grid grid-cols-2 gap-3">
<div
@click="rentScheme = 'return'"
:class="rentScheme === 'return' ? 'border-primary bg-red-50/50' : 'border-gray-200'"
class="p-3 rounded-xl border cursor-pointer transition-all"
>
<div class="font-bold text-gray-800">📦 到期归还 / 续租</div>
<p class="text-[11px] text-gray-500 mt-1">租期满后顺丰寄回或随时申请续租</p>
</div>
<div
@click="rentScheme = 'buyout'"
:class="rentScheme === 'buyout' ? 'border-primary bg-red-50/50' : 'border-gray-200'"
class="p-3 rounded-xl border cursor-pointer transition-all"
>
<div class="font-bold text-gray-800">🎁 租完即送 / 补差买断</div>
<p class="text-[11px] text-gray-500 mt-1">租期满后自动归您所有无需退还</p>
</div>
</div>
</div>
<!-- User Contact Info -->
<div class="space-y-3 pt-2">
<div>
<label class="block font-bold text-gray-700 mb-1">联系人姓名</label>
<input v-model="rentalForm.name" type="text" placeholder="请输入真实姓名" class="w-full px-3 py-2 border border-gray-200 rounded-lg outline-none focus:border-primary" />
</div>
<div>
<label class="block font-bold text-gray-700 mb-1">联系电话</label>
<input v-model="rentalForm.phone" type="text" placeholder="请输入手机号码" class="w-full px-3 py-2 border border-gray-200 rounded-lg outline-none focus:border-primary" />
</div>
<div>
<label class="block font-bold text-gray-700 mb-1">收货地址</label>
<input v-model="rentalForm.address" type="text" placeholder="请输入设备寄送详细地址" class="w-full px-3 py-2 border border-gray-200 rounded-lg outline-none focus:border-primary" />
</div>
</div>
<!-- Cost Estimate -->
<div class="p-4 bg-indigo-50/70 border border-indigo-100 rounded-xl flex items-center justify-between">
<div>
<span class="text-gray-500">预估总租金{{ selectedDays }}</span>
<p class="text-xs text-indigo-700 mt-0.5">芝麻信用分 650 ¥0 押金</p>
</div>
<span class="text-2xl font-black text-primary">¥{{ (currentRentalItem.dailyPrice * selectedDays).toFixed(2) }}</span>
</div>
</div>
<template #footer>
<div class="flex items-center justify-end space-x-3">
<button @click="showRentalDialog = false" class="px-5 py-2 text-xs text-gray-600 hover:text-gray-800">取消</button>
<button @click="submitRental" class="px-6 py-2 bg-primary hover:bg-primary-hover text-white text-xs font-bold rounded-lg shadow-sm">
提交免押租赁申请
</button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/store/user'
import { ElMessage } from 'element-plus'
const router = useRouter()
const userStore = useUserStore()
const activeCat = ref('all')
const selectedTerm = ref('all')
const rentalCategories = [
{ key: 'all', name: '全部设备', icon: '⚡' },
{ key: 'computer', name: '笔记本 / 台式电脑', icon: '💻' },
{ key: 'phone', name: '智能手机', icon: '📱' },
{ key: 'pad', name: 'iPad / 平板电脑', icon: '📟' },
{ key: 'audio', name: '音响 / 会议影音', icon: '🎧' },
{ key: 'enterprise', name: '企业批量工位机', icon: '🏢' }
]
const leaseTerms = [
{ label: '全部周期', val: 'all' },
{ label: '短期体验 (7~30天)', val: 'short' },
{ label: '季度/半年租 (3~6个月)', val: 'mid' },
{ label: '长期租赁 (12个月以上)', val: 'long' }
]
// Curated rental products mapped to store products
const rentalProducts = ref([
{
id: 1,
cat: 'computer',
title: 'Apple MacBook Pro 14英寸 M3 Max 芯片 / 36G / 1TB 剪辑办公本',
specs: '设计剪辑/移动办公神器 · 官方质保 · 顺丰包邮',
dailyPrice: 18.8,
monthlyPrice: 499,
image: 'https://images.unsplash.com/photo-1517336714731-489689fd1ca8?auto=format&fit=crop&w=600&q=80',
condition: '99新',
tag: '电脑办公',
termDesc: '企业免押专享'
},
{
id: 2,
cat: 'pad',
title: 'Apple iPad Pro 13英寸 M4芯片 256G 深空黑 (双层OLED屏)',
specs: '原封/99新 · 生产力神器 · 支持二代Pencil',
dailyPrice: 8.5,
monthlyPrice: 240,
image: 'https://images.unsplash.com/photo-1544244015-0df4b3ffc6b0?auto=format&fit=crop&w=600&q=80',
condition: '全新原封',
tag: '平板旗舰',
termDesc: '支持租完即送'
},
{
id: 3,
cat: 'phone',
title: 'iPhone 15 Pro Max 256GB 原色钛金属 5G旗舰手机',
specs: 'A17 Pro芯片 · 5倍潜望光学长焦 · 钛金属机身',
dailyPrice: 6.8,
monthlyPrice: 188,
image: 'https://images.unsplash.com/photo-1695048133142-1a20484d2569?auto=format&fit=crop&w=600&q=80',
condition: '99新',
tag: '手机租借',
termDesc: '7天起租 · 随租随还'
},
{
id: 4,
cat: 'computer',
title: 'ThinkPad X1 Carbon 商务旗舰超极本 i7/32G/1TB',
specs: '仅1.09kg轻薄碳纤维 · 经典小红点 · 超长续航',
dailyPrice: 12.5,
monthlyPrice: 320,
image: 'https://images.unsplash.com/photo-1588872657578-7efd1f1555ed?auto=format&fit=crop&w=600&q=80',
condition: '95新',
tag: '商务出差',
termDesc: '支持月租/年租'
},
{
id: 5,
cat: 'audio',
title: '索尼 WH-1000XM5 无线降噪头戴式耳机 铂金银',
specs: '旗舰双芯降噪 · 商务会议降噪 · 40小时长续航',
dailyPrice: 3.5,
monthlyPrice: 90,
image: 'https://images.unsplash.com/photo-1505740420928-5e560c06d30e?auto=format&fit=crop&w=600&q=80',
condition: '99新',
tag: '影音会议',
termDesc: '免押试用'
},
{
id: 6,
cat: 'audio',
title: 'Apple Watch Ultra 2 智能手表 钛金属表壳/越野表带',
specs: '3000尼特超亮屏 · 双频GPS · 户外探险运动',
dailyPrice: 4.2,
monthlyPrice: 115,
image: 'https://images.unsplash.com/photo-1523275335684-37898b6baf30?auto=format&fit=crop&w=600&q=80',
condition: '99新',
tag: '智能穿戴',
termDesc: '短期户外租'
},
{
id: 7,
cat: 'enterprise',
title: 'Dell / 联想企业级批量商务台式电脑 + 27寸4K双屏工位套装',
specs: 'i7/32G/1TB SSD + 27寸4K护眼屏 · 上门安装',
dailyPrice: 9.8,
monthlyPrice: 260,
image: 'https://images.unsplash.com/photo-1497215728101-856f4ea42174?auto=format&fit=crop&w=600&q=80',
condition: '全新批量',
tag: '企业免押',
termDesc: '企业租金按月结'
}
])
const filteredRentalProducts = computed(() => {
if (activeCat.value === 'all') return rentalProducts.value
return rentalProducts.value.filter(item => item.cat === activeCat.value)
})
const showRentalDialog = ref(false)
const currentRentalItem = ref(null)
const selectedDays = ref(30)
const rentScheme = ref('return')
const durationOptions = [
{ label: '7 天试用', days: 7, discount: '标准价' },
{ label: '30 天月租', days: 30, discount: '立减10%' },
{ label: '90 天季租', days: 90, discount: '立减20%' },
{ label: '365 天年租', days: 365, discount: '可0元买断' }
]
const rentalForm = ref({
name: '',
phone: '',
address: ''
})
const goDetail = (id) => {
router.push(`/product/${id}`)
}
const openRentalModal = (item) => {
if (!userStore.token) {
userStore.openAuthModal('login')
return
}
currentRentalItem.value = item
rentalForm.value = {
name: userStore.userInfo?.nickname || '',
phone: userStore.userInfo?.phone || '',
address: ''
}
showRentalDialog.value = true
}
const openConsult = () => {
ElMessage.success('企业客服专线已接通:400-888-9999,请联系在线客服了解企业批量免押授信方案')
}
const submitRental = () => {
if (!rentalForm.value.name || !rentalForm.value.phone || !rentalForm.value.address) {
ElMessage.warning('请填写完整的收货人、电话与寄送地址')
return
}
showRentalDialog.value = false
ElMessage.success('🎉 租赁预订申请提交成功!专属客服将在 15 分钟内为您完成免押审核与发货。')
router.push('/user?tab=orders')
}
</script>
+478
View File
@@ -0,0 +1,478 @@
<template>
<div class="max-w-site mx-auto px-4 py-8 space-y-6">
<div class="grid grid-cols-1 md:grid-cols-12 gap-6">
<!-- Left Sidebar Nav -->
<div class="md:col-span-3 space-y-4">
<!-- User Profile Card -->
<div class="bg-white rounded-2xl p-6 shadow-sm border border-gray-100 text-center">
<img
:src="userStore.userInfo?.avatar || 'https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png'"
class="w-20 h-20 rounded-full mx-auto object-cover border-4 border-red-50 shadow-sm"
/>
<h3 class="text-base font-bold text-gray-900 mt-3">
{{ userStore.userInfo?.nickname || userStore.userInfo?.account || '尊贵会员' }}
</h3>
<p class="text-xs text-gray-400 mt-0.5">ID: {{ userStore.userInfo?.uid || '10001' }}</p>
<div class="grid grid-cols-2 gap-2 mt-4 pt-4 border-t border-gray-100 text-xs">
<div>
<span class="text-gray-400">账户余额</span>
<p class="font-bold text-primary text-sm mt-0.5">¥{{ userStore.userCenter?.now_money || '0.00' }}</p>
</div>
<div>
<span class="text-gray-400">可用积分</span>
<p class="font-bold text-gray-800 text-sm mt-0.5">{{ userStore.userCenter?.integral || '0' }}</p>
</div>
</div>
</div>
<!-- Navigation Menu Tabs -->
<div class="bg-white rounded-2xl shadow-sm border border-gray-100 p-2 space-y-1 text-sm font-medium">
<button
@click="switchTab('orders')"
:class="activeTab === 'orders' ? 'bg-primary text-white font-bold' : 'text-gray-700 hover:bg-gray-50'"
class="w-full text-left px-4 py-3 rounded-xl transition-colors flex items-center space-x-2"
>
<span>📦</span>
<span>我的订单</span>
</button>
<button
@click="switchTab('address')"
:class="activeTab === 'address' ? 'bg-primary text-white font-bold' : 'text-gray-700 hover:bg-gray-50'"
class="w-full text-left px-4 py-3 rounded-xl transition-colors flex items-center space-x-2"
>
<span>📍</span>
<span>收货地址管理</span>
</button>
<button
@click="switchTab('collect')"
:class="activeTab === 'collect' ? 'bg-primary text-white font-bold' : 'text-gray-700 hover:bg-gray-50'"
class="w-full text-left px-4 py-3 rounded-xl transition-colors flex items-center space-x-2"
>
<span></span>
<span>商品收藏夹</span>
</button>
<button
@click="switchTab('coupons')"
:class="activeTab === 'coupons' ? 'bg-primary text-white font-bold' : 'text-gray-700 hover:bg-gray-50'"
class="w-full text-left px-4 py-3 rounded-xl transition-colors flex items-center space-x-2"
>
<span>🎟</span>
<span>我的优惠券</span>
</button>
</div>
</div>
<!-- Right Main Content Panel -->
<div class="md:col-span-9">
<!-- Tab 1: Orders -->
<div v-if="activeTab === 'orders'" class="bg-white rounded-2xl p-6 shadow-sm border border-gray-100 space-y-6">
<div class="flex items-center justify-between pb-4 border-b border-gray-100">
<h2 class="text-base font-bold text-gray-900">订单列表</h2>
<!-- Order status filter chips -->
<div class="flex space-x-2 text-xs">
<button
v-for="st in orderStatusList"
:key="st.val"
@click="filterOrderStatus(st.val)"
:class="orderStatus === st.val ? 'bg-red-50 text-primary border border-red-200 font-bold' : 'border border-gray-200 text-gray-600'"
class="px-3 py-1.5 rounded-lg transition-colors"
>
{{ st.label }}
</button>
</div>
</div>
<!-- Orders List -->
<div v-loading="ordersLoading" class="space-y-4">
<div
v-for="order in orders"
:key="order.id"
class="border border-gray-100 rounded-2xl overflow-hidden hover:border-gray-200 transition-colors"
>
<!-- Order Header -->
<div class="bg-gray-50 px-6 py-3 flex items-center justify-between text-xs text-gray-500">
<div class="space-x-4">
<span>订单编号<strong class="text-gray-800">{{ order.order_id }}</strong></span>
<span>下单时间{{ order._add_time || order.add_time_y }}</span>
</div>
<span class="font-bold text-primary">{{ order._status?._title || order.status_name || '进行中' }}</span>
</div>
<!-- Order Body -->
<div class="p-6 space-y-4">
<div
v-for="cart in order.cartInfo"
:key="cart.id"
class="flex items-center justify-between text-xs"
>
<div class="flex items-center space-x-4">
<img :src="cart.productInfo?.attrInfo?.image || cart.productInfo?.image" class="w-16 h-16 rounded-xl object-cover border border-gray-100" />
<div>
<h4 class="font-bold text-gray-800 hover:text-primary cursor-pointer">{{ cart.productInfo?.store_name }}</h4>
<p v-if="cart.productInfo?.attrInfo?.suk" class="text-gray-400 mt-1">规格{{ cart.productInfo?.attrInfo?.suk }}</p>
</div>
</div>
<div class="text-right">
<p class="font-bold text-gray-900">¥{{ cart.truePrice || cart.productInfo?.price }} x {{ cart.cart_num }}</p>
</div>
</div>
<!-- Order Footer -->
<div class="pt-4 border-t border-gray-100 flex items-center justify-between">
<span class="text-xs text-gray-500">
实付金额<strong class="text-base font-extrabold text-primary">¥{{ order.pay_price }}</strong>
</span>
<div class="flex space-x-2">
<button
v-if="order._status?._type === 0"
@click="cancelOrder(order.id)"
class="px-4 py-1.5 text-xs text-gray-600 border border-gray-200 rounded-lg hover:border-gray-400"
>
取消订单
</button>
<button
v-if="order._status?._type === 2"
@click="takeOrder(order.order_id)"
class="px-4 py-1.5 text-xs bg-primary text-white font-bold rounded-lg hover:bg-primary-hover shadow-sm"
>
确认收货
</button>
</div>
</div>
</div>
</div>
<div v-if="orders.length === 0 && !ordersLoading" class="text-center py-16 text-gray-400 text-sm">
暂无相关订单
</div>
</div>
</div>
<!-- Tab 2: Address Book -->
<div v-else-if="activeTab === 'address'" class="bg-white rounded-2xl p-6 shadow-sm border border-gray-100 space-y-6">
<div class="flex items-center justify-between pb-4 border-b border-gray-100">
<h2 class="text-base font-bold text-gray-900">收货地址簿</h2>
<button @click="openEditAddressModal()" class="px-4 py-2 bg-primary text-white font-bold text-xs rounded-xl hover:bg-primary-hover shadow-sm">
+ 新增收货地址
</button>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div
v-for="addr in addressList"
:key="addr.id"
class="p-5 rounded-2xl border border-gray-200 hover:border-red-200 space-y-2 text-xs relative"
>
<div class="flex items-center justify-between font-bold text-gray-800 text-sm">
<span>{{ addr.real_name }} ({{ addr.phone }})</span>
<span v-if="addr.is_default" class="bg-red-100 text-primary text-[10px] px-2 py-0.5 rounded">默认地址</span>
</div>
<p class="text-gray-500 leading-relaxed">{{ addr.province }} {{ addr.city }} {{ addr.district }} {{ addr.detail }}</p>
<div class="pt-3 border-t border-gray-100 flex items-center justify-end space-x-3 text-xs">
<button v-if="!addr.is_default" @click="setAddrDefault(addr.id)" class="text-gray-500 hover:text-primary">设为默认</button>
<button @click="openEditAddressModal(addr)" class="text-primary hover:underline">编辑</button>
<button @click="deleteAddress(addr.id)" class="text-gray-400 hover:text-red-600">删除</button>
</div>
</div>
</div>
</div>
<!-- Tab 3: Collect Favorites -->
<div v-else-if="activeTab === 'collect'" class="bg-white rounded-2xl p-6 shadow-sm border border-gray-100 space-y-6">
<h2 class="text-base font-bold text-gray-900 pb-4 border-b border-gray-100">商品收藏夹</h2>
<div v-if="collectList.length" class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
<div
v-for="item in collectList"
:key="item.pid"
class="border border-gray-100 rounded-xl overflow-hidden hover:shadow-md transition-all p-3 space-y-2"
>
<router-link :to="`/product/${item.pid}`" class="block aspect-square rounded-lg overflow-hidden bg-gray-50">
<img :src="item.image" class="w-full h-full object-cover" />
</router-link>
<h4 class="text-xs font-bold text-gray-800 line-clamp-1 hover:text-primary cursor-pointer">{{ item.store_name }}</h4>
<div class="flex items-center justify-between text-xs">
<span class="text-primary font-bold">¥{{ item.price }}</span>
<button @click="removeCollect(item.pid)" class="text-gray-400 hover:text-red-500 text-[11px]">取消收藏</button>
</div>
</div>
</div>
<div v-else class="text-center py-16 text-gray-400 text-sm">
暂无收藏商品
</div>
</div>
<!-- Tab 4: Coupons -->
<div v-else-if="activeTab === 'coupons'" class="bg-white rounded-2xl p-6 shadow-sm border border-gray-100 space-y-6">
<h2 class="text-base font-bold text-gray-900 pb-4 border-b border-gray-100">我的优惠券</h2>
<div v-if="couponList.length" class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div
v-for="c in couponList"
:key="c.id"
class="p-5 rounded-2xl border-2 border-red-200 bg-red-50/30 flex items-center justify-between"
>
<div>
<div class="text-2xl font-black text-primary">¥{{ c.coupon_price }}</div>
<p class="text-xs text-gray-600 font-medium mt-1">{{ c.coupon_title }}</p>
<p class="text-[10px] text-gray-400 mt-0.5"> ¥{{ c.use_min_price }} 可用</p>
</div>
<router-link to="/products" class="px-4 py-2 bg-primary text-white text-xs font-bold rounded-xl hover:bg-primary-hover shadow-sm">
去使用
</router-link>
</div>
</div>
<div v-else class="text-center py-16 text-gray-400 text-sm">
暂无可用的优惠券
</div>
</div>
</div>
</div>
<!-- Address Edit Modal -->
<el-dialog
v-model="showAddressModal"
:title="editingAddress.id ? '编辑收货地址' : '新增收货地址'"
width="480px"
:align-center="true"
>
<div class="space-y-4 text-xs">
<div>
<label class="block font-bold text-gray-700 mb-1">收货人姓名</label>
<input v-model="editingAddress.real_name" class="w-full px-3 py-2 border rounded-lg" placeholder="请输入姓名" />
</div>
<div>
<label class="block font-bold text-gray-700 mb-1">联系手机号</label>
<input v-model="editingAddress.phone" class="w-full px-3 py-2 border rounded-lg" placeholder="请输入手机号" />
</div>
<div class="grid grid-cols-3 gap-2">
<div>
<label class="block font-bold text-gray-700 mb-1">省份</label>
<input v-model="editingAddress.province" class="w-full px-3 py-2 border rounded-lg" placeholder="省份" />
</div>
<div>
<label class="block font-bold text-gray-700 mb-1">城市</label>
<input v-model="editingAddress.city" class="w-full px-3 py-2 border rounded-lg" placeholder="城市" />
</div>
<div>
<label class="block font-bold text-gray-700 mb-1">/</label>
<input v-model="editingAddress.district" class="w-full px-3 py-2 border rounded-lg" placeholder="区县" />
</div>
</div>
<div>
<label class="block font-bold text-gray-700 mb-1">详细地址</label>
<input v-model="editingAddress.detail" class="w-full px-3 py-2 border rounded-lg" placeholder="街道、小区、楼层门牌号" />
</div>
<div class="pt-2">
<el-checkbox v-model="editingAddress.is_default">设为默认收货地址</el-checkbox>
</div>
</div>
<template #footer>
<button @click="saveAddress" class="px-6 py-2 bg-primary text-white font-bold text-xs rounded-lg hover:bg-primary-hover">
保存地址
</button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '@/store/user'
import {
getOrderList,
orderCancel,
orderTake,
getAddressList,
editAddress,
delAddress,
setDefaultAddress,
getCollectList,
delCollect,
getMyCoupons
} from '@/api'
import { ElMessage, ElMessageBox } from 'element-plus'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const activeTab = ref(route.query.tab || 'orders')
const orderStatusList = [
{ label: '全部', val: '' },
{ label: '待付款', val: '0' },
{ label: '待发货', val: '1' },
{ label: '待收货', val: '2' },
{ label: '已完成', val: '3' }
]
const orderStatus = ref('')
const orders = ref([])
const ordersLoading = ref(false)
const addressList = ref([])
const showAddressModal = ref(false)
const editingAddress = ref({
id: 0,
real_name: '',
phone: '',
province: '',
city: '',
district: '',
detail: '',
is_default: false
})
const collectList = ref([])
const couponList = ref([])
const switchTab = (tab) => {
activeTab.value = tab
router.replace({ query: { ...route.query, tab } })
loadTabData(tab)
}
const loadTabData = (tab) => {
if (tab === 'orders') fetchOrders()
else if (tab === 'address') fetchAddresses()
else if (tab === 'collect') fetchCollects()
else if (tab === 'coupons') fetchCoupons()
}
const fetchOrders = async () => {
ordersLoading.value = true
try {
const res = await getOrderList({ page: 1, limit: 20, type: orderStatus.value })
orders.value = (res && res.list) || []
} catch (e) {
// ignore
} finally {
ordersLoading.value = false
}
}
const filterOrderStatus = (val) => {
orderStatus.value = val
fetchOrders()
}
const cancelOrder = (id) => {
ElMessageBox.confirm('确定要取消该订单吗?', '提示', { type: 'warning' }).then(async () => {
await orderCancel(id)
ElMessage.success('订单已取消')
fetchOrders()
})
}
const takeOrder = (uni) => {
ElMessageBox.confirm('确认已收到商品?', '提示', { type: 'success' }).then(async () => {
await orderTake(uni)
ElMessage.success('已确认收货')
fetchOrders()
})
}
const fetchAddresses = async () => {
try {
const res = await getAddressList({ page: 1, limit: 50 })
addressList.value = (res && res.list) || []
} catch (e) {
// ignore
}
}
const openEditAddressModal = (addr) => {
if (addr) {
editingAddress.value = { ...addr, is_default: !!addr.is_default }
} else {
editingAddress.value = {
id: 0,
real_name: '',
phone: '',
province: '',
city: '',
district: '',
detail: '',
is_default: false
}
}
showAddressModal.value = true
}
const saveAddress = async () => {
if (!editingAddress.value.real_name || !editingAddress.value.phone || !editingAddress.value.detail) {
ElMessage.warning('请填写完整的收货人、电话与详细地址')
return
}
try {
await editAddress({
...editingAddress.value,
is_default: editingAddress.value.is_default ? 1 : 0
})
ElMessage.success('地址保存成功')
showAddressModal.value = false
fetchAddresses()
} catch (e) {
// ignore
}
}
const deleteAddress = (id) => {
ElMessageBox.confirm('确定删除该地址吗?', '提示', { type: 'warning' }).then(async () => {
await delAddress(id)
ElMessage.success('删除成功')
fetchAddresses()
})
}
const setAddrDefault = async (id) => {
try {
await setDefaultAddress(id)
ElMessage.success('默认地址设置成功')
fetchAddresses()
} catch (e) {
// ignore
}
}
const fetchCollects = async () => {
try {
const res = await getCollectList({ page: 1, limit: 50 })
collectList.value = (res && res.list) || []
} catch (e) {
// ignore
}
}
const removeCollect = async (pid) => {
try {
await delCollect(pid)
ElMessage.success('已取消收藏')
fetchCollects()
} catch (e) {
// ignore
}
}
const fetchCoupons = async () => {
try {
const res = await getMyCoupons(0)
couponList.value = res || []
} catch (e) {
// ignore
}
}
watch(() => route.query.tab, (newTab) => {
if (newTab) {
activeTab.value = newTab
loadTabData(newTab)
}
})
onMounted(() => {
userStore.fetchUserCenter()
loadTabData(activeTab.value)
})
</script>