feat: 添加限时抢购功能模块
本次提交实现了完整的限时抢购业务: 1. 新增抢购活动列表页与详情页 2. 新增倒计时组件、抢购接口与类型定义 3. 新增抢购详情路由与全局组件注册 4. 清理了旧的js格式业务代码与生成的sourcemap文件 5. 更新了权限路由白名单适配新页面
This commit is contained in:
parent
de67c1d494
commit
4454107e7c
|
|
@ -27,6 +27,8 @@ coverage
|
|||
*.tsbuildinfo
|
||||
*.vue.js
|
||||
*.vue.js.map
|
||||
src/**/*.js
|
||||
src/**/*.js.map
|
||||
|
||||
.eslintcache
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +0,0 @@
|
|||
import { request } from '@/utils/request';
|
||||
/**
|
||||
* 用户登录
|
||||
* POST /api/user/login
|
||||
*/
|
||||
export function loginApi(params) {
|
||||
return request({
|
||||
url: '/api/user/login',
|
||||
method: 'POST',
|
||||
data: params
|
||||
});
|
||||
}
|
||||
//# sourceMappingURL=auth.js.map
|
||||
|
|
@ -1 +0,0 @@
|
|||
{"version":3,"file":"auth.js","sourceRoot":"","sources":["auth.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,iBAAiB,CAAA;AAGzC;;;GAGG;AACH,MAAM,UAAU,QAAQ,CAAC,MAAmB;IAC1C,OAAO,OAAO,CAAc;QAC1B,GAAG,EAAE,iBAAiB;QACtB,MAAM,EAAE,MAAM;QACd,IAAI,EAAE,MAAM;KACb,CAAC,CAAA;AACJ,CAAC"}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
import { request } from '@/utils/request';
|
||||
/**
|
||||
* 获取当前生效的轮播图列表
|
||||
* GET /api/banner
|
||||
*/
|
||||
export function getBannersApi() {
|
||||
return request({
|
||||
url: '/api/banner',
|
||||
method: 'GET'
|
||||
});
|
||||
}
|
||||
//# sourceMappingURL=banner.js.map
|
||||
|
|
@ -1 +0,0 @@
|
|||
{"version":3,"file":"banner.js","sourceRoot":"","sources":["banner.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,iBAAiB,CAAA;AAGzC;;;GAGG;AACH,MAAM,UAAU,aAAa;IAC3B,OAAO,OAAO,CAAe;QAC3B,GAAG,EAAE,aAAa;QAClB,MAAM,EAAE,KAAK;KACd,CAAC,CAAA;AACJ,CAAC"}
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
import { request } from '@/utils/request';
|
||||
/**
|
||||
* 获取购物车
|
||||
* GET /api/cart(需登录)
|
||||
*/
|
||||
export function getCartApi() {
|
||||
return request({
|
||||
url: '/api/cart',
|
||||
method: 'GET'
|
||||
});
|
||||
}
|
||||
/**
|
||||
* 添加商品到购物车
|
||||
* POST /api/cart(需登录)
|
||||
*/
|
||||
export function addToCartApi(data) {
|
||||
return request({
|
||||
url: '/api/cart',
|
||||
method: 'POST',
|
||||
data
|
||||
});
|
||||
}
|
||||
/**
|
||||
* 获取购物车数量(用于角标)
|
||||
* GET /api/cart/count(需登录)
|
||||
*/
|
||||
export function getCartCountApi() {
|
||||
return request({
|
||||
url: '/api/cart/count',
|
||||
method: 'GET'
|
||||
});
|
||||
}
|
||||
/**
|
||||
* 修改购物车商品数量
|
||||
* PUT /api/cart/quantity(需登录)
|
||||
*/
|
||||
export function updateCartQuantityApi(data) {
|
||||
return request({
|
||||
url: '/api/cart/quantity',
|
||||
method: 'PUT',
|
||||
data
|
||||
});
|
||||
}
|
||||
/**
|
||||
* 切换购物车项选中状态
|
||||
* PUT /api/cart/{skuId}/selected(需登录)
|
||||
*/
|
||||
export function updateCartSelectedApi(skuId, selected) {
|
||||
return request({
|
||||
url: `/api/cart/${skuId}/selected`,
|
||||
method: 'PUT',
|
||||
params: { selected }
|
||||
});
|
||||
}
|
||||
/**
|
||||
* 全选 / 全不选
|
||||
* PUT /api/cart/all-selected(需登录)
|
||||
*/
|
||||
export function updateAllCartSelectedApi(selected) {
|
||||
return request({
|
||||
url: '/api/cart/all-selected',
|
||||
method: 'PUT',
|
||||
params: { selected }
|
||||
});
|
||||
}
|
||||
/**
|
||||
* 删除购物车项
|
||||
* DELETE /api/cart/{skuId}(需登录)
|
||||
*/
|
||||
export function deleteCartItemApi(skuId) {
|
||||
return request({
|
||||
url: `/api/cart/${skuId}`,
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
/**
|
||||
* 批量删除购物车项
|
||||
* DELETE /api/cart(需登录)
|
||||
*/
|
||||
export function batchDeleteCartApi(skuIds) {
|
||||
return request({
|
||||
url: '/api/cart',
|
||||
method: 'DELETE',
|
||||
data: skuIds
|
||||
});
|
||||
}
|
||||
/**
|
||||
* 清空购物车
|
||||
* DELETE /api/cart/all(需登录)
|
||||
*/
|
||||
export function clearCartApi() {
|
||||
return request({
|
||||
url: '/api/cart/all',
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
//# sourceMappingURL=cart.js.map
|
||||
|
|
@ -1 +0,0 @@
|
|||
{"version":3,"file":"cart.js","sourceRoot":"","sources":["cart.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,iBAAiB,CAAA;AAGzC;;;GAGG;AACH,MAAM,UAAU,UAAU;IACxB,OAAO,OAAO,CAAS;QACrB,GAAG,EAAE,WAAW;QAChB,MAAM,EAAE,KAAK;KACd,CAAC,CAAA;AACJ,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,YAAY,CAAC,IAAgB;IAC3C,OAAO,OAAO,CAAO;QACnB,GAAG,EAAE,WAAW;QAChB,MAAM,EAAE,MAAM;QACd,IAAI;KACL,CAAC,CAAA;AACJ,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,eAAe;IAC7B,OAAO,OAAO,CAAS;QACrB,GAAG,EAAE,iBAAiB;QACtB,MAAM,EAAE,KAAK;KACd,CAAC,CAAA;AACJ,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,qBAAqB,CAAC,IAAmB;IACvD,OAAO,OAAO,CAAO;QACnB,GAAG,EAAE,oBAAoB;QACzB,MAAM,EAAE,KAAK;QACb,IAAI;KACL,CAAC,CAAA;AACJ,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,qBAAqB,CAAC,KAAa,EAAE,QAAe;IAClE,OAAO,OAAO,CAAO;QACnB,GAAG,EAAE,aAAa,KAAK,WAAW;QAClC,MAAM,EAAE,KAAK;QACb,MAAM,EAAE,EAAE,QAAQ,EAAE;KACrB,CAAC,CAAA;AACJ,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,wBAAwB,CAAC,QAAe;IACtD,OAAO,OAAO,CAAO;QACnB,GAAG,EAAE,wBAAwB;QAC7B,MAAM,EAAE,KAAK;QACb,MAAM,EAAE,EAAE,QAAQ,EAAE;KACrB,CAAC,CAAA;AACJ,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,iBAAiB,CAAC,KAAa;IAC7C,OAAO,OAAO,CAAO;QACnB,GAAG,EAAE,aAAa,KAAK,EAAE;QACzB,MAAM,EAAE,QAAQ;KACjB,CAAC,CAAA;AACJ,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,kBAAkB,CAAC,MAAgB;IACjD,OAAO,OAAO,CAAO;QACnB,GAAG,EAAE,WAAW;QAChB,MAAM,EAAE,QAAQ;QAChB,IAAI,EAAE,MAAM;KACb,CAAC,CAAA;AACJ,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,YAAY;IAC1B,OAAO,OAAO,CAAO;QACnB,GAAG,EAAE,eAAe;QACpB,MAAM,EAAE,QAAQ;KACjB,CAAC,CAAA;AACJ,CAAC"}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
import { request } from '@/utils/request';
|
||||
/**
|
||||
* 获取启用的分类树
|
||||
* GET /api/category/tree
|
||||
*/
|
||||
export function getCategoryTreeApi() {
|
||||
return request({
|
||||
url: '/api/category/tree',
|
||||
method: 'GET'
|
||||
});
|
||||
}
|
||||
//# sourceMappingURL=category.js.map
|
||||
|
|
@ -1 +0,0 @@
|
|||
{"version":3,"file":"category.js","sourceRoot":"","sources":["category.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,iBAAiB,CAAA;AAGzC;;;GAGG;AACH,MAAM,UAAU,kBAAkB;IAChC,OAAO,OAAO,CAAqB;QACjC,GAAG,EAAE,oBAAoB;QACzB,MAAM,EAAE,KAAK;KACd,CAAC,CAAA;AACJ,CAAC"}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
import { request } from '@/utils/request';
|
||||
/**
|
||||
* 获取当前生效的公告列表
|
||||
* GET /api/notice/list
|
||||
*/
|
||||
export function getNoticesApi() {
|
||||
return request({
|
||||
url: '/api/notice/list',
|
||||
method: 'GET'
|
||||
});
|
||||
}
|
||||
//# sourceMappingURL=notice.js.map
|
||||
|
|
@ -1 +0,0 @@
|
|||
{"version":3,"file":"notice.js","sourceRoot":"","sources":["notice.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,iBAAiB,CAAA;AAGzC;;;GAGG;AACH,MAAM,UAAU,aAAa;IAC3B,OAAO,OAAO,CAAe;QAC3B,GAAG,EAAE,kBAAkB;QACvB,MAAM,EAAE,KAAK;KACd,CAAC,CAAA;AACJ,CAAC"}
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
import { request } from '@/utils/request';
|
||||
/**
|
||||
* 商品详情(用户端)
|
||||
* GET /api/product/{id}
|
||||
*/
|
||||
export function getProductDetailApi(id) {
|
||||
return request({
|
||||
url: `/api/product/${id}`,
|
||||
method: 'GET'
|
||||
});
|
||||
}
|
||||
/**
|
||||
* 热门商品
|
||||
* GET /api/product/hot
|
||||
*/
|
||||
export function getHotProductsApi(limit = 10) {
|
||||
return request({
|
||||
url: '/api/product/hot',
|
||||
method: 'GET',
|
||||
params: { limit }
|
||||
});
|
||||
}
|
||||
/**
|
||||
* 新品上市
|
||||
* GET /api/product/new
|
||||
*/
|
||||
export function getNewProductsApi(limit = 10) {
|
||||
return request({
|
||||
url: '/api/product/new',
|
||||
method: 'GET',
|
||||
params: { limit }
|
||||
});
|
||||
}
|
||||
/**
|
||||
* 分类下商品列表
|
||||
* GET /api/product/list
|
||||
*/
|
||||
export function getProductsByCategoryApi(categoryIds, orderBy, limit = 20) {
|
||||
return request({
|
||||
url: '/api/product/list',
|
||||
method: 'GET',
|
||||
params: { categoryIds, orderBy, limit }
|
||||
});
|
||||
}
|
||||
//# sourceMappingURL=product.js.map
|
||||
|
|
@ -1 +0,0 @@
|
|||
{"version":3,"file":"product.js","sourceRoot":"","sources":["product.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,iBAAiB,CAAA;AAGzC;;;GAGG;AACH,MAAM,UAAU,mBAAmB,CAAC,EAAU;IAC5C,OAAO,OAAO,CAAgB;QAC5B,GAAG,EAAE,gBAAgB,EAAE,EAAE;QACzB,MAAM,EAAE,KAAK;KACd,CAAC,CAAA;AACJ,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,iBAAiB,CAAC,KAAK,GAAG,EAAE;IAC1C,OAAO,OAAO,CAAgB;QAC5B,GAAG,EAAE,kBAAkB;QACvB,MAAM,EAAE,KAAK;QACb,MAAM,EAAE,EAAE,KAAK,EAAE;KAClB,CAAC,CAAA;AACJ,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,iBAAiB,CAAC,KAAK,GAAG,EAAE;IAC1C,OAAO,OAAO,CAAgB;QAC5B,GAAG,EAAE,kBAAkB;QACvB,MAAM,EAAE,KAAK;QACb,MAAM,EAAE,EAAE,KAAK,EAAE;KAClB,CAAC,CAAA;AACJ,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,wBAAwB,CAAC,WAAmB,EAAE,OAAgB,EAAE,KAAK,GAAG,EAAE;IACxF,OAAO,OAAO,CAAgB;QAC5B,GAAG,EAAE,mBAAmB;QACxB,MAAM,EAAE,KAAK;QACb,MAAM,EAAE,EAAE,WAAW,EAAE,OAAO,EAAE,KAAK,EAAE;KACxC,CAAC,CAAA;AACJ,CAAC"}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import { request } from '@/utils/request'
|
||||
import type { SeckillActivity, SeckillBuyReq, SeckillResult } from '@/types/seckill'
|
||||
|
||||
/**
|
||||
* 获取进行中 / 即将开始的抢购活动
|
||||
* GET /api/seckill/activities
|
||||
*/
|
||||
export function getSeckillActivitiesApi() {
|
||||
return request<SeckillActivity[]>({
|
||||
url: '/api/seckill/activities',
|
||||
method: 'GET'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 抢购活动详情(含商品)
|
||||
* GET /api/seckill/activities/{id}
|
||||
*/
|
||||
export function getSeckillDetailApi(id: number) {
|
||||
return request<SeckillActivity>({
|
||||
url: `/api/seckill/activities/${id}`,
|
||||
method: 'GET'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 抢购下单(需登录)
|
||||
* POST /api/seckill/buy
|
||||
*/
|
||||
export function seckillBuyApi(data: SeckillBuyReq) {
|
||||
return request<{ orderNo: string }>({
|
||||
url: '/api/seckill/buy',
|
||||
method: 'POST',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询抢购结果(轮询)
|
||||
* GET /api/seckill/result/{orderNo}
|
||||
*/
|
||||
export function getSeckillResultApi(orderNo: string) {
|
||||
return request<SeckillResult>({
|
||||
url: `/api/seckill/result/${orderNo}`,
|
||||
method: 'GET'
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* 抢购倒计时
|
||||
*
|
||||
* 接收后端返回的剩余秒数,本地每秒递减,归零后触发 expire
|
||||
*/
|
||||
import { ref, watch, onUnmounted } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
seconds: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'expire'): void
|
||||
}>()
|
||||
|
||||
const remaining = ref(Math.max(0, props.seconds))
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function stop() {
|
||||
if (timer) {
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
}
|
||||
|
||||
function restart() {
|
||||
stop()
|
||||
if (remaining.value <= 0) return
|
||||
timer = setInterval(() => {
|
||||
remaining.value = Math.max(0, remaining.value - 1)
|
||||
if (remaining.value <= 0) {
|
||||
stop()
|
||||
emit('expire')
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
watch(() => props.seconds, (value) => {
|
||||
remaining.value = Math.max(0, value)
|
||||
restart()
|
||||
}, { immediate: true })
|
||||
|
||||
onUnmounted(stop)
|
||||
|
||||
function format(total: number) {
|
||||
const days = Math.floor(total / 86400)
|
||||
const hours = Math.floor((total % 86400) / 3600)
|
||||
const minutes = Math.floor((total % 3600) / 60)
|
||||
const seconds = total % 60
|
||||
const hh = String(hours).padStart(2, '0')
|
||||
const mm = String(minutes).padStart(2, '0')
|
||||
const ss = String(seconds).padStart(2, '0')
|
||||
return days > 0 ? `${days}天 ${hh}:${mm}:${ss}` : `${hh}:${mm}:${ss}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="countdown-text">{{ format(remaining) }}</span>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.countdown-text {
|
||||
font-family: 'Fredoka', sans-serif;
|
||||
font-size: $font-size-lg;
|
||||
font-weight: 700;
|
||||
color: $color-primary;
|
||||
letter-spacing: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
import { createApp } from 'vue';
|
||||
import App from './App.vue';
|
||||
// 状态管理
|
||||
import pinia from './stores';
|
||||
// 路由
|
||||
import router from './router';
|
||||
// 全局样式
|
||||
import 'element-plus/dist/index.css';
|
||||
import './styles/reset.scss';
|
||||
import 'virtual:uno.css';
|
||||
import 'nprogress/nprogress.css';
|
||||
// 权限控制
|
||||
import './permission';
|
||||
const app = createApp(App);
|
||||
app.use(pinia);
|
||||
app.use(router);
|
||||
app.mount('#app');
|
||||
//# sourceMappingURL=main.js.map
|
||||
|
|
@ -1 +0,0 @@
|
|||
{"version":3,"file":"main.js","sourceRoot":"","sources":["main.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,KAAK,CAAA;AAC/B,OAAO,GAAG,MAAM,WAAW,CAAA;AAE3B,OAAO;AACP,OAAO,KAAK,MAAM,UAAU,CAAA;AAE5B,KAAK;AACL,OAAO,MAAM,MAAM,UAAU,CAAA;AAE7B,OAAO;AACP,OAAO,6BAA6B,CAAA;AACpC,OAAO,qBAAqB,CAAA;AAC5B,OAAO,iBAAiB,CAAA;AACxB,OAAO,yBAAyB,CAAA;AAEhC,OAAO;AACP,OAAO,cAAc,CAAA;AAErB,MAAM,GAAG,GAAG,SAAS,CAAC,GAAG,CAAC,CAAA;AAE1B,GAAG,CAAC,GAAG,CAAC,KAAK,CAAC,CAAA;AACd,GAAG,CAAC,GAAG,CAAC,MAAM,CAAC,CAAA;AAEf,GAAG,CAAC,KAAK,CAAC,MAAM,CAAC,CAAA"}
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
import router from '@/router';
|
||||
import NProgress from 'nprogress';
|
||||
import { useUserStore } from '@/stores/modules/user';
|
||||
NProgress.configure({ showSpinner: false });
|
||||
const WHITE_LIST = ['/login', '/404'];
|
||||
router.beforeEach(async (to, _from) => {
|
||||
NProgress.start();
|
||||
document.title = to.meta.title || '零食商城';
|
||||
const userStore = useUserStore();
|
||||
// 白名单放行
|
||||
if (WHITE_LIST.includes(to.path)) {
|
||||
if (to.path === '/login' && userStore.isLoggedIn) {
|
||||
return '/';
|
||||
}
|
||||
return;
|
||||
}
|
||||
// 不需要登录的公开页面(首页、分类、商品详情、搜索、公告、抢购)
|
||||
const publicPaths = ['/', '/search', '/seckill', '/notice', '/products'];
|
||||
const isPublic = publicPaths.includes(to.path) ||
|
||||
to.path.startsWith('/category') ||
|
||||
to.path.startsWith('/product') ||
|
||||
to.path.startsWith('/notice');
|
||||
if (isPublic) {
|
||||
return;
|
||||
}
|
||||
// 需要登录的页面(购物车、个人中心、客服)
|
||||
if (!userStore.isLoggedIn) {
|
||||
NProgress.done();
|
||||
return `/login?redirect=${encodeURIComponent(to.fullPath)}`;
|
||||
}
|
||||
});
|
||||
router.afterEach(() => {
|
||||
NProgress.done();
|
||||
});
|
||||
//# sourceMappingURL=permission.js.map
|
||||
|
|
@ -1 +0,0 @@
|
|||
{"version":3,"file":"permission.js","sourceRoot":"","sources":["permission.ts"],"names":[],"mappings":"AAAA,OAAO,MAAM,MAAM,UAAU,CAAA;AAC7B,OAAO,SAAS,MAAM,WAAW,CAAA;AACjC,OAAO,EAAE,YAAY,EAAE,MAAM,uBAAuB,CAAA;AAEpD,SAAS,CAAC,SAAS,CAAC,EAAE,WAAW,EAAE,KAAK,EAAE,CAAC,CAAA;AAE3C,MAAM,UAAU,GAAG,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAA;AAErC,MAAM,CAAC,UAAU,CAAC,KAAK,EAAE,EAAE,EAAE,KAAK,EAAE,EAAE;IACpC,SAAS,CAAC,KAAK,EAAE,CAAA;IACjB,QAAQ,CAAC,KAAK,GAAI,EAAE,CAAC,IAAI,CAAC,KAAgB,IAAI,MAAM,CAAA;IAEpD,MAAM,SAAS,GAAG,YAAY,EAAE,CAAA;IAEhC,QAAQ;IACR,IAAI,UAAU,CAAC,QAAQ,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC;QACjC,IAAI,EAAE,CAAC,IAAI,KAAK,QAAQ,IAAI,SAAS,CAAC,UAAU,EAAE,CAAC;YACjD,OAAO,GAAG,CAAA;QACZ,CAAC;QACD,OAAM;IACR,CAAC;IAED,kCAAkC;IAClC,MAAM,WAAW,GAAG,CAAC,GAAG,EAAE,SAAS,EAAE,UAAU,EAAE,SAAS,EAAE,WAAW,CAAC,CAAA;IACxE,MAAM,QAAQ,GACZ,WAAW,CAAC,QAAQ,CAAC,EAAE,CAAC,IAAI,CAAC;QAC7B,EAAE,CAAC,IAAI,CAAC,UAAU,CAAC,WAAW,CAAC;QAC/B,EAAE,CAAC,IAAI,CAAC,UAAU,CAAC,UAAU,CAAC;QAC9B,EAAE,CAAC,IAAI,CAAC,UAAU,CAAC,SAAS,CAAC,CAAA;IAE/B,IAAI,QAAQ,EAAE,CAAC;QACb,OAAM;IACR,CAAC;IAED,uBAAuB;IACvB,IAAI,CAAC,SAAS,CAAC,UAAU,EAAE,CAAC;QAC1B,SAAS,CAAC,IAAI,EAAE,CAAA;QAChB,OAAO,mBAAmB,kBAAkB,CAAC,EAAE,CAAC,QAAQ,CAAC,EAAE,CAAA;IAC7D,CAAC;AACH,CAAC,CAAC,CAAA;AAEF,MAAM,CAAC,SAAS,CAAC,GAAG,EAAE;IACpB,SAAS,CAAC,IAAI,EAAE,CAAA;AAClB,CAAC,CAAC,CAAA"}
|
||||
|
|
@ -26,7 +26,8 @@ router.beforeEach(async (to, _from) => {
|
|||
publicPaths.includes(to.path) ||
|
||||
to.path.startsWith('/category') ||
|
||||
to.path.startsWith('/product') ||
|
||||
to.path.startsWith('/notice')
|
||||
to.path.startsWith('/notice') ||
|
||||
to.path.startsWith('/seckill')
|
||||
|
||||
if (isPublic) {
|
||||
return
|
||||
|
|
|
|||
|
|
@ -1,110 +0,0 @@
|
|||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
scrollBehavior: () => ({ left: 0, top: 0 }),
|
||||
routes: [
|
||||
// ==================== 公开路由 ====================
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('@/views/auth/login.vue'),
|
||||
meta: { title: '登录', hidden: true }
|
||||
},
|
||||
{
|
||||
path: '/404',
|
||||
name: 'NotFound',
|
||||
component: () => import('@/views/error/404.vue'),
|
||||
meta: { title: '404', hidden: true }
|
||||
},
|
||||
// ==================== 首页 ====================
|
||||
{
|
||||
path: '/',
|
||||
name: 'Home',
|
||||
component: () => import('@/views/home/index.vue'),
|
||||
meta: { title: '零食商城', keepAlive: true }
|
||||
},
|
||||
// ==================== 分类页(占位) ====================
|
||||
{
|
||||
path: '/category/:id?',
|
||||
name: 'Category',
|
||||
component: () => import('@/views/category/index.vue'),
|
||||
meta: { title: '商品分类' }
|
||||
},
|
||||
// ==================== 全部商品 ====================
|
||||
{
|
||||
path: '/products',
|
||||
name: 'Products',
|
||||
component: () => import('@/views/category/index.vue'),
|
||||
meta: { title: '全部商品' }
|
||||
},
|
||||
// ==================== 商品详情(占位) ====================
|
||||
{
|
||||
path: '/product/:id',
|
||||
name: 'ProductDetail',
|
||||
component: () => import('@/views/product/detail.vue'),
|
||||
meta: { title: '商品详情' }
|
||||
},
|
||||
// ==================== 搜索(占位) ====================
|
||||
{
|
||||
path: '/search',
|
||||
name: 'Search',
|
||||
component: () => import('@/views/product/search.vue'),
|
||||
meta: { title: '搜索' }
|
||||
},
|
||||
// ==================== 购物车(占位) ====================
|
||||
{
|
||||
path: '/cart',
|
||||
name: 'Cart',
|
||||
component: () => import('@/views/cart/index.vue'),
|
||||
meta: { title: '购物车' }
|
||||
},
|
||||
// ==================== 确认订单(占位) ====================
|
||||
{
|
||||
path: '/checkout',
|
||||
name: 'Checkout',
|
||||
component: () => import('@/views/checkout/index.vue'),
|
||||
meta: { title: '确认订单' }
|
||||
},
|
||||
// ==================== 限时抢购(占位) ====================
|
||||
{
|
||||
path: '/seckill',
|
||||
name: 'Seckill',
|
||||
component: () => import('@/views/seckill/index.vue'),
|
||||
meta: { title: '限时抢购' }
|
||||
},
|
||||
// ==================== 公告中心(占位) ====================
|
||||
{
|
||||
path: '/notice',
|
||||
name: 'NoticeList',
|
||||
component: () => import('@/views/notice/index.vue'),
|
||||
meta: { title: '公告中心' }
|
||||
},
|
||||
{
|
||||
path: '/notice/:id',
|
||||
name: 'NoticeDetail',
|
||||
component: () => import('@/views/notice/detail.vue'),
|
||||
meta: { title: '公告详情' }
|
||||
},
|
||||
// ==================== 个人中心(占位) ====================
|
||||
{
|
||||
path: '/user',
|
||||
name: 'UserCenter',
|
||||
component: () => import('@/views/user/index.vue'),
|
||||
meta: { title: '个人中心' }
|
||||
},
|
||||
// ==================== 客诉/客服(占位) ====================
|
||||
{
|
||||
path: '/chat',
|
||||
name: 'Chat',
|
||||
component: () => import('@/views/chat/index.vue'),
|
||||
meta: { title: '在线客服' }
|
||||
},
|
||||
// ==================== 404 兜底 ====================
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
redirect: '/404'
|
||||
}
|
||||
]
|
||||
});
|
||||
export default router;
|
||||
//# sourceMappingURL=index.js.map
|
||||
|
|
@ -1 +0,0 @@
|
|||
{"version":3,"file":"index.js","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAA;AAE3D,MAAM,MAAM,GAAG,YAAY,CAAC;IAC1B,OAAO,EAAE,gBAAgB,EAAE;IAC3B,cAAc,EAAE,GAAG,EAAE,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC,EAAE,GAAG,EAAE,CAAC,EAAE,CAAC;IAC3C,MAAM,EAAE;QACN,iDAAiD;QACjD;YACE,IAAI,EAAE,QAAQ;YACd,IAAI,EAAE,OAAO;YACb,SAAS,EAAE,GAAG,EAAE,CAAC,MAAM,CAAC,wBAAwB,CAAC;YACjD,IAAI,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE;SACpC;QACD;YACE,IAAI,EAAE,MAAM;YACZ,IAAI,EAAE,UAAU;YAChB,SAAS,EAAE,GAAG,EAAE,CAAC,MAAM,CAAC,uBAAuB,CAAC;YAChD,IAAI,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE;SACrC;QAED,+CAA+C;QAC/C;YACE,IAAI,EAAE,GAAG;YACT,IAAI,EAAE,MAAM;YACZ,SAAS,EAAE,GAAG,EAAE,CAAC,MAAM,CAAC,wBAAwB,CAAC;YACjD,IAAI,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,IAAI,EAAE;SACzC;QAED,oDAAoD;QACpD;YACE,IAAI,EAAE,gBAAgB;YACtB,IAAI,EAAE,UAAU;YAChB,SAAS,EAAE,GAAG,EAAE,CAAC,MAAM,CAAC,4BAA4B,CAAC;YACrD,IAAI,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE;SACxB;QAED,iDAAiD;QACjD;YACE,IAAI,EAAE,WAAW;YACjB,IAAI,EAAE,UAAU;YAChB,SAAS,EAAE,GAAG,EAAE,CAAC,MAAM,CAAC,4BAA4B,CAAC;YACrD,IAAI,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE;SACxB;QAED,qDAAqD;QACrD;YACE,IAAI,EAAE,cAAc;YACpB,IAAI,EAAE,eAAe;YACrB,SAAS,EAAE,GAAG,EAAE,CAAC,MAAM,CAAC,4BAA4B,CAAC;YACrD,IAAI,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE;SACxB;QAED,mDAAmD;QACnD;YACE,IAAI,EAAE,SAAS;YACf,IAAI,EAAE,QAAQ;YACd,SAAS,EAAE,GAAG,EAAE,CAAC,MAAM,CAAC,4BAA4B,CAAC;YACrD,IAAI,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE;SACtB;QAED,oDAAoD;QACpD;YACE,IAAI,EAAE,OAAO;YACb,IAAI,EAAE,MAAM;YACZ,SAAS,EAAE,GAAG,EAAE,CAAC,MAAM,CAAC,wBAAwB,CAAC;YACjD,IAAI,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE;SACvB;QAED,qDAAqD;QACrD;YACE,IAAI,EAAE,WAAW;YACjB,IAAI,EAAE,UAAU;YAChB,SAAS,EAAE,GAAG,EAAE,CAAC,MAAM,CAAC,4BAA4B,CAAC;YACrD,IAAI,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE;SACxB;QAED,qDAAqD;QACrD;YACE,IAAI,EAAE,UAAU;YAChB,IAAI,EAAE,SAAS;YACf,SAAS,EAAE,GAAG,EAAE,CAAC,MAAM,CAAC,2BAA2B,CAAC;YACpD,IAAI,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE;SACxB;QAED,qDAAqD;QACrD;YACE,IAAI,EAAE,SAAS;YACf,IAAI,EAAE,YAAY;YAClB,SAAS,EAAE,GAAG,EAAE,CAAC,MAAM,CAAC,0BAA0B,CAAC;YACnD,IAAI,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE;SACxB;QACD;YACE,IAAI,EAAE,aAAa;YACnB,IAAI,EAAE,cAAc;YACpB,SAAS,EAAE,GAAG,EAAE,CAAC,MAAM,CAAC,2BAA2B,CAAC;YACpD,IAAI,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE;SACxB;QAED,qDAAqD;QACrD;YACE,IAAI,EAAE,OAAO;YACb,IAAI,EAAE,YAAY;YAClB,SAAS,EAAE,GAAG,EAAE,CAAC,MAAM,CAAC,wBAAwB,CAAC;YACjD,IAAI,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE;SACxB;QAED,sDAAsD;QACtD;YACE,IAAI,EAAE,OAAO;YACb,IAAI,EAAE,MAAM;YACZ,SAAS,EAAE,GAAG,EAAE,CAAC,MAAM,CAAC,wBAAwB,CAAC;YACjD,IAAI,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE;SACxB;QAED,mDAAmD;QACnD;YACE,IAAI,EAAE,kBAAkB;YACxB,QAAQ,EAAE,MAAM;SACjB;KACF;CACF,CAAC,CAAA;AAEF,eAAe,MAAM,CAAA"}
|
||||
|
|
@ -81,6 +81,12 @@ const router = createRouter({
|
|||
component: () => import('@/views/seckill/index.vue'),
|
||||
meta: { title: '限时抢购' }
|
||||
},
|
||||
{
|
||||
path: '/seckill/:id',
|
||||
name: 'SeckillDetail',
|
||||
component: () => import('@/views/seckill/detail.vue'),
|
||||
meta: { title: '抢购详情' }
|
||||
},
|
||||
|
||||
// ==================== 公告中心(占位) ====================
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,6 +0,0 @@
|
|||
import { createPinia } from 'pinia';
|
||||
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate';
|
||||
const pinia = createPinia();
|
||||
pinia.use(piniaPluginPersistedstate);
|
||||
export default pinia;
|
||||
//# sourceMappingURL=index.js.map
|
||||
|
|
@ -1 +0,0 @@
|
|||
{"version":3,"file":"index.js","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,OAAO,CAAA;AACnC,OAAO,yBAAyB,MAAM,6BAA6B,CAAA;AAEnE,MAAM,KAAK,GAAG,WAAW,EAAE,CAAA;AAC3B,KAAK,CAAC,GAAG,CAAC,yBAAyB,CAAC,CAAA;AAEpC,eAAe,KAAK,CAAA"}
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
import { defineStore } from 'pinia';
|
||||
import { ref, computed } from 'vue';
|
||||
import { loginApi } from '@/api/auth';
|
||||
/**
|
||||
* 用户 Store
|
||||
*
|
||||
* - token 通过 pinia-plugin-persistedstate 持久化到 localStorage(key: snack-user)
|
||||
* - 刷新页面后自动恢复登录态
|
||||
* - 401 由 request 拦截器统一处理:清空登录态 + 跳 /login
|
||||
*/
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
const token = ref('');
|
||||
const userId = ref(0);
|
||||
const nickname = ref('');
|
||||
const isLoggedIn = computed(() => !!token.value);
|
||||
const displayName = computed(() => nickname.value || '用户');
|
||||
/** 登录 */
|
||||
async function login(params) {
|
||||
const data = await loginApi(params);
|
||||
token.value = data.token;
|
||||
userId.value = data.userId;
|
||||
nickname.value = data.nickname;
|
||||
return data;
|
||||
}
|
||||
/** 登出 */
|
||||
function logout() {
|
||||
clearAuth();
|
||||
}
|
||||
function clearAuth() {
|
||||
token.value = '';
|
||||
userId.value = 0;
|
||||
nickname.value = '';
|
||||
}
|
||||
return {
|
||||
token,
|
||||
userId,
|
||||
nickname,
|
||||
isLoggedIn,
|
||||
displayName,
|
||||
login,
|
||||
logout,
|
||||
clearAuth
|
||||
};
|
||||
}, {
|
||||
persist: {
|
||||
key: 'snack-user',
|
||||
pick: ['token', 'userId', 'nickname']
|
||||
}
|
||||
});
|
||||
//# sourceMappingURL=user.js.map
|
||||
|
|
@ -1 +0,0 @@
|
|||
{"version":3,"file":"user.js","sourceRoot":"","sources":["user.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,OAAO,CAAA;AACnC,OAAO,EAAE,GAAG,EAAE,QAAQ,EAAE,MAAM,KAAK,CAAA;AACnC,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAA;AAGrC;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,YAAY,GAAG,WAAW,CACrC,MAAM,EACN,GAAG,EAAE;IACH,MAAM,KAAK,GAAG,GAAG,CAAS,EAAE,CAAC,CAAA;IAC7B,MAAM,MAAM,GAAG,GAAG,CAAS,CAAC,CAAC,CAAA;IAC7B,MAAM,QAAQ,GAAG,GAAG,CAAS,EAAE,CAAC,CAAA;IAEhC,MAAM,UAAU,GAAG,QAAQ,CAAC,GAAG,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,CAAA;IAChD,MAAM,WAAW,GAAG,QAAQ,CAAC,GAAG,EAAE,CAAC,QAAQ,CAAC,KAAK,IAAI,IAAI,CAAC,CAAA;IAE1D,SAAS;IACT,KAAK,UAAU,KAAK,CAAC,MAAmB;QACtC,MAAM,IAAI,GAAG,MAAM,QAAQ,CAAC,MAAM,CAAC,CAAA;QACnC,KAAK,CAAC,KAAK,GAAG,IAAI,CAAC,KAAK,CAAA;QACxB,MAAM,CAAC,KAAK,GAAG,IAAI,CAAC,MAAM,CAAA;QAC1B,QAAQ,CAAC,KAAK,GAAG,IAAI,CAAC,QAAQ,CAAA;QAC9B,OAAO,IAAI,CAAA;IACb,CAAC;IAED,SAAS;IACT,SAAS,MAAM;QACb,SAAS,EAAE,CAAA;IACb,CAAC;IAED,SAAS,SAAS;QAChB,KAAK,CAAC,KAAK,GAAG,EAAE,CAAA;QAChB,MAAM,CAAC,KAAK,GAAG,CAAC,CAAA;QAChB,QAAQ,CAAC,KAAK,GAAG,EAAE,CAAA;IACrB,CAAC;IAED,OAAO;QACL,KAAK;QACL,MAAM;QACN,QAAQ;QACR,UAAU;QACV,WAAW;QACX,KAAK;QACL,MAAM;QACN,SAAS;KACV,CAAA;AACH,CAAC,EACD;IACE,OAAO,EAAE;QACP,GAAG,EAAE,YAAY;QACjB,IAAI,EAAE,CAAC,OAAO,EAAE,QAAQ,EAAE,UAAU,CAAC;KACtC;CACF,CACF,CAAA"}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export {};
|
||||
//# sourceMappingURL=auth.js.map
|
||||
|
|
@ -1 +0,0 @@
|
|||
{"version":3,"file":"auth.js","sourceRoot":"","sources":["auth.ts"],"names":[],"mappings":""}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export {};
|
||||
//# sourceMappingURL=banner.js.map
|
||||
|
|
@ -1 +0,0 @@
|
|||
{"version":3,"file":"banner.js","sourceRoot":"","sources":["banner.ts"],"names":[],"mappings":""}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export {};
|
||||
//# sourceMappingURL=cart.js.map
|
||||
|
|
@ -1 +0,0 @@
|
|||
{"version":3,"file":"cart.js","sourceRoot":"","sources":["cart.ts"],"names":[],"mappings":""}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export {};
|
||||
//# sourceMappingURL=category.js.map
|
||||
|
|
@ -1 +0,0 @@
|
|||
{"version":3,"file":"category.js","sourceRoot":"","sources":["category.ts"],"names":[],"mappings":""}
|
||||
|
|
@ -13,6 +13,7 @@ declare module 'vue' {
|
|||
export interface GlobalComponents {
|
||||
AppFooter: typeof import('./../components/layout/AppFooter.vue')['default']
|
||||
AppHeader: typeof import('./../components/layout/AppHeader.vue')['default']
|
||||
Countdown: typeof import('./../components/seckill/Countdown.vue')['default']
|
||||
ElBadge: typeof import('element-plus/es')['ElBadge']
|
||||
ElButton: typeof import('element-plus/es')['ElButton']
|
||||
ElCarousel: typeof import('element-plus/es')['ElCarousel']
|
||||
|
|
|
|||
|
|
@ -1,2 +0,0 @@
|
|||
export {};
|
||||
//# sourceMappingURL=notice.js.map
|
||||
|
|
@ -1 +0,0 @@
|
|||
{"version":3,"file":"notice.js","sourceRoot":"","sources":["notice.ts"],"names":[],"mappings":""}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export {};
|
||||
//# sourceMappingURL=product.js.map
|
||||
|
|
@ -1 +0,0 @@
|
|||
{"version":3,"file":"product.js","sourceRoot":"","sources":["product.ts"],"names":[],"mappings":""}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
/** 抢购活动商品(对应后端 SeckillProductVO) */
|
||||
export interface SeckillProduct {
|
||||
id: number
|
||||
activityId: number
|
||||
productId: number
|
||||
productName: string
|
||||
productImage: string
|
||||
skuId: number
|
||||
skuName: string
|
||||
originPrice: number
|
||||
seckillPrice: number
|
||||
discount: number
|
||||
seckillStock: number
|
||||
remainStock: number
|
||||
soldPercent: number
|
||||
perLimit: number
|
||||
sort: number
|
||||
sales: number
|
||||
}
|
||||
|
||||
/** 抢购活动(对应后端 SeckillActivityVO) */
|
||||
export interface SeckillActivity {
|
||||
id: number
|
||||
name: string
|
||||
cover: string
|
||||
startTime: string
|
||||
endTime: string
|
||||
status: 0 | 1 | 2
|
||||
statusText: string
|
||||
countdownSeconds: number
|
||||
description: string
|
||||
products: SeckillProduct[]
|
||||
}
|
||||
|
||||
/** 抢购下单请求 */
|
||||
export interface SeckillBuyReq {
|
||||
activityId: number
|
||||
skuId: number
|
||||
quantity?: number
|
||||
}
|
||||
|
||||
/** 抢购结果(对应后端 SeckillResultVO) */
|
||||
export interface SeckillResult {
|
||||
orderNo: string
|
||||
status: number
|
||||
statusText: string
|
||||
message?: string
|
||||
mainOrderId?: number | null
|
||||
}
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
import axios from 'axios';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import NProgress from 'nprogress';
|
||||
import { useUserStore } from '@/stores/modules/user';
|
||||
NProgress.configure({ showSpinner: false });
|
||||
/**
|
||||
* baseURL 处理:
|
||||
* - 开发/测试:VITE_API_BASE_URL 留空 → baseURL = '' → 请求走相对路径
|
||||
* 浏览器同源,Vite server.proxy 在 Node 端转发到后端
|
||||
* - 生产:VITE_API_BASE_URL 设为真实后端域名
|
||||
*/
|
||||
const baseURL = (import.meta.env.VITE_API_BASE_URL || '').replace(/\/+$/, '');
|
||||
const service = axios.create({
|
||||
baseURL,
|
||||
timeout: 15000,
|
||||
withCredentials: false
|
||||
});
|
||||
// 请求拦截器
|
||||
service.interceptors.request.use((config) => {
|
||||
NProgress.start();
|
||||
const userStore = useUserStore();
|
||||
if (userStore.token) {
|
||||
config.headers.Authorization = `Bearer ${userStore.token}`;
|
||||
}
|
||||
return config;
|
||||
}, (error) => {
|
||||
NProgress.done();
|
||||
return Promise.reject(error);
|
||||
});
|
||||
// 响应拦截器
|
||||
service.interceptors.response.use((response) => {
|
||||
NProgress.done();
|
||||
const { code, message, data } = response.data;
|
||||
if (code === 200) {
|
||||
return data;
|
||||
}
|
||||
// Token 过期 / 未登录
|
||||
if (code === 401) {
|
||||
const userStore = useUserStore();
|
||||
userStore.clearAuth();
|
||||
import('@/router').then(({ default: router }) => {
|
||||
router.push('/login');
|
||||
});
|
||||
return Promise.reject(new Error(message || '未登录或Token已过期'));
|
||||
}
|
||||
ElMessage.error(message || '请求失败');
|
||||
return Promise.reject(new Error(message || 'Error'));
|
||||
}, (error) => {
|
||||
NProgress.done();
|
||||
const status = error.response?.status;
|
||||
let message = error.message;
|
||||
if (status === 401) {
|
||||
message = '登录已过期,请重新登录';
|
||||
const userStore = useUserStore();
|
||||
userStore.clearAuth();
|
||||
import('@/router').then(({ default: router }) => {
|
||||
router.push('/login');
|
||||
});
|
||||
}
|
||||
else if (status === 403) {
|
||||
message = '无权限访问';
|
||||
}
|
||||
else if (status === 404) {
|
||||
message = '资源不存在';
|
||||
}
|
||||
else if (status === 500) {
|
||||
message = '服务器内部错误';
|
||||
}
|
||||
ElMessage.error(message);
|
||||
return Promise.reject(error);
|
||||
});
|
||||
/**
|
||||
* 统一请求方法封装
|
||||
*/
|
||||
export function request(config) {
|
||||
return service.request(config);
|
||||
}
|
||||
export default service;
|
||||
//# sourceMappingURL=request.js.map
|
||||
|
|
@ -1 +0,0 @@
|
|||
{"version":3,"file":"request.js","sourceRoot":"","sources":["request.ts"],"names":[],"mappings":"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"}
|
||||
|
|
@ -0,0 +1,725 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* 抢购活动详情
|
||||
*
|
||||
* 活动倒计时 + 抢购商品列表(价格、库存进度、限购、立即抢购)
|
||||
*/
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { ArrowLeft, Clock, Lightning, Minus, Plus } from '@element-plus/icons-vue'
|
||||
import AppHeader from '@/components/layout/AppHeader.vue'
|
||||
import AppFooter from '@/components/layout/AppFooter.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import Countdown from '@/components/seckill/Countdown.vue'
|
||||
import { getSeckillDetailApi, seckillBuyApi } from '@/api/seckill'
|
||||
import { useUserStore } from '@/stores/modules/user'
|
||||
import type { SeckillActivity, SeckillProduct } from '@/types/seckill'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const activity = ref<SeckillActivity | null>(null)
|
||||
const loading = ref(true)
|
||||
const buyingSkuId = ref<number | null>(null)
|
||||
const quantities = ref<Record<number, number>>({})
|
||||
|
||||
async function loadDetail() {
|
||||
const id = Number(route.params.id)
|
||||
if (!id || Number.isNaN(id)) {
|
||||
router.replace('/404')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
activity.value = await getSeckillDetailApi(id)
|
||||
document.title = activity.value?.name
|
||||
? `${activity.value.name} - 零食商城`
|
||||
: '抢购详情 - 零食商城'
|
||||
const map: Record<number, number> = {}
|
||||
activity.value?.products?.forEach(product => {
|
||||
map[product.skuId] = 1
|
||||
})
|
||||
quantities.value = map
|
||||
} catch {
|
||||
activity.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleExpire() {
|
||||
loadDetail()
|
||||
}
|
||||
|
||||
function canBuy(product: SeckillProduct) {
|
||||
return activity.value?.status === 1 && product.remainStock > 0
|
||||
}
|
||||
|
||||
function stateText(product: SeckillProduct) {
|
||||
if (activity.value?.status === 0) return '活动未开始'
|
||||
if (activity.value?.status === 2) return '活动已结束'
|
||||
if (product.remainStock <= 0) return '已抢完'
|
||||
return ''
|
||||
}
|
||||
|
||||
function changeQty(product: SeckillProduct, delta: number) {
|
||||
const current = quantities.value[product.skuId] || 1
|
||||
const next = current + delta
|
||||
const max = Math.min(product.perLimit || 1, Math.max(product.remainStock, 1))
|
||||
if (next < 1) return
|
||||
if (next > max) {
|
||||
ElMessage.warning(`每人限购 ${product.perLimit} 件`)
|
||||
return
|
||||
}
|
||||
quantities.value = { ...quantities.value, [product.skuId]: next }
|
||||
}
|
||||
|
||||
async function handleBuy(product: SeckillProduct) {
|
||||
if (!activity.value || !canBuy(product)) return
|
||||
|
||||
if (!userStore.isLoggedIn) {
|
||||
router.push(`/login?redirect=${encodeURIComponent(route.fullPath)}`)
|
||||
return
|
||||
}
|
||||
|
||||
buyingSkuId.value = product.skuId
|
||||
try {
|
||||
const data = await seckillBuyApi({
|
||||
activityId: activity.value.id,
|
||||
skuId: product.skuId,
|
||||
quantity: quantities.value[product.skuId] || 1
|
||||
})
|
||||
await ElMessageBox.alert(
|
||||
`抢购成功,排队号:${data.orderNo}。请及时前往订单页完成支付。`,
|
||||
'抢购成功',
|
||||
{ confirmButtonText: '知道了' }
|
||||
)
|
||||
await loadDetail()
|
||||
} catch {
|
||||
/* 拦截器已提示 */
|
||||
} finally {
|
||||
buyingSkuId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function formatPrice(value: number | string | null | undefined) {
|
||||
return Number(value || 0).toFixed(2)
|
||||
}
|
||||
|
||||
function formatTime(value?: string) {
|
||||
return value ? value.slice(0, 16) : ''
|
||||
}
|
||||
|
||||
onMounted(loadDetail)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="seckill-detail-page">
|
||||
<AppHeader />
|
||||
|
||||
<main class="detail-main">
|
||||
<div class="container">
|
||||
<el-skeleton v-if="loading" :rows="6" animated class="detail-skeleton" />
|
||||
|
||||
<template v-else-if="activity">
|
||||
<button type="button" class="back-btn" @click="router.push('/seckill')">
|
||||
<el-icon :size="15"><ArrowLeft /></el-icon>
|
||||
限时抢购
|
||||
</button>
|
||||
|
||||
<div class="activity-head" :class="`status-${activity.status}`">
|
||||
<div class="head-left">
|
||||
<div class="title-row">
|
||||
<h1 class="activity-name">{{ activity.name }}</h1>
|
||||
<span class="status-badge" :class="`badge-${activity.status}`">
|
||||
{{ activity.statusText }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="time-range">
|
||||
<el-icon :size="16"><Clock /></el-icon>
|
||||
{{ formatTime(activity.startTime) }} ~ {{ formatTime(activity.endTime) }}
|
||||
</p>
|
||||
<p v-if="activity.description" class="description">{{ activity.description }}</p>
|
||||
</div>
|
||||
|
||||
<div class="head-countdown">
|
||||
<span class="countdown-label">
|
||||
{{ activity.status === 0 ? '距开始' : activity.status === 1 ? '距结束' : '已结束' }}
|
||||
</span>
|
||||
<Countdown
|
||||
v-if="activity.status !== 2"
|
||||
:seconds="activity.countdownSeconds"
|
||||
@expire="handleExpire"
|
||||
/>
|
||||
<span v-else class="ended-text">已结束</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="product-section">
|
||||
<div class="section-head">
|
||||
<h2 class="section-title">活动商品</h2>
|
||||
<span class="section-count">{{ activity.products?.length || 0 }} 件</span>
|
||||
</div>
|
||||
|
||||
<EmptyState
|
||||
v-if="!activity.products?.length"
|
||||
description="本场活动暂无商品"
|
||||
/>
|
||||
|
||||
<div v-else class="product-grid">
|
||||
<div
|
||||
v-for="product in activity.products"
|
||||
:key="product.skuId"
|
||||
class="product-card"
|
||||
:class="{ soldout: product.remainStock <= 0 }"
|
||||
>
|
||||
<div class="product-image" @click="router.push(`/product/${product.productId}`)">
|
||||
<el-image
|
||||
:src="product.productImage"
|
||||
fit="cover"
|
||||
class="product-img"
|
||||
lazy
|
||||
>
|
||||
<template #error>
|
||||
<div class="image-placeholder">
|
||||
<el-icon :size="36"><Lightning /></el-icon>
|
||||
</div>
|
||||
</template>
|
||||
</el-image>
|
||||
<span v-if="product.discount" class="discount-badge">
|
||||
{{ product.discount }}折
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="product-body">
|
||||
<h3 class="product-name" @click="router.push(`/product/${product.productId}`)">
|
||||
{{ product.productName }}
|
||||
</h3>
|
||||
<p class="sku-name">{{ product.skuName }}</p>
|
||||
|
||||
<div class="price-row">
|
||||
<span class="seckill-price">¥{{ formatPrice(product.seckillPrice) }}</span>
|
||||
<span class="origin-price">¥{{ formatPrice(product.originPrice) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="stock-row">
|
||||
<div class="progress-track">
|
||||
<div
|
||||
class="progress-fill"
|
||||
:style="{ width: `${Math.min(product.soldPercent, 100)}%` }"
|
||||
/>
|
||||
</div>
|
||||
<span class="sold-text">已抢 {{ product.soldPercent }}%</span>
|
||||
</div>
|
||||
|
||||
<div class="stock-meta">
|
||||
<span v-if="product.remainStock > 0" class="remain-text">
|
||||
仅剩 {{ product.remainStock }} 件
|
||||
</span>
|
||||
<span v-else class="soldout-text">已抢完</span>
|
||||
<span class="limit-text">每人限购 {{ product.perLimit }} 件</span>
|
||||
</div>
|
||||
|
||||
<div v-if="canBuy(product)" class="buy-row">
|
||||
<div class="qty-stepper">
|
||||
<button
|
||||
type="button"
|
||||
class="qty-btn"
|
||||
:disabled="(quantities[product.skuId] || 1) <= 1"
|
||||
@click="changeQty(product, -1)"
|
||||
>
|
||||
<el-icon :size="14"><Minus /></el-icon>
|
||||
</button>
|
||||
<span class="qty-num">{{ quantities[product.skuId] || 1 }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="qty-btn"
|
||||
:disabled="(quantities[product.skuId] || 1) >= Math.min(product.perLimit, Math.max(product.remainStock, 1))"
|
||||
@click="changeQty(product, 1)"
|
||||
>
|
||||
<el-icon :size="14"><Plus /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="buy-btn"
|
||||
:disabled="buyingSkuId === product.skuId"
|
||||
@click="handleBuy(product)"
|
||||
>
|
||||
<el-icon :size="15"><Lightning /></el-icon>
|
||||
{{ buyingSkuId === product.skuId ? '抢购中...' : '立即抢购' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span v-else class="state-text">{{ stateText(product) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<EmptyState v-else description="活动不存在或已结束" />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<AppFooter />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.seckill-detail-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: $bg-page;
|
||||
}
|
||||
|
||||
.detail-main {
|
||||
flex: 1;
|
||||
padding: 28px 0 48px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: $container-max;
|
||||
margin: 0 auto;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.detail-skeleton {
|
||||
padding: 20px;
|
||||
border: 2px solid $color-border;
|
||||
border-radius: $radius-lg;
|
||||
background: $bg-surface;
|
||||
box-shadow: $clay-shadow-sm;
|
||||
}
|
||||
|
||||
// ==================== 返回 ====================
|
||||
.back-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
margin-bottom: 18px;
|
||||
border: 2px solid $color-border;
|
||||
border-radius: $radius-full;
|
||||
background: $bg-surface;
|
||||
color: $color-text-regular;
|
||||
font-size: $font-size-sm;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 2px 0 0 $color-border-dark;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s, border-color 0.2s, transform 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: $color-primary;
|
||||
border-color: $color-primary;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 活动头部 ====================
|
||||
.activity-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
flex-wrap: wrap;
|
||||
padding: 24px;
|
||||
margin-bottom: 28px;
|
||||
background: $bg-surface;
|
||||
border: 2px solid $color-border;
|
||||
border-radius: $radius-lg;
|
||||
box-shadow: $clay-shadow-sm;
|
||||
}
|
||||
|
||||
.head-left {
|
||||
flex: 1;
|
||||
min-width: 260px;
|
||||
}
|
||||
|
||||
.title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.activity-name {
|
||||
margin: 0;
|
||||
font-size: $font-size-xxl;
|
||||
font-weight: 700;
|
||||
color: $color-text-primary;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 4px 14px;
|
||||
border-radius: $radius-full;
|
||||
font-size: $font-size-sm;
|
||||
font-weight: 700;
|
||||
line-height: 1.5;
|
||||
|
||||
&.badge-0 {
|
||||
background: #FEF3C7;
|
||||
color: $color-warning;
|
||||
}
|
||||
|
||||
&.badge-1 {
|
||||
background: $color-primary-light;
|
||||
color: $color-primary;
|
||||
}
|
||||
|
||||
&.badge-2 {
|
||||
background: $bg-hover;
|
||||
color: $color-text-secondary;
|
||||
}
|
||||
}
|
||||
|
||||
.time-range {
|
||||
margin: 0 0 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: $font-size-sm;
|
||||
color: $color-text-secondary;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 0;
|
||||
font-size: $font-size-sm;
|
||||
color: $color-text-secondary;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.head-countdown {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 6px;
|
||||
padding: 14px 18px;
|
||||
border: 2px solid $color-border;
|
||||
border-radius: $radius-md;
|
||||
background: $color-primary-light;
|
||||
|
||||
.countdown-label {
|
||||
font-size: $font-size-sm;
|
||||
color: $color-text-secondary;
|
||||
}
|
||||
}
|
||||
|
||||
.ended-text {
|
||||
font-size: $font-size-lg;
|
||||
font-weight: 700;
|
||||
color: $color-text-placeholder;
|
||||
}
|
||||
|
||||
// ==================== 商品区 ====================
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 0;
|
||||
font-size: $font-size-xl;
|
||||
font-weight: 700;
|
||||
color: $color-text-primary;
|
||||
}
|
||||
|
||||
.section-count {
|
||||
font-size: $font-size-sm;
|
||||
color: $color-text-secondary;
|
||||
}
|
||||
|
||||
.product-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.product-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: $bg-surface;
|
||||
border: 2px solid $color-border;
|
||||
border-radius: $radius-lg;
|
||||
box-shadow: $clay-shadow-sm;
|
||||
overflow: hidden;
|
||||
transition: transform 0.25s ease, box-shadow 0.25s ease, opacity 0.2s;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: $clay-shadow-md;
|
||||
}
|
||||
|
||||
&.soldout {
|
||||
opacity: 0.78;
|
||||
}
|
||||
}
|
||||
|
||||
.product-image {
|
||||
position: relative;
|
||||
aspect-ratio: 1 / 1;
|
||||
overflow: hidden;
|
||||
background: $bg-page;
|
||||
cursor: pointer;
|
||||
|
||||
.product-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.image-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: $color-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.discount-badge {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
padding: 3px 10px;
|
||||
border-radius: $radius-full;
|
||||
background: linear-gradient(135deg, $color-primary, $color-cta);
|
||||
color: #fff;
|
||||
font-size: $font-size-xs;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.product-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.product-name {
|
||||
margin: 0;
|
||||
font-size: $font-size-lg;
|
||||
font-weight: 600;
|
||||
color: $color-text-primary;
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: $color-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.sku-name {
|
||||
margin: 0;
|
||||
font-size: $font-size-sm;
|
||||
color: $color-text-secondary;
|
||||
}
|
||||
|
||||
.price-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.seckill-price {
|
||||
font-family: 'Fredoka', sans-serif;
|
||||
font-size: $font-size-xl;
|
||||
font-weight: 700;
|
||||
color: $color-primary;
|
||||
}
|
||||
|
||||
.origin-price {
|
||||
font-size: $font-size-sm;
|
||||
color: $color-text-placeholder;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.stock-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.progress-track {
|
||||
flex: 1;
|
||||
height: 8px;
|
||||
border-radius: $radius-full;
|
||||
background: $bg-hover;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
border-radius: $radius-full;
|
||||
background: linear-gradient(90deg, $color-secondary, $color-cta);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.sold-text {
|
||||
flex-shrink: 0;
|
||||
font-size: $font-size-xs;
|
||||
color: $color-text-secondary;
|
||||
}
|
||||
|
||||
.stock-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.remain-text {
|
||||
font-size: $font-size-xs;
|
||||
color: $color-danger;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.soldout-text {
|
||||
font-size: $font-size-xs;
|
||||
color: $color-text-placeholder;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.limit-text {
|
||||
font-size: $font-size-xs;
|
||||
color: $color-text-placeholder;
|
||||
}
|
||||
|
||||
.buy-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.qty-stepper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px;
|
||||
border: 2px solid $color-border;
|
||||
border-radius: $radius-full;
|
||||
background: $bg-page;
|
||||
}
|
||||
|
||||
.qty-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: $radius-full;
|
||||
background: $bg-surface;
|
||||
color: $color-text-regular;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s, background-color 0.2s;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: $color-primary;
|
||||
background: $color-primary-light;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.qty-num {
|
||||
min-width: 26px;
|
||||
text-align: center;
|
||||
font-size: $font-size-md;
|
||||
font-weight: 700;
|
||||
color: $color-text-primary;
|
||||
font-family: 'Fredoka', sans-serif;
|
||||
}
|
||||
|
||||
.buy-btn {
|
||||
flex: 1;
|
||||
min-height: 42px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
border: 2px solid $color-border-dark;
|
||||
border-radius: $radius-full;
|
||||
background: linear-gradient(135deg, $color-primary, $color-cta);
|
||||
color: #fff;
|
||||
font-size: $font-size-md;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 4px 0 0 $color-primary-dark;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s, filter 0.2s, opacity 0.2s;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
filter: brightness(1.04);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
box-shadow: 0 1px 0 0 $color-primary-dark;
|
||||
transform: translateY(3px);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.state-text {
|
||||
display: block;
|
||||
padding: 11px 0;
|
||||
text-align: center;
|
||||
font-size: $font-size-md;
|
||||
font-weight: 600;
|
||||
color: $color-text-placeholder;
|
||||
}
|
||||
|
||||
// ==================== 响应式 ====================
|
||||
@media (max-width: 1024px) {
|
||||
.product-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.container {
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.activity-head {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.activity-name {
|
||||
font-size: $font-size-xl;
|
||||
}
|
||||
|
||||
.head-countdown {
|
||||
width: 100%;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.product-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,25 +1,379 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* 限时抢购页 — 占位
|
||||
* 限时抢购活动列表
|
||||
*
|
||||
* 展示进行中 / 即将开始的活动场次,含状态与倒计时
|
||||
*/
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ArrowRight, Clock, Lightning } from '@element-plus/icons-vue'
|
||||
import AppHeader from '@/components/layout/AppHeader.vue'
|
||||
import AppFooter from '@/components/layout/AppFooter.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import Countdown from '@/components/seckill/Countdown.vue'
|
||||
import { getSeckillActivitiesApi } from '@/api/seckill'
|
||||
import type { SeckillActivity } from '@/types/seckill'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const activities = ref<SeckillActivity[]>([])
|
||||
const loading = ref(true)
|
||||
|
||||
async function loadActivities() {
|
||||
loading.value = true
|
||||
try {
|
||||
activities.value = (await getSeckillActivitiesApi()) || []
|
||||
} catch {
|
||||
activities.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleExpire() {
|
||||
loadActivities()
|
||||
}
|
||||
|
||||
function formatTime(value?: string) {
|
||||
return value ? value.slice(0, 16) : ''
|
||||
}
|
||||
|
||||
function enterButtonText(status: number) {
|
||||
if (status === 0) return '提前查看'
|
||||
if (status === 1) return '立即进入'
|
||||
return '查看回顾'
|
||||
}
|
||||
|
||||
onMounted(loadActivities)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="placeholder-page">
|
||||
<h2>限时抢购</h2>
|
||||
<p>即将上线,敬请期待</p>
|
||||
<div class="seckill-page">
|
||||
<AppHeader />
|
||||
|
||||
<main class="seckill-main">
|
||||
<div class="container">
|
||||
<div class="page-head">
|
||||
<h1 class="page-title">
|
||||
<span class="title-badge">
|
||||
<el-icon :size="20"><Lightning /></el-icon>
|
||||
</span>
|
||||
限时抢购
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<el-skeleton v-if="loading" :rows="5" animated class="seckill-skeleton" />
|
||||
|
||||
<EmptyState
|
||||
v-else-if="activities.length === 0"
|
||||
description="暂无进行中的抢购活动,稍后再来看看吧"
|
||||
/>
|
||||
|
||||
<div v-else class="activity-grid">
|
||||
<div
|
||||
v-for="activity in activities"
|
||||
:key="activity.id"
|
||||
class="activity-card"
|
||||
:class="`status-${activity.status}`"
|
||||
>
|
||||
<div class="card-top">
|
||||
<span class="status-badge" :class="`badge-${activity.status}`">
|
||||
{{ activity.statusText }}
|
||||
</span>
|
||||
|
||||
<div class="countdown-wrap">
|
||||
<span class="countdown-label">
|
||||
{{ activity.status === 0 ? '距开始' : activity.status === 1 ? '距结束' : '已结束' }}
|
||||
</span>
|
||||
<Countdown
|
||||
v-if="activity.status !== 2"
|
||||
:seconds="activity.countdownSeconds"
|
||||
@expire="handleExpire"
|
||||
/>
|
||||
<span v-else class="ended-text">已结束</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cover">
|
||||
<el-image
|
||||
v-if="activity.cover"
|
||||
:src="activity.cover"
|
||||
fit="cover"
|
||||
class="cover-img"
|
||||
lazy
|
||||
>
|
||||
<template #error>
|
||||
<div class="cover-placeholder">
|
||||
<el-icon :size="44"><Lightning /></el-icon>
|
||||
</div>
|
||||
</template>
|
||||
</el-image>
|
||||
<div v-else class="cover-placeholder">
|
||||
<el-icon :size="44"><Lightning /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<h2 class="activity-name">{{ activity.name }}</h2>
|
||||
<p class="time-range">
|
||||
<el-icon :size="15"><Clock /></el-icon>
|
||||
{{ formatTime(activity.startTime) }} ~ {{ formatTime(activity.endTime) }}
|
||||
</p>
|
||||
<p v-if="activity.description" class="description">{{ activity.description }}</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="enter-btn"
|
||||
@click="router.push(`/seckill/${activity.id}`)"
|
||||
>
|
||||
{{ enterButtonText(activity.status) }}
|
||||
<el-icon :size="15"><ArrowRight /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<AppFooter />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.placeholder-page {
|
||||
.seckill-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: $bg-page;
|
||||
}
|
||||
|
||||
.seckill-main {
|
||||
flex: 1;
|
||||
padding: 28px 0 48px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: $container-max;
|
||||
margin: 0 auto;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
// ==================== 页头 ====================
|
||||
.page-head {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-family: 'Fredoka', 'PingFang SC', sans-serif;
|
||||
font-size: $font-size-xxl;
|
||||
font-weight: 700;
|
||||
color: $color-text-primary;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
.title-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 12px;
|
||||
background: $color-primary-light;
|
||||
color: $color-primary;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 加载与空状态 ====================
|
||||
.seckill-skeleton {
|
||||
padding: 20px;
|
||||
border: 2px solid $color-border;
|
||||
border-radius: $radius-lg;
|
||||
background: $bg-surface;
|
||||
box-shadow: $clay-shadow-sm;
|
||||
}
|
||||
|
||||
// ==================== 活动卡片 ====================
|
||||
.activity-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.activity-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: $bg-surface;
|
||||
border: 2px solid $color-border;
|
||||
border-radius: $radius-lg;
|
||||
box-shadow: $clay-shadow-sm;
|
||||
overflow: hidden;
|
||||
transition: transform 0.25s ease, box-shadow 0.25s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: $clay-shadow-md;
|
||||
}
|
||||
}
|
||||
|
||||
.card-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 2px solid $color-border;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 4px 14px;
|
||||
border-radius: $radius-full;
|
||||
font-size: $font-size-sm;
|
||||
font-weight: 700;
|
||||
line-height: 1.5;
|
||||
|
||||
&.badge-0 {
|
||||
background: #FEF3C7;
|
||||
color: $color-warning;
|
||||
}
|
||||
|
||||
&.badge-1 {
|
||||
background: $color-primary-light;
|
||||
color: $color-primary;
|
||||
}
|
||||
|
||||
&.badge-2 {
|
||||
background: $bg-hover;
|
||||
color: $color-text-secondary;
|
||||
}
|
||||
}
|
||||
|
||||
.countdown-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
.countdown-label {
|
||||
font-size: $font-size-sm;
|
||||
color: $color-text-secondary;
|
||||
}
|
||||
}
|
||||
|
||||
.ended-text {
|
||||
font-size: $font-size-md;
|
||||
font-weight: 600;
|
||||
color: $color-text-placeholder;
|
||||
}
|
||||
|
||||
.cover {
|
||||
position: relative;
|
||||
aspect-ratio: 16 / 7;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(135deg, $color-primary-light, #FFE4D9);
|
||||
|
||||
.cover-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.cover-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 60vh;
|
||||
color: $color-text-secondary;
|
||||
color: $color-primary;
|
||||
}
|
||||
|
||||
h2 { font-size: $font-size-xxl; color: $color-text-primary; margin-bottom: 8px; }
|
||||
.card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.activity-name {
|
||||
margin: 0;
|
||||
font-size: $font-size-xl;
|
||||
font-weight: 700;
|
||||
color: $color-text-primary;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.time-range {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: $font-size-sm;
|
||||
color: $color-text-secondary;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 0;
|
||||
font-size: $font-size-sm;
|
||||
color: $color-text-secondary;
|
||||
line-height: 1.6;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.enter-btn {
|
||||
min-height: 42px;
|
||||
padding: 0 22px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
border: 2px solid $color-border-dark;
|
||||
border-radius: $radius-full;
|
||||
background: linear-gradient(135deg, $color-primary, $color-cta);
|
||||
color: #fff;
|
||||
font-size: $font-size-md;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 4px 0 0 $color-primary-dark;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s, filter 0.2s;
|
||||
|
||||
&:hover {
|
||||
filter: brightness(1.04);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&:active {
|
||||
box-shadow: 0 1px 0 0 $color-primary-dark;
|
||||
transform: translateY(3px);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 响应式 ====================
|
||||
@media (max-width: 900px) {
|
||||
.activity-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.container {
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: $font-size-xl;
|
||||
}
|
||||
|
||||
.card-top {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.activity-name {
|
||||
font-size: $font-size-lg;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
Loading…
Reference in New Issue