fix(buyer): 优化秒杀页面布局与顶部搜索栏样式统一

- 增加秒杀顶部氛围横幅与数字倒计时模块
- 修复时间轴 Tab 场次少时异常拉伸撑满的问题,支持单场次居中对齐
- 重构秒杀商品网格与卡片排版,统一两行截断、进度条占位与按钮状态
- 顶部搜索栏统一引用 SearchHeader,规范整站搜索组件蓝色基调
This commit is contained in:
pikachu1995@126.com
2026-11-10 10:55:57 +08:00
parent 3c4604a342
commit f33c969d1e
2 changed files with 545 additions and 253 deletions

View File

@@ -449,12 +449,12 @@ onUnmounted(() => {
user-select: none;
&:hover {
color: $theme_color;
color: #409EFF;
}
&.active {
color: #fff;
background: $theme_color;
background: #409EFF;
}
}

View File

@@ -1,70 +1,134 @@
<template>
<div class="seckill-list">
<BaseHeader></BaseHeader>
<Search />
<!-- 秒杀时间段 -->
<div class="promotion-decorate">限时秒杀</div>
<ul class="time-line">
<template v-for="(time, index) in list">
<li v-if="index < 5" @click="currIndex = index" :key="index" :class="{ currTimeline: currIndex === index }">
<div>{{ time.timeLine + ":00" }}</div>
<div v-if="currIndex === index">
<p>{{ nowHour >= time.timeLine ? "秒杀中" : "即将开始" }}</p>
<p>
{{ nowHour >= time.timeLine ? "距结束" : "距开始" }}&nbsp;{{
currTime
}}
</p>
<SearchHeader></SearchHeader>
<!-- 秒杀顶部氛围横幅 -->
<div class="seckill-hero">
<div class="hero-inner">
<div class="hero-left">
<div class="hero-tag">
<span class="lightning">⚡</span>
<span>限时特惠 · 爆款直降</span>
</div>
<div v-else class="not-curr">
{{ nowHour >= time.timeLine ? "秒杀中" : "即将开始" }}
<h1 class="hero-title">限时秒杀</h1>
<p class="hero-subtitle">正品严选 · 限量低价 · 抢完即止</p>
</div>
<div class="hero-countdown" v-if="list.length && list[currIndex]">
<div class="countdown-badge">
<span class="status-dot" :class="{ 'is-live': isCurrentSessionActive }"></span>
<span>{{ isCurrentSessionActive ? "正在抢购中" : "即将准点开抢" }}</span>
</div>
<div class="countdown-label">
{{ isCurrentSessionActive ? "距本场结束仅剩" : "距本场开始仅剩" }}
</div>
<div class="countdown-boxes">
<span class="time-block">{{ countdownHours }}</span>
<span class="time-sep">:</span>
<span class="time-block">{{ countdownMinutes }}</span>
<span class="time-sep">:</span>
<span class="time-block">{{ countdownSeconds }}</span>
</div>
</li>
</template>
</ul>
<!-- 秒杀商品列表 -->
<div class="seckill-goods-wrap">
<div class="goods-list">
<empty v-if="goodsList.length === 0" />
<div v-else class="goods-show-info1" v-for="(item, index) in goodsList" :key="index"
@click="goGoodsDetail(item.skuId, item.goodsId)">
<div class="goods-show-img">
<img :src="item.goodsImage" loading="lazy" decoding="async" :alt="item.goodsName" />
</div>
<div class="goods-show-price">
<span class="seckill-price text-danger">{{ $filters.unitPrice(item.price, "¥") }}</span>
<span class="origin-price">{{ $filters.unitPrice(item.originalPrice, "¥") }}</span>
</div>
<div class="goods-show-detail">
<span>{{ item.goodsName }}</span>
</div>
<div v-if="nowHour >= list[currIndex].timeLine" class="goods-show-num1">
<span>已售{{ salePercent(item) }}%</span>
<el-progress
:show-text="false"
:color="'var(--theme-color, #ff3c2a)'"
:percentage="salePercent(item)"
/>
</div>
<div class="goods-show-seller">
<span>{{ item.storeName }}</span>
</div>
<div
class="goods-seckill-btn"
:class="{ 'is-disabled': !canBuy(item) }"
>
{{ buyText(item) }}
</div>
</div>
</div>
</div>
<!-- 秒杀时间段导航 -->
<div class="time-line-wrap" v-if="list.length">
<ul class="time-line" :class="{ 'single-session': list.length === 1 }">
<template v-for="(time, index) in list">
<li
v-if="index < 5"
:key="index"
@click="currIndex = index"
:class="{ currTimeline: currIndex === index }"
>
<div class="timeline-hour">{{ time.timeLine + ":00" }}</div>
<div class="timeline-status">
<span class="status-pill">{{ nowHour >= time.timeLine ? "秒杀中" : "即将开始" }}</span>
</div>
</li>
</template>
</ul>
</div>
<!-- 秒杀商品列表 -->
<div class="seckill-goods-wrap">
<div v-if="goodsList.length === 0" class="empty-wrap">
<empty :_Title="list.length ? '当前场次暂无秒杀商品,去其他场次看看吧' : '今日暂无秒杀活动,去首页逛逛吧'" />
</div>
<div v-else class="goods-grid">
<div
class="goods-card"
v-for="(item, index) in goodsList"
:key="index"
@click="goGoodsDetail(item.skuId, item.goodsId)"
>
<div class="goods-img-box">
<img :src="item.goodsImage" loading="lazy" decoding="async" :alt="item.goodsName" />
<div v-if="isSoldOut(item)" class="sold-out-overlay">
<span>已抢光</span>
</div>
</div>
<div class="goods-price-row">
<div class="price-main">
<span class="price-symbol">¥</span>
<span class="price-val">{{ $filters.unitPrice(item.price, "") }}</span>
</div>
<div class="price-origin">
{{ $filters.unitPrice(item.originalPrice, "¥") }}
</div>
</div>
<div class="goods-name-row" :title="item.goodsName">
{{ item.goodsName }}
</div>
<div class="goods-progress-row">
<template v-if="nowHour >= list[currIndex].timeLine">
<span class="progress-label">已抢 {{ salePercent(item) }}%</span>
<el-progress
:show-text="false"
:stroke-width="6"
:color="'var(--theme-color, #ff3c2a)'"
:percentage="salePercent(item)"
/>
</template>
<template v-else>
<span class="upcoming-tip">限量抢购 · 准点开抢</span>
</template>
</div>
<div class="goods-store-row" :title="item.storeName">
<span class="store-name">{{ item.storeName }}</span>
</div>
<div
class="goods-action-btn"
:class="{
'btn-disabled': !canBuy(item),
'btn-upcoming': nowHour < list[currIndex].timeLine,
'btn-active': canBuy(item)
}"
>
{{ buyText(item) }}
</div>
</div>
</div>
</div>
<BaseFooter />
</div>
</template>
<script setup>
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { seckillByDay } from "@/api/promotion";
import SearchHeader from "@/components/indexDecorate/components/SearchHeader.vue";
const router = useRouter();
@@ -72,10 +136,19 @@ const list = ref([]); // 秒杀时段列表
const goodsList = ref([]); // 商品列表
let interval = null; // 定时器
const currIndex = ref(0); // 当前时间段的下标
const currTime = ref(0); // 当前显示的倒计时
const diffSeconds = ref(0); // 倒计时时间戳
const currTime = ref("00:00:00"); // 当前显示的倒计时格式字符串
const countdownHours = ref("00");
const countdownMinutes = ref("00");
const countdownSeconds = ref("00");
const diffSeconds = ref(0); // 倒计时时间戳差值
const nowHour = ref(new Date().getHours()); // 当前小时数
// 当前选中场次是否处于开抢中
const isCurrentSessionActive = computed(() => {
if (!list.value.length || !list.value[currIndex.value]) return false;
return nowHour.value >= list.value[currIndex.value].timeLine;
});
watch(currIndex, (val) => {
clearInterval(interval);
interval = null;
@@ -85,26 +158,22 @@ watch(currIndex, (val) => {
});
watch(diffSeconds, (val) => {
const hours = Math.floor(val / 3600);
// 当前秒数 / 60,向下取整
// 获取到所有分钟数 3600 / 60 = 60分钟
// 对60取模,超过小时数的分钟数
const minutes = Math.floor(val / 60) % 60;
// 当前的秒数 % 60,获取到 超过小时数、分钟数的秒数(秒数)
const seconds = val % 60;
currTime.value =
filteTime(hours) + ":" + filteTime(minutes) + ":" + filteTime(seconds);
if (val <= 0) {
clearInterval(interval);
interval = null;
countdownHours.value = "00";
countdownMinutes.value = "00";
countdownSeconds.value = "00";
currTime.value = "00:00:00";
return;
}
function filteTime (time) {
if (time < 10) {
return "0" + time;
} else {
return time;
}
}
const hours = Math.floor(val / 3600);
const minutes = Math.floor(val / 60) % 60;
const seconds = val % 60;
countdownHours.value = hours < 10 ? "0" + hours : String(hours);
countdownMinutes.value = minutes < 10 ? "0" + minutes : String(minutes);
countdownSeconds.value = seconds < 10 ? "0" + seconds : String(seconds);
currTime.value = `${countdownHours.value}:${countdownMinutes.value}:${countdownSeconds.value}`;
});
function getListByDay () {
@@ -120,7 +189,7 @@ function getListByDay () {
function goGoodsDetail (skuId, goodsId) {
// 跳转商品详情
let routeUrl = router.resolve({
const routeUrl = router.resolve({
path: "/goodsDetail",
query: { skuId, goodsId },
});
@@ -135,41 +204,46 @@ function salePercent (item) {
// 场次已开始,且销量达到库存
function isSoldOut (item) {
if (!list.value[currIndex.value]) return false;
return nowHour.value >= list.value[currIndex.value].timeLine && item.quantity <= item.salesNum;
}
// 场次已开始且未售罄才可抢
function canBuy (item) {
if (!list.value[currIndex.value]) return false;
return nowHour.value >= list.value[currIndex.value].timeLine && !isSoldOut(item);
}
// 按钮文案:未开始 / 已售罄 / 立即抢购
function buyText (item) {
if (!list.value[currIndex.value]) return "即将开始";
if (nowHour.value < list.value[currIndex.value].timeLine) return "即将开始";
if (isSoldOut(item)) return "已售罄";
return "立即抢购";
}
function countDown (currIndex) {
// 倒计时
// 0点时间戳
let zeroTime = new Date(new Date().toLocaleDateString()).getTime();
let currTime = new Date().getTime();
if (!list.value[currIndex]) return;
// 倒计时:0点时间戳
const zeroTime = new Date(new Date().toLocaleDateString()).getTime();
const currTimeMs = new Date().getTime();
let actTime = 0;
let nowHour = new Date().getHours(); // 当前小时数
if (list.value[currIndex].timeLine > nowHour) {
// 活动未开始
const currentHour = new Date().getHours();
nowHour.value = currentHour;
if (list.value[currIndex].timeLine > currentHour) {
// 活动未开始:距开始时间
actTime = zeroTime + list.value[currIndex].timeLine * 3600 * 1000;
} else if (list.value[currIndex].timeLine <= nowHour) {
// 活动进行中
} else {
// 活动进行中:距结束时间
if (currIndex === list.value.length - 1) {
// 如果是最后一个活动,直到24点结束
actTime = zeroTime + 24 * 3600 * 1000;
} else {
actTime = zeroTime + list.value[currIndex + 1].timeLine * 3600 * 1000;
}
}
diffSeconds.value = Math.floor((actTime - currTime) / 1000);
diffSeconds.value = Math.max(0, Math.floor((actTime - currTimeMs) / 1000));
interval = setInterval(() => {
diffSeconds.value--;
}, 1000);
@@ -183,216 +257,434 @@ onBeforeUnmount(() => {
clearInterval(interval);
});
</script>
<style lang="scss" scoped>
@import "../../assets/styles/goodsList.scss";
.seckill-list {
background: #f5f5f5;
background: #f7f8fa;
min-height: 100vh;
}
.promotion-decorate {
width: auto;
margin: 28px auto 16px;
color: #171A26;
font-size: 22px;
font-weight: 600;
/* 顶部氛围 Hero 横幅 */
.seckill-hero {
background: linear-gradient(135deg, #e52d27 0%, #b31217 60%, #790c10 100%);
color: #fff;
padding: 30px 0 38px;
box-shadow: inset 0 -4px 12px rgba(0, 0, 0, 0.15);
&::before,
&::after {
display: none;
.hero-inner {
width: 1200px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
}
}
.time-line {
width: 1200px;
max-width: 1200px;
height: auto;
margin: 0 auto;
background: #fff;
display: flex;
box-sizing: border-box;
border-radius: 8px;
overflow: hidden;
.hero-left {
display: flex;
flex-direction: column;
gap: 8px;
li {
flex: 1;
width: auto;
min-height: 78px;
padding: 12px 20px;
.hero-tag {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 10px;
background: rgba(255, 255, 255, 0.2);
border-radius: 20px;
font-size: 13px;
width: fit-content;
backdrop-filter: blur(4px);
.lightning {
color: #ffeb3b;
font-weight: bold;
}
}
.hero-title {
margin: 0;
font-size: 34px;
font-weight: 800;
letter-spacing: 2px;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.25);
}
.hero-subtitle {
margin: 0;
font-size: 14px;
color: rgba(255, 255, 255, 0.85);
letter-spacing: 1px;
}
}
.hero-countdown {
background: rgba(0, 0, 0, 0.25);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 12px;
padding: 16px 24px;
backdrop-filter: blur(8px);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
gap: 4px;
font-size: 18px;
font-weight: 600;
color: #171A26;
box-sizing: border-box;
gap: 8px;
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.18);
& + li {
box-shadow: inset 1px 0 0 #f2f2f2;
}
&:hover {
cursor: pointer;
background: #fafafa;
}
p {
margin: 0;
.countdown-badge {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 400;
line-height: 18px;
}
.not-curr {
border: none;
border-radius: 0;
padding: 0;
margin: 0;
font-size: 13px;
font-weight: 400;
color: #999;
}
}
.currTimeline {
background: $theme_color;
color: #fff;
&:hover {
background: $theme_color;
}
> div:nth-child(1) {
font-size: 22px;
line-height: 28px;
}
> div:nth-child(2) {
margin-left: 0;
}
.not-curr {
color: #fff;
font-weight: 500;
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #ffc107;
&.is-live {
background: #4caf50;
box-shadow: 0 0 8px #4caf50;
}
}
}
.countdown-label {
font-size: 13px;
color: rgba(255, 255, 255, 0.9);
}
.countdown-boxes {
display: flex;
align-items: center;
gap: 6px;
.time-block {
display: inline-block;
min-width: 38px;
height: 38px;
line-height: 38px;
text-align: center;
background: #1e1e1e;
color: #ffeb3b;
font-size: 20px;
font-weight: 700;
font-family: Consolas, Monaco, monospace;
border-radius: 6px;
padding: 0 4px;
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.3);
}
.time-sep {
font-size: 20px;
font-weight: bold;
color: #fff;
}
}
}
}
/* 时间轴导航 */
.time-line-wrap {
width: 1200px;
margin: -18px auto 0;
position: relative;
z-index: 10;
.time-line {
list-style: none;
padding: 0;
margin: 0;
background: #fff;
border-radius: 10px;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.06);
display: flex;
overflow: hidden;
&.single-session {
justify-content: center;
li {
max-width: 300px;
}
}
li {
flex: 1;
min-width: 160px;
max-width: 240px;
height: 68px;
padding: 10px 16px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
cursor: pointer;
background: #fff;
color: #333;
transition: all 0.2s ease;
position: relative;
box-sizing: border-box;
& + li {
box-shadow: inset 1px 0 0 #f0f0f0;
}
&:hover:not(.currTimeline) {
background: #fafafa;
color: var(--theme-color, #e52d27);
}
.timeline-hour {
font-size: 19px;
font-weight: 700;
line-height: 22px;
}
.timeline-status {
.status-pill {
display: inline-block;
font-size: 12px;
padding: 1px 8px;
border-radius: 10px;
background: #f5f5f5;
color: #888;
line-height: 16px;
}
}
}
.currTimeline {
background: linear-gradient(135deg, var(--theme-color, #e52d27) 0%, #ff5252 100%);
color: #fff;
box-shadow: 0 4px 12px rgba(229, 45, 39, 0.3);
&:hover {
background: linear-gradient(135deg, var(--theme-color, #e52d27) 0%, #ff5252 100%);
}
.timeline-hour {
font-size: 21px;
}
.timeline-status {
.status-pill {
background: rgba(255, 255, 255, 0.25);
color: #fff;
font-weight: 500;
}
}
}
}
}
/* 秒杀商品容器 */
.seckill-goods-wrap {
width: 1200px;
max-width: 1200px;
margin: 16px auto 40px;
padding: 16px;
box-sizing: border-box;
margin: 20px auto 50px;
background: #fff;
border-radius: 8px;
min-height: 320px;
border-radius: 10px;
padding: 24px;
box-sizing: border-box;
min-height: 380px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.04);
.goods-list {
width: 100%;
margin: 0;
column-gap: 16px;
.empty-wrap {
padding: 60px 0;
}
:deep(.empty) {
margin: 48px 0;
color: #999;
.goods-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px;
}
}
.goods-show-info1 {
width: calc(25% - 12px);
min-width: 0;
padding: 12px;
margin: 0 0 16px;
position: relative;
border: 1px solid #f0f0f0;
/* 商品卡片 */
.goods-card {
background: #fff;
border: 1px solid #ebebeb;
border-radius: 8px;
cursor: pointer;
background-color: #fff;
padding: 12px;
box-sizing: border-box;
display: flex;
flex-direction: column;
cursor: pointer;
transition: all 0.25s cubic-bezier(0.25, 0.8, 0.25, 1);
position: relative;
&:hover {
border-color: #eee;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.06);
border-color: #ffd0d0;
transform: translateY(-4px);
box-shadow: 0 10px 24px rgba(229, 45, 39, 0.12);
}
}
.goods-show-img {
display: flex;
justify-content: center;
border-radius: 6px;
overflow: hidden;
background: #fafafa;
img {
.goods-img-box {
position: relative;
width: 100%;
height: auto;
aspect-ratio: 1;
object-fit: cover;
}
}
border-radius: 6px;
overflow: hidden;
background: #f9f9f9;
.goods-show-price {
display: flex;
align-items: baseline;
gap: 8px;
margin-top: 10px;
}
img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
.origin-price {
color: #999;
font-size: 13px;
text-decoration: line-through;
}
&:hover img {
transform: scale(1.03);
}
.goods-show-detail {
height: 40px;
margin: 8px 0;
color: #333;
font-size: 14px;
line-height: 20px;
}
.sold-out-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.55);
display: flex;
align-items: center;
justify-content: center;
.goods-show-num1 {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
margin-bottom: 8px;
color: #666;
span {
flex-shrink: 0;
span {
display: inline-block;
padding: 6px 14px;
border-radius: 20px;
background: rgba(255, 255, 255, 0.9);
color: #333;
font-size: 13px;
font-weight: 600;
}
}
}
:deep(.el-progress) {
flex: 1;
.goods-price-row {
display: flex;
align-items: baseline;
gap: 8px;
margin-top: 12px;
.price-main {
color: var(--theme-color, #e52d27);
font-weight: 800;
display: flex;
align-items: baseline;
.price-symbol {
font-size: 14px;
}
.price-val {
font-size: 22px;
font-family: Arial, Helvetica, sans-serif;
}
}
.price-origin {
font-size: 13px;
color: #aaa;
text-decoration: line-through;
}
}
.goods-name-row {
margin-top: 8px;
height: 40px;
font-size: 14px;
line-height: 20px;
color: #333;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
word-break: break-all;
font-weight: 500;
}
.goods-progress-row {
margin-top: 10px;
height: 20px;
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: #777;
.progress-label {
flex-shrink: 0;
}
:deep(.el-progress) {
flex: 1;
margin: 0;
}
.upcoming-tip {
color: #e67e22;
font-size: 12px;
background: #fff8f0;
padding: 2px 8px;
border-radius: 4px;
}
}
.goods-store-row {
margin-top: 8px;
margin-bottom: 12px;
font-size: 12px;
color: #999;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
.store-name {
&:hover {
color: var(--theme-color, #e52d27);
}
}
}
.goods-action-btn {
margin-top: auto;
height: 36px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 600;
transition: all 0.2s ease;
user-select: none;
&.btn-active {
background: linear-gradient(135deg, var(--theme-color, #e52d27) 0%, #ff5252 100%);
color: #fff;
box-shadow: 0 4px 10px rgba(229, 45, 39, 0.25);
&:hover {
opacity: 0.92;
box-shadow: 0 6px 14px rgba(229, 45, 39, 0.35);
}
}
&.btn-upcoming {
background: #ff9800;
color: #fff;
&:hover {
opacity: 0.92;
}
}
&.btn-disabled {
background: #e0e0e0;
color: #888;
cursor: not-allowed;
box-shadow: none;
}
}
}
.goods-show-seller {
margin-bottom: 10px;
color: #999;
font-size: 13px;
}
.goods-seckill-btn {
margin-top: auto;
height: 34px;
border-radius: 4px;
color: #fff;
text-align: center;
line-height: 34px;
font-size: 14px;
background-color: $theme_color;
&.is-disabled {
background: #c8c8c8;
}
}
</style>
</style>