feat: 添加限时抢购功能模块

本次提交实现了完整的限时抢购业务:
1. 新增抢购活动列表页与详情页
2. 新增倒计时组件、抢购接口与类型定义
3. 新增抢购详情路由与全局组件注册
4. 清理了旧的js格式业务代码与生成的sourcemap文件
5. 更新了权限路由白名单适配新页面
This commit is contained in:
sparksfly 2026-08-01 21:04:38 +08:00
parent de67c1d494
commit 4454107e7c
45 changed files with 1265 additions and 528 deletions

View File

@ -27,6 +27,8 @@ coverage
*.tsbuildinfo
*.vue.js
*.vue.js.map
src/**/*.js
src/**/*.js.map
.eslintcache

View File

@ -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

View File

@ -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"}

View File

@ -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

View File

@ -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"}

View File

@ -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

View File

@ -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"}

View File

@ -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

View File

@ -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"}

View File

@ -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

View File

@ -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"}

View File

@ -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

View File

@ -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"}

View File

@ -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'
})
}

View File

@ -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>

View File

@ -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

View File

@ -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"}

View File

@ -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

View File

@ -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"}

View File

@ -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

View File

@ -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

View File

@ -1 +0,0 @@
{"version":3,"file":"index.js","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":"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"}

View File

@ -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: '抢购详情' }
},
// ==================== 公告中心(占位) ====================
{

View File

@ -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

View File

@ -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"}

View File

@ -1,50 +0,0 @@
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import { loginApi } from '@/api/auth';
/**
* 用户 Store
*
* - token 通过 pinia-plugin-persistedstate 持久化到 localStoragekey: 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

View File

@ -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"}

View File

@ -1,2 +0,0 @@
export {};
//# sourceMappingURL=auth.js.map

View File

@ -1 +0,0 @@
{"version":3,"file":"auth.js","sourceRoot":"","sources":["auth.ts"],"names":[],"mappings":""}

View File

@ -1,2 +0,0 @@
export {};
//# sourceMappingURL=banner.js.map

View File

@ -1 +0,0 @@
{"version":3,"file":"banner.js","sourceRoot":"","sources":["banner.ts"],"names":[],"mappings":""}

View File

@ -1,2 +0,0 @@
export {};
//# sourceMappingURL=cart.js.map

View File

@ -1 +0,0 @@
{"version":3,"file":"cart.js","sourceRoot":"","sources":["cart.ts"],"names":[],"mappings":""}

View File

@ -1,2 +0,0 @@
export {};
//# sourceMappingURL=category.js.map

View File

@ -1 +0,0 @@
{"version":3,"file":"category.js","sourceRoot":"","sources":["category.ts"],"names":[],"mappings":""}

View File

@ -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']

View File

@ -1,2 +0,0 @@
export {};
//# sourceMappingURL=notice.js.map

View File

@ -1 +0,0 @@
{"version":3,"file":"notice.js","sourceRoot":"","sources":["notice.ts"],"names":[],"mappings":""}

View File

@ -1,2 +0,0 @@
export {};
//# sourceMappingURL=product.js.map

View File

@ -1 +0,0 @@
{"version":3,"file":"product.js","sourceRoot":"","sources":["product.ts"],"names":[],"mappings":""}

View File

@ -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
}

View File

@ -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

View File

@ -1 +0,0 @@
{"version":3,"file":"request.js","sourceRoot":"","sources":["request.ts"],"names":[],"mappings":"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"}

View File

@ -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>

View File

@ -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>