init
This commit is contained in:
+22
@@ -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;"]
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
services:
|
||||||
|
dachui-pc:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: dachui_pc_web
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "8090:80"
|
||||||
+16
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
Generated
+2933
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
Vendored
BIN
Binary file not shown.
+114
@@ -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>
|
||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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
@@ -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')
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
< 继续选购
|
||||||
|
</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>
|
||||||
@@ -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>
|
||||||
@@ -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">进入分类列表 ></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">更多 ></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>进入大厅 ></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">
|
||||||
|
查看更多 >
|
||||||
|
</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">
|
||||||
|
全部 >
|
||||||
|
</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">
|
||||||
|
更多 >
|
||||||
|
</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>
|
||||||
@@ -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">
|
||||||
|
< 返回资讯列表
|
||||||
|
</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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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: [],
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user