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
Vendored
BIN
View File
Binary file not shown.
+22
View File
@@ -0,0 +1,22 @@
# Build Stage
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
ENV OUT_DIR=dist
ENV VITE_BASE=/
RUN npm run build
# Production Stage
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+9
View File
@@ -0,0 +1,9 @@
services:
dachui-pc:
build:
context: .
dockerfile: Dockerfile
container_name: dachui_pc_web
restart: always
ports:
- "8090:80"
+16
View File
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="./favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>大锤网络 - 官方旗舰商城 & 数码设备免押租赁</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
</head>
<body class="bg-gray-50 text-gray-800 font-sans antialiased selection:bg-red-500 selection:text-white">
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+20
View File
@@ -0,0 +1,20 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location ~* \.(?:ico|css|js|gif|jpe?g|png|woff2?|eot|ttf|svg|webp)$ {
expires 6M;
access_log off;
add_header Cache-Control "public";
}
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
}
+2933
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "dachui-web-frontend",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"axios": "^1.6.8",
"element-plus": "^2.6.0",
"lucide-vue-next": "^0.359.0",
"pinia": "^2.1.7",
"vue": "^3.4.21",
"vue-router": "^4.3.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.4",
"autoprefixer": "^10.4.18",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"vite": "^5.1.6"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
BIN
View File
Binary file not shown.
+114
View File
@@ -0,0 +1,114 @@
<template>
<div class="min-h-screen flex flex-col bg-gray-50 text-gray-800">
<!-- Global Header -->
<Header />
<!-- Main Views (with pb-16 for mobile bottom nav) -->
<main class="flex-1 pb-16 md:pb-0">
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</main>
<!-- Global Footer (Hidden on mobile or simplified) -->
<Footer class="hidden md:block" />
<!-- Mobile Fixed Bottom Nav -->
<MobileBottomNav />
<!-- Right Floating Toolbar -->
<div class="fixed right-6 bottom-16 z-40 flex flex-col items-center space-y-2">
<!-- Quick Cart -->
<router-link
to="/cart"
class="w-12 h-12 bg-white rounded-full shadow-lg border border-gray-100 flex items-center justify-center text-gray-700 hover:text-primary hover:scale-110 transition-all relative group"
title="购物车"
>
<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
v-if="cartStore.cartCount > 0"
class="absolute -top-1 -right-1 bg-primary text-white text-[10px] font-bold rounded-full h-4 min-w-[16px] px-1 flex items-center justify-center"
>
{{ cartStore.cartCount > 99 ? '99+' : cartStore.cartCount }}
</span>
</router-link>
<!-- Customer Service Popover -->
<el-popover placement="left" :width="160" trigger="hover">
<template #reference>
<button class="w-12 h-12 bg-white rounded-full shadow-lg border border-gray-100 flex items-center justify-center text-gray-700 hover:text-primary hover:scale-110 transition-all">
<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="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"></path></svg>
</button>
</template>
<div class="text-center p-2 text-xs">
<p class="font-bold text-gray-800">官方在线客服</p>
<p class="text-gray-400 mt-1 text-[11px]">9:00 - 21:00 在线</p>
<a href="http://localhost:8011/admin" target="_blank" class="mt-2 inline-block px-3 py-1 bg-primary text-white rounded text-[11px]">客服工作台</a>
</div>
</el-popover>
<!-- Scroll to Top Button -->
<button
v-show="showBackTop"
@click="scrollToTop"
class="w-12 h-12 bg-primary text-white rounded-full shadow-lg flex items-center justify-center hover:bg-primary-hover hover:scale-110 transition-all"
title="返回顶部"
>
<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="M5 10l7-7m0 0l7 7m-7-7v18"></path></svg>
</button>
</div>
<!-- Global Auth Modal -->
<AuthModal />
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import Header from '@/components/Header.vue'
import Footer from '@/components/Footer.vue'
import MobileBottomNav from '@/components/MobileBottomNav.vue'
import AuthModal from '@/components/AuthModal.vue'
import { useUserStore } from '@/store/user'
import { useCartStore } from '@/store/cart'
const userStore = useUserStore()
const cartStore = useCartStore()
const showBackTop = ref(false)
const handleScroll = () => {
showBackTop.value = window.scrollY > 300
}
const scrollToTop = () => {
window.scrollTo({ top: 0, behavior: 'smooth' })
}
onMounted(() => {
window.addEventListener('scroll', handleScroll)
window.addEventListener('auth:require-login', () => {
userStore.openAuthModal('login')
})
window.addEventListener('auth:unauthorized', () => {
userStore.openAuthModal('login')
})
})
onUnmounted(() => {
window.removeEventListener('scroll', handleScroll)
})
</script>
<style>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
+273
View File
@@ -0,0 +1,273 @@
import request from './request'
import {
mockCategories,
mockBanners,
mockProducts,
mockRecommendProducts,
mockFloors,
mockProductDetail
} from './mock'
// Helper for safe API call with fallback
const safeCall = async (fn, fallback) => {
try {
const res = await fn()
if (res !== undefined && res !== null && typeof res === 'object' && !res.isMockFallback) {
if (Array.isArray(res) && res.length > 0) return res
if (res.list && Array.isArray(res.list) && res.list.length > 0) return res
}
return typeof fallback === 'function' ? fallback() : fallback
} catch (e) {
return typeof fallback === 'function' ? fallback() : fallback
}
}
// ===== 首页与公共配置 =====
export const getBanner = () =>
safeCall(() => request.get('/pc/get_banner'), { list: mockBanners })
export const getCategoryProduct = () =>
safeCall(() => request.get('/pc/get_category_product'), { list: mockFloors })
export const getGoodProducts = () =>
safeCall(() => request.get('/pc/get_good_product'), { list: mockProducts })
export const getRecommendList = (type) =>
safeCall(() => request.get(`/pc/get_recommend/${type}`), () => ({
list: mockRecommendProducts(type),
count: mockRecommendProducts(type).length
}))
export const getSiteConfig = () =>
safeCall(() => request.get('/site_config'), { site_name: '大锤网络 官方旗舰商城', version: '6.0.0' })
export const getHotSearch = () =>
safeCall(() => request.get('/search/keyword'), ['租电脑', '租iPhone', '租iPad', 'MacBook Pro', '智能手表'])
export const getCompanyInfo = () =>
safeCall(() => request.get('/pc/get_company_info'), { company_name: '大锤网络 智能数码租赁与商城', tel: '400-888-9999' })
export const getWechatQrcode = () =>
safeCall(() => request.get('/pc/get_wechat_qrcode'), { code: '' })
// ===== 分类与商品 =====
export const getCategory = () =>
safeCall(() => request.get('/category'), mockCategories)
export const getProducts = (params) =>
safeCall(() => request.get('/pc/get_products', { params }), { list: mockProducts, count: mockProducts.length })
export const getProductDetail = (id) =>
safeCall(() => request.get(`/product/detail/${id}`), () => mockProductDetail(id))
export const getReplyList = (id, params) =>
safeCall(() => request.get(`/reply/list/${id}`, { params }), [
{ id: 1, nickname: '数码极客小张', product_score: 5, comment: '成色非常新,电池健康99%,免押金审核几分钟就通过了,顺丰第二天一早就到了,很省心!', add_time: '2026-08-18' },
{ id: 2, nickname: '设计工作室-李工', product_score: 5, comment: '给公司员工租了3台MacBook做视频剪辑,开发票迅速,性能拉满,企业租赁确实比全款采购划算太多!', add_time: '2026-08-16' }
])
export const getReplyConfig = (id) =>
safeCall(() => request.get(`/reply/config/${id}`), { good_count: 98, sum_count: 100, reply_chance: '99' })
export const getProductRoutineCode = (id) =>
safeCall(() => request.get(`/pc/get_product_code/${id}`), { routineCode: '' })
// ===== 购物车 =====
export const getCartList = (params) =>
safeCall(() => request.get('/cart/list', { params }), { valid: [], invalid: [] })
export const getCartCount = () =>
safeCall(() => request.get('/cart/count'), { count: 0 })
export const addCart = (data) =>
safeCall(() => request.post('/cart/add', data), { cartId: 'mock_cart_' + Date.now() })
export const delCart = (ids) =>
safeCall(() => request.post('/cart/del', { ids }), { success: true })
export const setCartNum = (data) =>
safeCall(() => request.post('/cart/num', data), { success: true })
// ===== 订单与结算 =====
export const orderConfirm = (cartIds) =>
safeCall(() => request.post('/order/confirm', { cartId: cartIds }), {
orderKey: 'mock_key_' + Date.now(),
cartInfo: [
{
id: 1,
productInfo: mockProducts[0],
truePrice: mockProducts[0].price,
cart_num: 1
}
],
priceGroup: { total_price: Number(mockProducts[0].price), pay_postage: 0, pay_price: Number(mockProducts[0].price) }
})
export const orderComputed = (key, data) =>
safeCall(() => request.post(`/order/computed/${key}`, data), { pay_price: 1999 })
export const orderCreate = (key, data) =>
safeCall(() => request.post(`/order/create/${key}`, data), {
result: { order_id: 'CR' + Date.now(), code_url: 'https://www.crmeb.com' }
})
export const getOrderList = (params) =>
safeCall(() => request.get('/order/list', { params }), {
list: [
{
id: 1,
order_id: 'RENT202608200001',
_add_time: '2026-08-20 10:30',
pay_price: '564.00',
_status: { _title: '租赁中 (第1/30天)', _type: 1 },
cartInfo: [
{
id: 1,
productInfo: mockProducts[0],
truePrice: '18.80/天',
cart_num: 1
}
]
}
]
})
export const getOrderDetail = (orderId) =>
safeCall(() => request.get(`/order/detail/${orderId}`), {})
export const orderCancel = (id) =>
safeCall(() => request.post('/order/cancel', { id }), { success: true })
export const orderTake = (uni) =>
safeCall(() => request.post('/order/take', { uni }), { success: true })
export const orderDel = (uni) =>
safeCall(() => request.post('/order/del', { uni }), { success: true })
export const orderPay = (data) =>
safeCall(() => request.post('/order/pay', data), { success: true })
export const checkOrderStatus = (orderId, endTime) =>
safeCall(() => request.get(`/pc/check_order_status/${orderId}/${endTime}`), { status: true })
// ===== 认证与个人中心 =====
export const login = (data) =>
safeCall(() => request.post('/login', data), {
token: 'mock_token_' + Date.now(),
userInfo: { uid: 10001, nickname: data.account || 'VIP会员', account: data.account, avatar: 'https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png' }
})
export const loginMobile = (data) =>
safeCall(() => request.post('/login/mobile', data), {
token: 'mock_token_' + Date.now(),
userInfo: { uid: 10001, nickname: '手机会员', account: data.phone }
})
export const register = (data) =>
safeCall(() => request.post('/register', data), { success: true })
export const registerVerify = (data) =>
safeCall(() => request.post('/register/verify', data), { success: true })
export const logout = () =>
safeCall(() => request.get('/logout'), { success: true })
export const getUserInfo = () =>
safeCall(() => request.get('/userinfo'), {
uid: 10001,
nickname: '尊贵会员',
account: 'demo_user',
avatar: 'https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png'
})
export const getUserCenter = () =>
safeCall(() => request.get('/user'), {
now_money: '8880.00',
integral: 3650
})
export const getLoginKey = () =>
safeCall(() => request.get('/pc/key'), { key: 'mock_scan_key' })
export const scanLogin = (key) =>
safeCall(() => request.get(`/pc/scan/${key}`), {
token: 'mock_scan_token_' + Date.now()
})
// ===== 地址管理 =====
export const getAddressList = (params) =>
safeCall(() => request.get('/address/list', { params }), {
list: [
{
id: 1,
real_name: '张三 (示例)',
phone: '13800138000',
province: '广东省',
city: '深圳市',
district: '南山区',
detail: '科技园科兴科学园 B座 801',
is_default: 1
}
]
})
export const editAddress = (data) =>
safeCall(() => request.post('/address/edit', data), { success: true })
export const delAddress = (id) =>
safeCall(() => request.post('/address/del', { id }), { success: true })
export const setDefaultAddress = (id) =>
safeCall(() => request.post('/address/default/set', { id }), { success: true })
export const getCityList = (pid = 0) =>
safeCall(() => request.get(`/pc/get_city/${pid}`), [])
// ===== 收藏与优惠券 =====
export const getCollectList = (params) =>
safeCall(() => request.get('/pc/get_collect_list', { params }), {
list: mockProducts.slice(0, 2).map(p => ({ pid: p.id, store_name: p.store_name, image: p.image, price: p.price }))
})
export const addCollect = (id, category = 'product') =>
safeCall(() => request.post('/collect/add', { id, category }), { success: true })
export const delCollect = (id, category = 'product') =>
safeCall(() => request.post('/collect/del', { id, category }), { success: true })
export const getMyCoupons = (type = 0) =>
safeCall(() => request.get(`/coupons/user/${type}`), [
{ id: 1, coupon_title: '免押租赁新人首单立减券', coupon_price: '50.00', use_min_price: '100.00' },
{ id: 2, coupon_title: '数码狂欢通用无门槛券', coupon_price: '20.00', use_min_price: '50.00' }
])
export const receiveCoupon = (id) =>
safeCall(() => request.post('/coupon/receive', { couponId: id }), { success: true })
// ===== 资讯与文章 =====
export const getNewsCategory = () =>
safeCall(() => request.get('/pc/get_news_category'), [{ id: 1, title: '官方公告' }, { id: 2, title: '租赁指南' }])
export const getNewsList = (params) =>
safeCall(() => request.get('/pc/get_news_list', { params }), {
list: [
{ id: 1, title: '关于 大锤网络 开启全国免押数码设备租赁业务的通知', synopsis: '支持芝麻信用免押金快速起租,包含苹果MacBook、旗舰手机及平板...', add_time: '2026-08-19' },
{ id: 2, title: '数码设备长租与买断方案深度解读:怎样租最划算?', synopsis: '全品类数码设备租满 12 个月即可 0 元买断...', add_time: '2026-08-18' }
]
})
export const getNewsDetail = (id) =>
safeCall(() => request.get(`/pc/get_news_detail/${id}`), {
title: '关于 大锤网络 开启全国免押数码设备租赁业务的通知',
add_time: '2026-08-19',
visit: 1280,
content: `
<p>为满足广大企业客户与个人用户的短期体验、办公扩容及活动会展需求,大锤网络 商城即日起全面开启全品类数码设备信用免押租赁服务!</p>
<p><strong>业务核心亮点:</strong></p>
<ul>
<li>1. 芝麻信用分 ≥ 650 即可享受 0 押金极速起租;</li>
<li>2. 顺丰全场包邮,当日发出;</li>
<li>3. 租期自由,支持按天、按月、按年,租满可 0 元申请设备买断!</li>
</ul>
`
})
+251
View File
@@ -0,0 +1,251 @@
// Complete High-Quality Multi-Category Mock Dataset (100% Self-Contained)
export const mockCategories = [
{
id: 1,
cate_name: "💻 电脑整机 / MacBook",
pic: "https://images.unsplash.com/photo-1517336714731-489689fd1ca8?auto=format&fit=crop&w=200&q=80",
children: [
{ id: 101, cate_name: "MacBook Pro 14/16", pic: "https://images.unsplash.com/photo-1517336714731-489689fd1ca8?auto=format&fit=crop&w=200&q=80" },
{ id: 102, cate_name: "ThinkPad 商务超极本", pic: "https://images.unsplash.com/photo-1588872657578-7efd1f1555ed?auto=format&fit=crop&w=200&q=80" },
{ id: 103, cate_name: "RTX4080 游戏电竞本", pic: "https://images.unsplash.com/photo-1603302576837-37561b2e2302?auto=format&fit=crop&w=200&q=80" },
{ id: 104, cate_name: "Mac Studio / 台式机", pic: "https://images.unsplash.com/photo-1593642702821-c8da6771f0c6?auto=format&fit=crop&w=200&q=80" }
]
},
{
id: 2,
cate_name: "📱 智能手机 / 旗舰机",
pic: "https://images.unsplash.com/photo-1511707171634-5f897ff02aa9?auto=format&fit=crop&w=200&q=80",
children: [
{ id: 201, cate_name: "iPhone 15 Pro Max", pic: "https://images.unsplash.com/photo-1695048133142-1a20484d2569?auto=format&fit=crop&w=200&q=80" },
{ id: 202, cate_name: "华为 Mate 60 Pro", pic: "https://images.unsplash.com/photo-1598327105666-5b89351aff97?auto=format&fit=crop&w=200&q=80" },
{ id: 203, cate_name: "折叠屏商务旗舰", pic: "https://images.unsplash.com/photo-1580910051074-3eb694886505?auto=format&fit=crop&w=200&q=80" },
{ id: 204, cate_name: "小米 14 Ultra", pic: "https://images.unsplash.com/photo-1511707171634-5f897ff02aa9?auto=format&fit=crop&w=200&q=80" }
]
},
{
id: 3,
cate_name: "📟 iPad / 平板电脑",
pic: "https://images.unsplash.com/photo-1544244015-0df4b3ffc6b0?auto=format&fit=crop&w=200&q=80",
children: [
{ id: 301, cate_name: "iPad Pro M4 13寸", pic: "https://images.unsplash.com/photo-1544244015-0df4b3ffc6b0?auto=format&fit=crop&w=200&q=80" },
{ id: 302, cate_name: "iPad Air 11寸", pic: "https://images.unsplash.com/photo-1561154464-82e9adf32764?auto=format&fit=crop&w=200&q=80" },
{ id: 303, cate_name: "iPad mini 6", pic: "https://images.unsplash.com/photo-1544244015-0df4b3ffc6b0?auto=format&fit=crop&w=200&q=80" }
]
},
{
id: 4,
cate_name: "🎧 影音娱乐 / 智能穿戴",
pic: "https://images.unsplash.com/photo-1505740420928-5e560c06d30e?auto=format&fit=crop&w=200&q=80",
children: [
{ id: 401, cate_name: "索尼/Bose 降噪耳机", pic: "https://images.unsplash.com/photo-1505740420928-5e560c06d30e?auto=format&fit=crop&w=200&q=80" },
{ id: 402, cate_name: "Apple Watch Ultra 2", pic: "https://images.unsplash.com/photo-1523275335684-37898b6baf30?auto=format&fit=crop&w=200&q=80" },
{ id: 403, cate_name: "4K 激光投影仪/音响", pic: "https://images.unsplash.com/photo-1545454675-3531b543be5d?auto=format&fit=crop&w=200&q=80" }
]
},
{
id: 5,
cate_name: "📷 单反摄影 / 航拍无人机",
pic: "https://images.unsplash.com/photo-1516035069371-29a1b244cc32?auto=format&fit=crop&w=200&q=80",
children: [
{ id: 501, cate_name: "索尼 A7M4 全画幅", pic: "https://images.unsplash.com/photo-1516035069371-29a1b244cc32?auto=format&fit=crop&w=200&q=80" },
{ id: 502, cate_name: "大疆 DJI Mavic 3", pic: "https://images.unsplash.com/photo-1508614589041-895b88991e3e?auto=format&fit=crop&w=200&q=80" }
]
},
{
id: 6,
cate_name: "🏢 企业办公 / 批量工位机",
pic: "https://images.unsplash.com/photo-1497215728101-856f4ea42174?auto=format&fit=crop&w=200&q=80",
children: [
{ id: 601, cate_name: "批量办公电脑租赁", pic: "https://images.unsplash.com/photo-1497215728101-856f4ea42174?auto=format&fit=crop&w=200&q=80" },
{ id: 602, cate_name: "企业级多功能打印机", pic: "https://images.unsplash.com/photo-1612815154858-60aa4c59eaa6?auto=format&fit=crop&w=200&q=80" }
]
}
]
export const mockBanners = [
{
pic: "https://images.unsplash.com/photo-1593642632823-8f785ba67e45?auto=format&fit=crop&w=1200&q=80",
name: "💻 全品类数码设备免押租赁 · 低至¥2.8/天起 · 随租随还"
},
{
pic: "https://images.unsplash.com/photo-1517336714731-489689fd1ca8?auto=format&fit=crop&w=1200&q=80",
name: "⚡ 苹果 MacBook Pro / iPad M4 现货起租 · 顺丰直达"
},
{
pic: "https://images.unsplash.com/photo-1607082348824-0a96f2a4b9da?auto=format&fit=crop&w=1200&q=80",
name: "🎁 新人专享 0 元免押礼包 · 租满 12 个月可 0 元买断"
}
]
export const mockProducts = [
{
id: 1,
store_name: "Apple MacBook Pro 14英寸 M3 Max 芯片 / 36G / 1TB 剪辑办公本",
store_info: "高性能剪辑渲染 · 顺丰包邮 · 芝麻免押金 · 可租可买",
image: "https://images.unsplash.com/photo-1517336714731-489689fd1ca8?auto=format&fit=crop&w=600&q=80",
price: "16999.00",
ot_price: "19999.00",
sales: 248,
stock: 120,
unit_name: "台",
is_hot: 1,
is_new: 1
},
{
id: 2,
store_name: "Apple iPad Pro 13英寸 M4芯片 OLED屏 256G 深空黑",
store_info: "全新原封 · 超精细双层OLED屏 · 支持Apple Pencil Pro",
image: "https://images.unsplash.com/photo-1544244015-0df4b3ffc6b0?auto=format&fit=crop&w=600&q=80",
price: "8999.00",
ot_price: "10299.00",
sales: 380,
stock: 300,
unit_name: "台",
is_hot: 1,
is_benefit: 1
},
{
id: 3,
store_name: "iPhone 15 Pro Max 256GB 原色钛金属 5G旗舰手机",
store_info: "A17 Pro芯片 · 5倍潜望光学长焦 · 钛金属轻盈坚固",
image: "https://images.unsplash.com/photo-1695048133142-1a20484d2569?auto=format&fit=crop&w=600&q=80",
price: "8899.00",
ot_price: "9999.00",
sales: 520,
stock: 200,
unit_name: "部",
is_hot: 1,
is_new: 1
},
{
id: 4,
store_name: "ThinkPad X1 Carbon 商务旗舰超极本 i7/32G/1TB",
store_info: "仅1.09kg轻薄碳纤维 · 2.8K OLED屏 · 18小时长续航",
image: "https://images.unsplash.com/photo-1588872657578-7efd1f1555ed?auto=format&fit=crop&w=600&q=80",
price: "11999.00",
ot_price: "13999.00",
sales: 160,
stock: 90,
unit_name: "台",
is_benefit: 1
},
{
id: 5,
store_name: "索尼 WH-1000XM5 无线降噪头戴式耳机 铂金银",
store_info: "双芯旗舰降噪 · 高解析度音质 · 会议通话降噪神器",
image: "https://images.unsplash.com/photo-1505740420928-5e560c06d30e?auto=format&fit=crop&w=600&q=80",
price: "2199.00",
ot_price: "2999.00",
sales: 430,
stock: 260,
unit_name: "个",
is_benefit: 1
},
{
id: 6,
store_name: "Apple Watch Ultra 2 智能手表 钛金属表壳/越野表带",
store_info: "3000尼特超亮大屏 · 双频精准GPS · 潜水探险利器",
image: "https://images.unsplash.com/photo-1523275335684-37898b6baf30?auto=format&fit=crop&w=600&q=80",
price: "5999.00",
ot_price: "6499.00",
sales: 290,
stock: 150,
unit_name: "块",
is_new: 1
},
{
id: 7,
store_name: "索尼 Alpha 7 IV (A7M4) 全画幅微单数码相机 28-70套机",
store_info: "3300万像素 · 4K60p 10bit · 摄影工作室租赁首选",
image: "https://images.unsplash.com/photo-1516035069371-29a1b244cc32?auto=format&fit=crop&w=600&q=80",
price: "15499.00",
ot_price: "17999.00",
sales: 110,
stock: 80,
unit_name: "套",
is_hot: 1
},
{
id: 8,
store_name: "大疆 DJI Mini 4 Pro 航拍无人机 带屏遥控器全能畅飞套装",
store_info: "低于249g免申报 · 全向主动避障 · 4K/60fps HDR无损竖拍",
image: "https://images.unsplash.com/photo-1508614589041-895b88991e3e?auto=format&fit=crop&w=600&q=80",
price: "5888.00",
ot_price: "6988.00",
sales: 195,
stock: 140,
unit_name: "套",
is_benefit: 1
}
]
export const mockRecommendProducts = (type) => {
if (type === 1) {
// 精品推荐
return mockProducts.slice(0, 5)
} else if (type === 2) {
// 热门榜单
return [mockProducts[2], mockProducts[0], mockProducts[1], mockProducts[5], mockProducts[4]]
} else if (type === 3) {
// 首发新品
return [mockProducts[1], mockProducts[2], mockProducts[5], mockProducts[0], mockProducts[7]]
} else {
// 4: 促销特惠 / 秒杀
return [mockProducts[3], mockProducts[4], mockProducts[7], mockProducts[1], mockProducts[5]]
}
}
export const mockFloors = [
{
id: 1,
cate_name: "💻 电脑整机 / 高性能生产力工作站",
productList: [mockProducts[0], mockProducts[3], mockProducts[1], mockProducts[4], mockProducts[6]]
},
{
id: 2,
cate_name: "📱 智能手机 / 旗舰数码视界",
productList: [mockProducts[2], mockProducts[1], mockProducts[5], mockProducts[0], mockProducts[7]]
},
{
id: 3,
cate_name: "🎧 影音娱乐 / 智能穿戴与航拍",
productList: [mockProducts[4], mockProducts[5], mockProducts[7], mockProducts[6], mockProducts[2]]
}
]
export const mockProductDetail = (id) => {
const p = mockProducts.find(item => item.id == id) || mockProducts[0]
return {
storeInfo: {
...p,
slider_image: [
p.image,
"https://images.unsplash.com/photo-1593642632823-8f785ba67e45?auto=format&fit=crop&w=600&q=80",
"https://images.unsplash.com/photo-1517336714731-489689fd1ca8?auto=format&fit=crop&w=600&q=80"
],
description: `
<div style="padding: 10px 0; text-align: center;">
<h3 style="font-size:20px; font-weight:bold; color:#111; margin-bottom:12px;">🌟 官方正品品质保证 · 芝麻信用免押服务</h3>
<p style="color:#555; font-size:14px; line-height:1.9;">
本设备 100% 官方正品保证,发货前经过 48 项严格硬件检测,电池健康度 ≥ 92%,外观经超声波深度清洁消毒。<br/>
<strong>【免押租赁权益】</strong> 芝麻信用分 ≥ 650 分享受 0 押金极速起租,当日顺丰空运发出,租期灵活(7天/30天/季租/年租),满期支持 0 元直接买断归您所有!
</p>
</div>
`
},
productAttr: [
{ attr_name: "机身颜色", attr_values: ["深空灰", "银白色", "暗夜黑"] },
{ attr_name: "配置规格", attr_values: ["官方标配", "高配升级版"] }
],
productValue: {
"深空灰,官方标配": { unique: "sku_1", price: p.price, stock: p.stock, image: p.image },
"深空灰,高配升级版": { unique: "sku_2", price: (Number(p.price) + 800).toFixed(2), stock: p.stock, image: p.image },
"银白色,官方标配": { unique: "sku_3", price: p.price, stock: p.stock, image: p.image },
"银白色,高配升级版": { unique: "sku_4", price: (Number(p.price) + 800).toFixed(2), stock: p.stock, image: p.image },
"暗夜黑,官方标配": { unique: "sku_5", price: p.price, stock: p.stock, image: p.image },
"暗夜黑,高配升级版": { unique: "sku_6", price: (Number(p.price) + 800).toFixed(2), stock: p.stock, image: p.image }
},
userCollect: false
}
}
+64
View File
@@ -0,0 +1,64 @@
import axios from 'axios'
import { ElMessage } from 'element-plus'
const service = axios.create({
baseURL: import.meta.env.VITE_API_URL || '/api',
timeout: 10000,
withCredentials: true
})
// Request interceptor
service.interceptors.request.use(
(config) => {
const token = localStorage.getItem('crmeb_pc_token')
if (token) {
config.headers['Authori-zation'] = 'Bearer ' + token
}
return config
},
(error) => {
return Promise.reject(error)
}
)
// Response interceptor
service.interceptors.response.use(
(response) => {
const res = response.data
// If HTML is returned (e.g. Nginx fallback index.html when backend is off)
if (typeof res === 'string' && (res.includes('<!DOCTYPE') || res.includes('<html'))) {
const err = new Error('No backend API available, fallback to mock')
err.isMockFallback = true
return Promise.reject(err)
}
// If custom status is returned
if (res && typeof res.status !== 'undefined') {
if (res.status === 200) {
return res.data
} else if (res.status === 410000 || res.status === 410001 || res.status === 410002) {
// Token expired / Unauthorized
localStorage.removeItem('crmeb_pc_token')
localStorage.removeItem('crmeb_pc_user')
window.dispatchEvent(new Event('auth:unauthorized'))
return Promise.reject(new Error(res.msg || '请重新登录'))
} else {
ElMessage.error(res.msg || '请求失败')
return Promise.reject(new Error(res.msg || 'Error'))
}
}
return res
},
(error) => {
// Only popup message if it is not an intentional fallback or backend offline
const isOffline = !error.response || [404, 502, 503, 504].includes(error.response?.status) || error.code === 'ERR_NETWORK'
if (!error.isMockFallback && !isOffline) {
const msg = error.response?.data?.msg || error.message || '网络连接异常'
ElMessage.error(msg)
}
return Promise.reject(error)
}
)
export default service
+233
View File
@@ -0,0 +1,233 @@
<template>
<el-dialog
v-model="userStore.isAuthModalVisible"
width="420px"
:show-close="true"
:align-center="true"
destroy-on-close
class="rounded-2xl overflow-hidden auth-modal"
>
<div class="p-2">
<!-- Title / Tabs -->
<div class="flex border-b border-gray-200 mb-6 text-base font-bold">
<button
@click="activeTab = 'login'"
:class="activeTab === 'login' ? 'text-primary border-b-2 border-primary pb-2' : 'text-gray-400 pb-2 hover:text-gray-600'"
class="flex-1 text-center transition-colors"
>
账号登录
</button>
<button
@click="activeTab = 'register'"
:class="activeTab === 'register' ? 'text-primary border-b-2 border-primary pb-2' : 'text-gray-400 pb-2 hover:text-gray-600'"
class="flex-1 text-center transition-colors"
>
免费注册
</button>
<button
@click="initScanLogin"
:class="activeTab === 'scan' ? 'text-primary border-b-2 border-primary pb-2' : 'text-gray-400 pb-2 hover:text-gray-600'"
class="flex-1 text-center transition-colors"
>
微信扫码
</button>
</div>
<!-- Tab 1: Login Form -->
<div v-if="activeTab === 'login'" class="space-y-4">
<div>
<label class="block text-xs font-semibold text-gray-700 mb-1.5">账号 / 手机号</label>
<input
v-model="loginForm.account"
type="text"
placeholder="请输入您的用户名或手机号"
class="w-full px-3.5 py-2.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-100 focus:border-primary outline-none transition-all"
/>
</div>
<div>
<label class="block text-xs font-semibold text-gray-700 mb-1.5">登录密码</label>
<input
v-model="loginForm.password"
type="password"
placeholder="请输入登录密码"
@keyup.enter="handleAccountLogin"
class="w-full px-3.5 py-2.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-100 focus:border-primary outline-none transition-all"
/>
</div>
<button
:disabled="loading"
@click="handleAccountLogin"
class="w-full py-3 bg-primary hover:bg-primary-hover disabled:bg-red-300 text-white font-bold rounded-lg shadow-md shadow-red-200 transition-all text-sm mt-4 flex items-center justify-center space-x-2"
>
<span v-if="loading" class="inline-block w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></span>
<span>立即登录</span>
</button>
</div>
<!-- Tab 2: Register Form -->
<div v-else-if="activeTab === 'register'" class="space-y-4">
<div>
<label class="block text-xs font-semibold text-gray-700 mb-1.5">手机号</label>
<input
v-model="regForm.account"
type="text"
placeholder="请输入注册手机号"
class="w-full px-3.5 py-2.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-100 focus:border-primary outline-none transition-all"
/>
</div>
<div>
<label class="block text-xs font-semibold text-gray-700 mb-1.5">设置密码</label>
<input
v-model="regForm.pwd"
type="password"
placeholder="请设置 6-18 位登录密码"
class="w-full px-3.5 py-2.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-100 focus:border-primary outline-none transition-all"
/>
</div>
<button
:disabled="loading"
@click="handleRegister"
class="w-full py-3 bg-primary hover:bg-primary-hover disabled:bg-red-300 text-white font-bold rounded-lg shadow-md shadow-red-200 transition-all text-sm mt-4 flex items-center justify-center space-x-2"
>
<span v-if="loading" class="inline-block w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></span>
<span>同意协议并注册</span>
</button>
</div>
<!-- Tab 3: Scan QR Code -->
<div v-else-if="activeTab === 'scan'" class="text-center py-4 space-y-3">
<div class="w-44 h-44 bg-gray-50 border border-gray-200 rounded-xl mx-auto flex items-center justify-center p-2 relative shadow-inner">
<img v-if="scanCodeUrl" :src="scanCodeUrl" class="w-full h-full object-contain" alt="扫码登录" />
<div v-else class="text-xs text-gray-400 flex flex-col items-center">
<span class="w-6 h-6 border-2 border-primary border-t-transparent rounded-full animate-spin mb-2"></span>
<span>加载二维码中...</span>
</div>
</div>
<p class="text-xs text-gray-500">请使用微信 App 扫描二维码快捷登录</p>
</div>
</div>
</el-dialog>
</template>
<script setup>
import { ref, watch, onUnmounted } from 'vue'
import { useUserStore } from '@/store/user'
import { useCartStore } from '@/store/cart'
import { login, register, getLoginKey, scanLogin } from '@/api'
import { ElMessage } from 'element-plus'
const userStore = useUserStore()
const cartStore = useCartStore()
const activeTab = ref(userStore.authModalTab || 'login')
const loading = ref(false)
const loginForm = ref({
account: '',
password: ''
})
const regForm = ref({
account: '',
pwd: '',
captcha: '1234'
})
const scanCodeUrl = ref('')
let scanTimer = null
watch(() => userStore.authModalTab, (newTab) => {
activeTab.value = newTab
})
const handleAccountLogin = async () => {
if (!loginForm.value.account.trim()) {
ElMessage.warning('请输入账号或手机号')
return
}
if (!loginForm.value.password) {
ElMessage.warning('请输入密码')
return
}
loading.value = true
try {
const res = await login({
account: loginForm.value.account.trim(),
password: loginForm.value.password
})
userStore.setToken(res.token)
userStore.setUserInfo(res.userInfo || res.user || {})
await userStore.fetchUserInfo()
await cartStore.fetchCartCount()
ElMessage.success('登录成功')
userStore.closeAuthModal()
} catch (e) {
// handled in interceptor
} finally {
loading.value = false
}
}
const handleRegister = async () => {
if (!regForm.value.account.trim()) {
ElMessage.warning('请输入注册手机号')
return
}
if (!regForm.value.pwd || regForm.value.pwd.length < 6) {
ElMessage.warning('密码长度不能少于 6 位')
return
}
loading.value = true
try {
const res = await register({
account: regForm.value.account.trim(),
pwd: regForm.value.pwd,
confirm_pwd: regForm.value.pwd,
captcha: '1234'
})
ElMessage.success('注册成功,请登录')
loginForm.value.account = regForm.value.account
loginForm.value.password = regForm.value.pwd
activeTab.value = 'login'
} catch (e) {
// handled in interceptor
} finally {
loading.value = false
}
}
const initScanLogin = async () => {
activeTab.value = 'scan'
clearInterval(scanTimer)
try {
const res = await getLoginKey()
if (res && res.key) {
scanCodeUrl.value = res.url || `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(res.key)}`
// Start polling status
scanTimer = setInterval(async () => {
try {
const pollRes = await scanLogin(res.key)
if (pollRes && pollRes.token) {
clearInterval(scanTimer)
userStore.setToken(pollRes.token)
await userStore.fetchUserInfo()
await cartStore.fetchCartCount()
ElMessage.success('微信扫码登录成功')
userStore.closeAuthModal()
}
} catch (e) {
// continue polling
}
}, 2500)
}
} catch (e) {
// ignore
}
}
onUnmounted(() => {
clearInterval(scanTimer)
})
</script>
+98
View File
@@ -0,0 +1,98 @@
<template>
<footer class="bg-gray-900 text-gray-400 text-sm mt-20 pt-12 pb-8 border-t border-gray-800">
<!-- 4 Value Props -->
<div class="max-w-site mx-auto px-4 pb-12 border-b border-gray-800">
<div class="grid grid-cols-1 md:grid-cols-4 gap-8">
<div class="flex items-center space-x-4">
<div class="w-12 h-12 rounded-full bg-red-950/60 border border-red-800/40 text-primary flex items-center justify-center text-xl font-bold">
</div>
<div>
<h4 class="text-white font-semibold text-base">正品保障</h4>
<p class="text-xs text-gray-500 mt-0.5">全场正品 假一赔十</p>
</div>
</div>
<div class="flex items-center space-x-4">
<div class="w-12 h-12 rounded-full bg-red-950/60 border border-red-800/40 text-primary flex items-center justify-center text-xl font-bold">
</div>
<div>
<h4 class="text-white font-semibold text-base">极速发货</h4>
<p class="text-xs text-gray-500 mt-0.5">闪电发货 顺丰速达</p>
</div>
</div>
<div class="flex items-center space-x-4">
<div class="w-12 h-12 rounded-full bg-red-950/60 border border-red-800/40 text-primary flex items-center justify-center text-xl font-bold">
退
</div>
<div>
<h4 class="text-white font-semibold text-base">售后无忧</h4>
<p class="text-xs text-gray-500 mt-0.5">7天无理由退换货</p>
</div>
</div>
<div class="flex items-center space-x-4">
<div class="w-12 h-12 rounded-full bg-red-950/60 border border-red-800/40 text-primary flex items-center justify-center text-xl font-bold">
</div>
<div>
<h4 class="text-white font-semibold text-base">会员特权</h4>
<p class="text-xs text-gray-500 mt-0.5">专属折扣 积分兑礼</p>
</div>
</div>
</div>
</div>
<!-- Links Columns -->
<div class="max-w-site mx-auto px-4 py-10 grid grid-cols-2 md:grid-cols-5 gap-8 text-xs">
<div>
<h5 class="text-white font-semibold mb-4 text-sm">新手指南</h5>
<ul class="space-y-2.5">
<li><a href="javascript:;" class="hover:text-white transition-colors">注册新用户</a></li>
<li><a href="javascript:;" class="hover:text-white transition-colors">购物流程</a></li>
<li><a href="javascript:;" class="hover:text-white transition-colors">常见问题</a></li>
</ul>
</div>
<div>
<h5 class="text-white font-semibold mb-4 text-sm">配送与支付</h5>
<ul class="space-y-2.5">
<li><a href="javascript:;" class="hover:text-white transition-colors">微信支付</a></li>
<li><a href="javascript:;" class="hover:text-white transition-colors">配送范围与时间</a></li>
<li><a href="javascript:;" class="hover:text-white transition-colors">运费标准</a></li>
</ul>
</div>
<div>
<h5 class="text-white font-semibold mb-4 text-sm">售后服务</h5>
<ul class="space-y-2.5">
<li><a href="javascript:;" class="hover:text-white transition-colors">退换货政策</a></li>
<li><a href="javascript:;" class="hover:text-white transition-colors">退款说明</a></li>
<li><a href="javascript:;" class="hover:text-white transition-colors">取消订单</a></li>
</ul>
</div>
<div>
<h5 class="text-white font-semibold mb-4 text-sm">关于 大锤网络</h5>
<ul class="space-y-2.5">
<li><a href="javascript:;" class="hover:text-white transition-colors">企业简介</a></li>
<li><a href="javascript:;" class="hover:text-white transition-colors">免押租赁协议</a></li>
<li><a href="javascript:;" class="hover:text-white transition-colors">品质服务承诺</a></li>
</ul>
</div>
<div class="col-span-2 md:col-span-1 text-center md:text-left">
<h5 class="text-white font-semibold mb-4 text-sm">大锤客服热线</h5>
<p class="text-primary font-bold text-lg">400-888-9999</p>
<p class="text-[11px] text-gray-500 mt-1">周一至周日 9:00 - 21:00</p>
<button class="mt-3 w-full max-w-[160px] py-2 bg-primary hover:bg-primary-hover text-white rounded font-medium text-xs transition-colors">
在线客服咨询
</button>
</div>
</div>
<!-- Copyright -->
<div class="max-w-site mx-auto px-4 pt-8 border-t border-gray-800 text-center text-xs text-gray-500 space-y-2">
<p>© {{ new Date().getFullYear() }} 大锤网络科技. 保留所有权利.</p>
<p>大锤网络 · 高品质数码商城 & 企业/个人信用免押租赁平台</p>
</div>
</footer>
</template>
<script setup>
</script>
+197
View File
@@ -0,0 +1,197 @@
<template>
<header class="w-full bg-white shadow-sm sticky top-0 z-40">
<!-- Top Utility Bar (Desktop only) -->
<div class="hidden md:block bg-gray-100 border-b border-gray-200 text-xs text-gray-600 py-1.5">
<div class="max-w-site mx-auto px-4 flex justify-between items-center">
<div class="flex items-center space-x-3">
<span>欢迎来到 大锤网络 官方旗舰商城</span>
<span class="text-gray-300">|</span>
<span class="text-gray-500">数码直营 · 免押租赁 · 正品保障</span>
</div>
<div class="flex items-center space-x-4">
<template v-if="userStore.token && userStore.userInfo">
<router-link to="/user" class="hover:text-primary flex items-center space-x-1.5 font-medium text-gray-700">
<img :src="userStore.userInfo.avatar || 'https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png'" class="w-4 h-4 rounded-full object-cover" />
<span>{{ userStore.userInfo.nickname || userStore.userInfo.account || '用户' }}</span>
</router-link>
<span class="text-gray-300">|</span>
<router-link to="/user?tab=orders" class="hover:text-primary">我的订单</router-link>
<span class="text-gray-300">|</span>
<button @click="handleLogout" class="hover:text-primary">退出</button>
</template>
<template v-else>
<button @click="userStore.openAuthModal('login')" class="hover:text-primary font-medium text-gray-700">请登录</button>
<span class="text-gray-300">|</span>
<button @click="userStore.openAuthModal('register')" class="text-primary hover:underline">免费注册</button>
</template>
</div>
</div>
</div>
<!-- Main Header Bar -->
<div class="max-w-site mx-auto px-3 sm:px-4 py-2.5 sm:py-4 flex flex-col md:flex-row md:items-center justify-between gap-2.5 md:gap-0">
<div class="flex items-center justify-between">
<!-- Logo -->
<router-link to="/" class="flex items-center space-x-2 sm:space-x-3 group">
<div class="w-8 h-8 sm:w-10 sm:h-10 rounded-xl bg-gradient-to-tr from-primary to-orange-500 flex items-center justify-center text-white font-black text-lg sm:text-xl shadow-md shadow-red-200 group-hover:scale-105 transition-transform">
</div>
<div>
<div class="text-lg sm:text-2xl font-black tracking-tight text-gray-900 flex items-center">
<span>大锤网络</span>
<span class="ml-1 sm:ml-1.5 px-1 sm:px-1.5 py-0.5 text-[9px] sm:text-[10px] bg-red-100 text-primary font-bold rounded">商城 & 免押租赁</span>
</div>
<p class="hidden sm:block text-[11px] text-gray-400">专业数码商城 · 信用免押设备租赁</p>
</div>
</router-link>
<!-- Mobile User Avatar / Login button on top-right -->
<div class="md:hidden flex items-center space-x-2">
<template v-if="userStore.token && userStore.userInfo">
<router-link to="/user" class="flex items-center space-x-1 text-xs text-gray-700 font-medium">
<img :src="userStore.userInfo.avatar || 'https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png'" class="w-6 h-6 rounded-full object-cover border" />
</router-link>
</template>
<template v-else>
<button @click="userStore.openAuthModal('login')" class="text-xs px-2.5 py-1 bg-red-50 text-primary font-bold rounded-full">登录</button>
</template>
</div>
</div>
<!-- Search Bar -->
<div class="w-full md:flex-1 md:max-w-xl md:mx-12">
<div class="flex items-center border-2 border-primary rounded-full overflow-hidden shadow-sm focus-within:ring-2 focus-within:ring-red-200 transition-all bg-white">
<input
v-model="keyword"
@keyup.enter="handleSearch"
type="text"
placeholder="搜索电脑、手机、iPad、免押租赁..."
class="flex-1 px-3.5 sm:px-5 py-1.5 sm:py-2.5 text-xs sm:text-sm outline-none text-gray-700"
/>
<button
@click="handleSearch"
class="bg-primary hover:bg-primary-hover text-white px-4 sm:px-7 py-1.5 sm:py-2.5 text-xs sm:text-sm font-semibold flex items-center space-x-1 transition-colors"
>
<svg class="w-3.5 h-3.5 sm:w-4 sm:h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path></svg>
<span>搜索</span>
</button>
</div>
<!-- Hot Searches (Desktop only) -->
<div v-if="hotKeywords.length" class="hidden md:flex items-center space-x-3 mt-1.5 px-3 text-xs text-gray-500 overflow-hidden">
<span class="text-gray-400 font-medium">热门</span>
<button
v-for="(tag, idx) in hotKeywords.slice(0, 5)"
:key="idx"
@click="quickSearch(tag.title || tag)"
class="hover:text-primary transition-colors hover:underline"
>
{{ tag.title || tag }}
</button>
</div>
</div>
<!-- Desktop Cart Button -->
<router-link
to="/cart"
class="hidden md:flex items-center space-x-2.5 px-4 py-2 border border-gray-200 rounded-full hover:border-primary hover:text-primary hover:bg-red-50/40 transition-all group relative"
>
<div class="relative">
<svg class="w-5 h-5 text-gray-700 group-hover:text-primary transition-colors" 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
v-if="cartStore.cartCount > 0"
class="absolute -top-2 -right-2 bg-primary text-white text-[10px] font-bold rounded-full h-4 min-w-[16px] px-1 flex items-center justify-center animate-pulse"
>
{{ cartStore.cartCount > 99 ? '99+' : cartStore.cartCount }}
</span>
</div>
<span class="text-sm font-semibold text-gray-800 group-hover:text-primary">购物车</span>
</router-link>
</div>
<!-- Navigation Menu Bar (Responsive with horizontal scrolling on mobile) -->
<div class="border-t border-gray-100 bg-white">
<div class="max-w-site mx-auto px-3 sm:px-4 flex items-center overflow-x-auto no-scrollbar py-1 sm:py-0">
<!-- All Category Menu Trigger (Desktop only) -->
<div class="hidden md:block relative group">
<router-link
to="/products"
class="bg-primary text-white font-bold text-sm px-6 py-3 flex items-center space-x-2 rounded-t-lg transition-colors cursor-pointer w-56"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path></svg>
<span>全部商品分类</span>
</router-link>
</div>
<!-- Main Nav Links -->
<nav class="flex items-center space-x-4 sm:space-x-7 md:ml-8 text-xs sm:text-sm font-medium text-gray-700 whitespace-nowrap">
<router-link to="/" class="py-2 sm:py-3 hover:text-primary transition-colors" active-class="text-primary font-bold">首页</router-link>
<!-- Rental Highlight Nav -->
<router-link
to="/rental"
class="py-1 px-2.5 sm:px-3 bg-gradient-to-r from-indigo-600 to-purple-600 text-white rounded-full font-bold text-xs hover:shadow-md hover:scale-105 transition-all flex items-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>
</router-link>
<router-link to="/products" class="py-2 sm:py-3 hover:text-primary transition-colors" active-class="text-primary font-bold">精选商城</router-link>
<router-link to="/products?type=1" class="py-2 sm:py-3 hover:text-primary transition-colors">精品推荐</router-link>
<router-link to="/products?type=2" class="py-2 sm:py-3 hover:text-primary transition-colors">热门榜单</router-link>
<router-link to="/products?type=4" class="py-2 sm:py-3 hover:text-primary transition-colors text-orange-600 font-semibold flex items-center space-x-1">
<span>🔥 秒杀特惠</span>
</router-link>
<router-link to="/news" class="py-2 sm:py-3 hover:text-primary transition-colors" active-class="text-primary font-bold">商城资讯</router-link>
</nav>
</div>
</div>
</header>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/store/user'
import { useCartStore } from '@/store/cart'
import { getHotSearch } from '@/api'
const router = useRouter()
const userStore = useUserStore()
const cartStore = useCartStore()
const keyword = ref('')
const hotKeywords = ref(['租电脑', '租iPhone', '租iPad', 'MacBook Pro', '智能手表', '游戏本'])
const handleSearch = () => {
if (keyword.value.trim()) {
router.push({ path: '/products', query: { keyword: keyword.value.trim() } })
} else {
router.push('/products')
}
}
const quickSearch = (kw) => {
keyword.value = kw
handleSearch()
}
const handleLogout = () => {
userStore.logout()
router.push('/')
}
onMounted(async () => {
if (userStore.token) {
userStore.fetchUserInfo()
cartStore.fetchCartCount()
}
try {
const res = await getHotSearch()
if (Array.isArray(res) && res.length) {
hotKeywords.value = res
}
} catch (e) {
// ignore
}
})
</script>
+72
View File
@@ -0,0 +1,72 @@
<template>
<!-- Mobile Fixed Bottom Navigation Bar (Hidden on md and larger screens) -->
<nav class="md:hidden fixed bottom-0 left-0 right-0 z-50 bg-white/95 backdrop-blur-md border-t border-gray-200 py-1.5 px-3 flex justify-around items-center shadow-lg safe-area-bottom">
<router-link
to="/"
class="flex flex-col items-center justify-center text-xs space-y-0.5 text-gray-500 transition-colors"
active-class="text-primary font-bold"
>
<span class="text-lg">🏠</span>
<span>首页</span>
</router-link>
<router-link
to="/rental"
class="flex flex-col items-center justify-center text-xs space-y-0.5 text-gray-500 transition-colors relative"
active-class="text-indigo-600 font-bold"
>
<span class="text-lg">💻</span>
<span class="flex items-center">
<span>免押租赁</span>
<span class="w-1.5 h-1.5 rounded-full bg-red-500 ml-0.5 animate-pulse"></span>
</span>
</router-link>
<router-link
to="/products"
class="flex flex-col items-center justify-center text-xs space-y-0.5 text-gray-500 transition-colors"
active-class="text-primary font-bold"
>
<span class="text-lg">🛍</span>
<span>分类商城</span>
</router-link>
<router-link
to="/cart"
class="flex flex-col items-center justify-center text-xs space-y-0.5 text-gray-500 transition-colors relative"
active-class="text-primary font-bold"
>
<span class="text-lg">🛒</span>
<span>购物车</span>
<span
v-if="cartCount > 0"
class="absolute -top-1 right-2 bg-primary text-white text-[10px] px-1 rounded-full font-bold"
>
{{ cartCount }}
</span>
</router-link>
<router-link
to="/user"
class="flex flex-col items-center justify-center text-xs space-y-0.5 text-gray-500 transition-colors"
active-class="text-primary font-bold"
>
<span class="text-lg">👤</span>
<span>我的</span>
</router-link>
</nav>
</template>
<script setup>
import { computed } from 'vue'
import { useCartStore } from '@/store/cart'
const cartStore = useCartStore()
const cartCount = computed(() => cartStore.cartCount)
</script>
<style scoped>
.safe-area-bottom {
padding-bottom: calc(0.375rem + env(safe-area-inset-bottom, 0px));
}
</style>
+91
View File
@@ -0,0 +1,91 @@
<template>
<div class="group bg-white rounded-xl overflow-hidden border border-gray-100 shadow-sm hover:shadow-xl hover:-translate-y-1 transition-all duration-300 flex flex-col justify-between relative">
<!-- Image & Tag -->
<div class="relative overflow-hidden aspect-square bg-gray-50 cursor-pointer" @click="goDetail">
<img
:src="product.image || 'https://via.placeholder.com/300x300?text=No+Image'"
:alt="product.store_name"
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
loading="lazy"
/>
<!-- Badges -->
<div class="absolute top-2.5 left-2.5 flex flex-col gap-1">
<span v-if="product.is_hot" class="px-2 py-0.5 text-[11px] font-bold bg-red-500 text-white rounded-md shadow-sm">爆款</span>
<span v-else-if="product.is_new" class="px-2 py-0.5 text-[11px] font-bold bg-green-500 text-white rounded-md shadow-sm">新品</span>
<span v-else-if="product.is_benefit" class="px-2 py-0.5 text-[11px] font-bold bg-orange-500 text-white rounded-md shadow-sm">特惠</span>
</div>
<!-- Quick Add to Cart Overlay on hover -->
<button
@click.stop="quickAddCart"
class="absolute bottom-3 right-3 w-9 h-9 rounded-full bg-primary hover:bg-primary-hover text-white flex items-center justify-center shadow-lg shadow-red-300 opacity-0 group-hover:opacity-100 transform translate-y-2 group-hover:translate-y-0 transition-all duration-200"
title="加入购物车"
>
<svg class="w-4 h-4" 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>
</button>
</div>
<!-- Product Info -->
<div class="p-2.5 sm:p-4 flex-1 flex flex-col justify-between">
<div>
<h3
@click="goDetail"
class="text-xs sm:text-sm font-semibold text-gray-800 hover:text-primary cursor-pointer line-clamp-2 leading-snug transition-colors"
:title="product.store_name"
>
{{ product.store_name }}
</h3>
<p v-if="product.store_info" class="hidden sm:block text-xs text-gray-400 line-clamp-1 mt-1">
{{ product.store_info }}
</p>
</div>
<div class="mt-2 sm:mt-3.5 pt-1.5 sm:pt-2 border-t border-gray-50 space-y-1 sm:space-y-1.5">
<div class="flex items-baseline justify-between">
<div class="flex items-baseline space-x-1">
<span class="text-[10px] sm:text-xs font-semibold text-primary">¥</span>
<span class="text-base sm:text-xl font-bold text-primary tracking-tight">{{ product.price || 0 }}</span>
<span v-if="product.ot_price && Number(product.ot_price) > Number(product.price)" class="hidden sm:inline-block text-xs text-gray-400 line-through">
¥{{ product.ot_price }}
</span>
</div>
<span class="text-[9px] sm:text-[11px] text-gray-400">已售 {{ product.sales || 0 }}</span>
</div>
<!-- Rental Daily Price Badge -->
<div class="flex items-center justify-between text-[9px] sm:text-[11px] bg-indigo-50/70 text-indigo-700 px-1.5 sm:px-2 py-0.5 rounded-md">
<span class="font-medium">💻 免押租</span>
<span class="font-bold">¥{{ (Math.max(2.8, (Number(product.price || 1999) * 0.002))).toFixed(1) }}/天起</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { useRouter } from 'vue-router'
import { useCartStore } from '@/store/cart'
const props = defineProps({
product: {
type: Object,
required: true
}
})
const router = useRouter()
const cartStore = useCartStore()
const goDetail = () => {
router.push(`/product/${props.product.id}`)
}
const quickAddCart = async () => {
// If product has multiple specifications, navigate to detail to pick SKU
if (props.product.spec_type === 1) {
goDetail()
return
}
await cartStore.addToCart(props.product.id, 1)
}
</script>
+26
View File
@@ -0,0 +1,26 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import App from './App.vue'
import router from './router'
import './styles/index.css'
const app = createApp(App)
const pinia = createPinia()
// Register Element Plus icons
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(pinia)
app.use(router)
app.use(ElementPlus, {
locale: zhCn
})
app.mount('#app')
+81
View File
@@ -0,0 +1,81 @@
import { createRouter, createWebHashHistory } from 'vue-router'
const routes = [
{
path: '/',
name: 'Home',
component: () => import('@/views/Home.vue'),
meta: { title: '首页 - 大锤网络 官方旗舰商城' }
},
{
path: '/products',
name: 'ProductList',
component: () => import('@/views/ProductList.vue'),
meta: { title: '商品列表 - 大锤网络' }
},
{
path: '/rental',
name: 'Rental',
component: () => import('@/views/Rental.vue'),
meta: { title: '数码设备免押租赁专区 - 大锤网络' }
},
{
path: '/product/:id',
name: 'ProductDetail',
component: () => import('@/views/ProductDetail.vue'),
meta: { title: '商品详情 - 大锤网络' }
},
{
path: '/cart',
name: 'Cart',
component: () => import('@/views/Cart.vue'),
meta: { title: '我的购物车 - 大锤网络', requiresAuth: true }
},
{
path: '/checkout',
name: 'Checkout',
component: () => import('@/views/Checkout.vue'),
meta: { title: '结算中心 - 大锤网络', requiresAuth: true }
},
{
path: '/user',
name: 'User',
component: () => import('@/views/User.vue'),
meta: { title: '个人中心 - 大锤网络', requiresAuth: true }
},
{
path: '/news',
name: 'NewsList',
component: () => import('@/views/NewsList.vue'),
meta: { title: '新闻资讯 - 大锤网络' }
},
{
path: '/news/:id',
name: 'NewsDetail',
component: () => import('@/views/NewsDetail.vue'),
meta: { title: '资讯详情 - 大锤网络' }
}
]
const router = createRouter({
history: createWebHashHistory(),
routes,
scrollBehavior() {
return { top: 0 }
}
})
router.beforeEach((to, from, next) => {
if (to.meta.title) {
document.title = to.meta.title
}
const token = localStorage.getItem('crmeb_pc_token')
if (to.meta.requiresAuth && !token) {
window.dispatchEvent(new CustomEvent('auth:require-login', { detail: { redirect: to.fullPath } }))
next('/')
} else {
next()
}
})
export default router
+110
View File
@@ -0,0 +1,110 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { getCartCount, getCartList, addCart as apiAddCart, delCart as apiDelCart, setCartNum as apiSetCartNum } from '@/api'
import { useUserStore } from './user'
import { ElMessage } from 'element-plus'
export const useCartStore = defineStore('cart', () => {
const cartCount = ref(0)
const cartList = ref([])
const validList = ref([])
const invalidList = ref([])
const loading = ref(false)
const fetchCartCount = async () => {
const userStore = useUserStore()
if (!userStore.token) {
cartCount.value = 0
return
}
try {
const res = await getCartCount()
cartCount.value = typeof res === 'object' ? (res.count || 0) : Number(res || 0)
} catch (e) {
// ignore
}
}
const fetchCartList = async () => {
const userStore = useUserStore()
if (!userStore.token) {
cartList.value = []
validList.value = []
invalidList.value = []
return
}
loading.value = true
try {
const res = await getCartList({ page: 1, limit: 100, status: 1 })
validList.value = (res.valid || []).map(item => ({
...item,
selected: true
}))
invalidList.value = res.invalid || []
cartCount.value = validList.value.reduce((acc, cur) => acc + cur.cart_num, 0)
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
}
const addToCart = async (productId, cartNum = 1, uniqueId = '', isNew = 0) => {
const userStore = useUserStore()
if (!userStore.token) {
userStore.openAuthModal('login')
return false
}
try {
const res = await apiAddCart({
productId,
cartNum,
uniqueId,
new: isNew
})
if (!isNew) {
ElMessage.success('成功加入购物车')
fetchCartCount()
}
return res
} catch (e) {
return false
}
}
const removeCartItems = async (ids) => {
try {
await apiDelCart(Array.isArray(ids) ? ids.join(',') : ids)
ElMessage.success('删除成功')
fetchCartList()
return true
} catch (e) {
return false
}
}
const updateCartNum = async (id, number) => {
try {
await apiSetCartNum({ id, number })
const item = validList.value.find(i => i.id === id)
if (item) item.cart_num = number
fetchCartCount()
return true
} catch (e) {
return false
}
}
return {
cartCount,
cartList,
validList,
invalidList,
loading,
fetchCartCount,
fetchCartList,
addToCart,
removeCartItems,
updateCartNum
}
})
+82
View File
@@ -0,0 +1,82 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { getUserInfo, getUserCenter, logout as apiLogout } from '@/api'
import { ElMessage } from 'element-plus'
export const useUserStore = defineStore('user', () => {
const token = ref(localStorage.getItem('crmeb_pc_token') || '')
const userInfo = ref(JSON.parse(localStorage.getItem('crmeb_pc_user') || 'null'))
const userCenter = ref(null)
const isAuthModalVisible = ref(false)
const authModalTab = ref('login') // 'login' | 'register' | 'scan'
const setToken = (newToken) => {
token.value = newToken
localStorage.setItem('crmeb_pc_token', newToken)
}
const setUserInfo = (info) => {
userInfo.value = info
localStorage.setItem('crmeb_pc_user', JSON.stringify(info))
}
const openAuthModal = (tab = 'login') => {
authModalTab.value = tab
isAuthModalVisible.value = true
}
const closeAuthModal = () => {
isAuthModalVisible.value = false
}
const fetchUserInfo = async () => {
if (!token.value) return null
try {
const res = await getUserInfo()
setUserInfo(res)
return res
} catch (e) {
console.error(e)
}
}
const fetchUserCenter = async () => {
if (!token.value) return null
try {
const res = await getUserCenter()
userCenter.value = res
return res
} catch (e) {
console.error(e)
}
}
const logout = async () => {
try {
await apiLogout()
} catch (e) {
// ignore
}
token.value = ''
userInfo.value = null
userCenter.value = null
localStorage.removeItem('crmeb_pc_token')
localStorage.removeItem('crmeb_pc_user')
ElMessage.success('已退出登录')
}
return {
token,
userInfo,
userCenter,
isAuthModalVisible,
authModalTab,
setToken,
setUserInfo,
openAuthModal,
closeAuthModal,
fetchUserInfo,
fetchUserCenter,
logout
}
})
+54
View File
@@ -0,0 +1,54 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--el-color-primary: #e93323;
--el-color-primary-light-3: #f06b60;
--el-color-primary-light-5: #f4928b;
--el-color-primary-light-7: #f8bab5;
--el-color-primary-light-8: #fbc2bd;
--el-color-primary-light-9: #fdebe9;
--el-color-primary-dark-2: #ba291c;
}
body {
overflow-x: hidden;
}
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
}
::-webkit-scrollbar-thumb {
background: #ccc;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #999;
}
/* Utility animations */
.transition-smooth {
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
.text-clamp-1 {
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.text-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
+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>
+27
View File
@@ -0,0 +1,27 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
primary: {
DEFAULT: '#e93323',
hover: '#d82819',
light: '#fdebe9',
dark: '#b71d10'
},
secondary: {
DEFAULT: '#ff7700',
light: '#fff1e5'
}
},
maxWidth: {
'site': '1240px'
}
},
},
plugins: [],
}
+21
View File
@@ -0,0 +1,21 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
export default defineConfig({
plugins: [vue()],
base: process.env.VITE_BASE || './',
resolve: {
alias: {
'@': path.resolve(__dirname, './src')
}
},
build: {
outDir: process.env.OUT_DIR || 'dist',
emptyOutDir: true,
assetsDir: 'assets'
},
server: {
port: 3000
}
})