feat: 支持秒杀活动

This commit is contained in:
richarjiang
2026-04-09 10:24:44 +08:00
parent 23bdd05811
commit 74551085e3
29 changed files with 3521 additions and 760 deletions

View File

@@ -30,35 +30,10 @@
class="card-row"
@tap="goToDetail(card.id)"
>
<!-- Card Cover horizontal premium design -->
<!-- Card Cover clean minimal design -->
<view class="card-cover" :class="getCardCoverClass(card.type)">
<!-- Left accent bar -->
<view class="cover-accent-bar" />
<!-- Decorative circles -->
<view class="cover-deco cover-deco--tl" />
<view class="cover-deco cover-deco--br" />
<!-- CSS Icon -->
<view class="cover-icon" :class="`cover-icon--${card.type}`" />
<!-- Right side: text content -->
<view class="cover-content">
<view class="cover-badge">
<text class="cover-badge-text">{{ getCardTypeLabel(card.type) }}</text>
</view>
<text class="cover-name">{{ card.name }}</text>
<view class="cover-price-row">
<text class="cover-currency">¥</text>
<text class="cover-price">{{ formatPrice(card.price) }}</text>
</view>
<text
v-if="card.originalPrice && card.originalPrice > card.price"
class="cover-original"
>
¥{{ formatPrice(card.originalPrice) }}
</text>
</view>
<view class="cover-deco cover-deco--1" />
<view class="cover-deco cover-deco--2" />
</view>
<!-- Card info aligns with card-cover height -->
@@ -100,7 +75,7 @@
import { ref, onMounted } from 'vue'
import type { CardType } from '@mp-pilates/shared'
import { get } from '../utils/request'
import { formatPrice, getCardTypeLabel, getCardCoverClass } from '../utils/format'
import { formatPrice, getCardCoverClass } from '../utils/format'
const cardTypes = ref<CardType[]>([])
const loading = ref(false)
@@ -180,249 +155,51 @@ function goToAllCards() {
}
/* ══════════════════════════════════════════════════════════
CARD COVER — Horizontal premium card design
CARD COVER — Clean minimal design
══════════════════════════════════════════════════════════ */
.card-cover {
width: 240rpx;
width: 200rpx;
height: 130rpx;
border-radius: 16rpx;
overflow: hidden;
flex-shrink: 0;
position: relative;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 0;
/* Glow effect behind */
&::before {
content: '';
position: absolute;
top: -20rpx;
left: -20rpx;
right: -20rpx;
bottom: -20rpx;
background: inherit;
filter: blur(24rpx) brightness(0.8);
z-index: 0;
opacity: 0.4;
}
}
/* Left accent stripe */
.cover-accent-bar {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 6rpx;
background: rgba(255, 255, 255, 0.4);
z-index: 1;
}
/* Decorative circles */
.cover-deco {
position: absolute;
border-radius: 50%;
z-index: 0;
pointer-events: none;
&--tl {
width: 60rpx;
height: 60rpx;
top: -16rpx;
right: 20rpx;
background: rgba(255, 255, 255, 0.1);
&--1 {
width: 100rpx;
height: 100rpx;
top: -30rpx;
right: -20rpx;
background: rgba(255, 255, 255, 0.4);
}
&--br {
width: 80rpx;
height: 80rpx;
bottom: -24rpx;
left: -16rpx;
background: rgba(255, 255, 255, 0.07);
}
}
/* CSS-drawn icons */
.cover-icon {
width: 52rpx;
height: 52rpx;
position: relative;
z-index: 2;
flex-shrink: 0;
margin-left: 20rpx;
}
/* 次卡 — stacked cards */
.cover-icon--TIMES {
&::before {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 36rpx;
height: 24rpx;
border: 2rpx solid rgba(255, 255, 255, 0.85);
border-radius: 5rpx;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.12);
}
&::after {
content: '';
position: absolute;
bottom: 10rpx;
left: 50%;
transform: translateX(-50%);
width: 36rpx;
height: 24rpx;
border: 2rpx solid rgba(255, 255, 255, 1);
border-radius: 5rpx;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.2);
}
}
/* 月卡 — calendar */
.cover-icon--DURATION {
&::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 36rpx;
height: 30rpx;
border: 2rpx solid rgba(255, 255, 255, 0.9);
border-radius: 5rpx;
box-sizing: border-box;
}
&::after {
content: '';
position: absolute;
top: 9rpx;
left: 50%;
transform: translateX(-50%);
width: 24rpx;
height: 0;
border-top: 2rpx solid rgba(255, 255, 255, 1);
box-shadow:
-6rpx 5rpx 0 0 rgba(255, 255, 255, 0.9),
6rpx 5rpx 0 0 rgba(255, 255, 255, 0.9);
}
}
/* 体验卡 — star */
.cover-icon--TRIAL {
&::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 16rpx;
height: 16rpx;
border: 2rpx solid rgba(255, 255, 255, 1);
border-radius: 50%;
box-sizing: border-box;
&--2 {
width: 70rpx;
height: 70rpx;
bottom: -20rpx;
left: -10rpx;
background: rgba(255, 255, 255, 0.25);
}
&::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 2rpx;
height: 42rpx;
background: rgba(255, 255, 255, 0.8);
box-shadow:
0 -12rpx 0 0 rgba(255, 255, 255, 0.8),
0 12rpx 0 0 rgba(255, 255, 255, 0.8),
-12rpx 0 0 0 rgba(255, 255, 255, 0.8),
12rpx 0 0 0 rgba(255, 255, 255, 0.8),
-8rpx -8rpx 0 0 rgba(255, 255, 255, 0.8),
8rpx -8rpx 0 0 rgba(255, 255, 255, 0.8),
-8rpx 8rpx 0 0 rgba(255, 255, 255, 0.8),
8rpx 8rpx 0 0 rgba(255, 255, 255, 0.8);
}
}
/* Card cover backgrounds */
.cover--times {
background: linear-gradient(135deg, #1e2340 0%, #2d2d5e 50%, #3a3a7a 100%);
background: linear-gradient(135deg, #E8D5C4 0%, #D4BFA8 100%);
}
.cover--duration {
background: linear-gradient(135deg, #4a1a6b 0%, #6c3483 50%, #8e4aaf 100%);
background: linear-gradient(135deg, #D8C8DC 0%, #C4AECB 100%);
}
.cover--trial {
background: linear-gradient(135deg, #14527a 0%, #1a6fa0 50%, #48a9a6 100%);
}
/* Right side text content */
.cover-content {
flex: 1;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
padding: 0 16rpx 0 12rpx;
gap: 4rpx;
z-index: 2;
}
.cover-badge {
padding: 3rpx 10rpx;
border-radius: 8rpx;
background: rgba(255, 255, 255, 0.18);
border: 1rpx solid rgba(255, 255, 255, 0.28);
}
.cover-badge-text {
font-size: 16rpx;
color: rgba(255, 255, 255, 0.9);
font-weight: 600;
}
.cover-name {
font-size: 24rpx;
font-weight: 700;
color: #ffffff;
letter-spacing: 0.5rpx;
line-height: 1.2;
max-width: 130rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cover-price-row {
display: flex;
align-items: baseline;
gap: 2rpx;
}
.cover-currency {
font-size: 18rpx;
font-weight: 600;
color: rgba(255, 255, 255, 0.85);
}
.cover-price {
font-size: 28rpx;
font-weight: 800;
color: #ffffff;
line-height: 1;
}
.cover-original {
font-size: 16rpx;
color: rgba(255, 255, 255, 0.5);
text-decoration: line-through;
background: linear-gradient(135deg, #C8D8D2 0%, #A9C4BC 100%);
}
/* ── Card info — matches card-cover height ── */

View File

@@ -0,0 +1,435 @@
<template>
<view v-if="flashSales.length" class="flash-sale-section">
<!-- Section header -->
<view class="section-header">
<view class="header-left">
<view class="flash-icon-wrap">
<view class="flash-icon-clock" />
</view>
<text class="section-title">限时秒杀</text>
<view v-if="hasOngoing" class="live-dot" />
</view>
</view>
<!-- Horizontal scroll cards -->
<scroll-view
scroll-x
:show-scrollbar="false"
class="flash-scroll"
>
<view class="flash-card-list">
<view
v-for="sale in flashSales"
:key="sale.id"
class="flash-card"
:class="cardPhaseClass(sale.phase)"
@tap="goToDetail(sale.id)"
>
<!-- Top gradient band -->
<view class="card-top">
<!-- Phase badge -->
<view class="phase-badge" :class="badgeClass(sale.phase)">
<text class="phase-badge-text">{{ phaseLabel(sale.phase) }}</text>
</view>
<!-- Countdown / status text -->
<view class="countdown-row">
<text v-if="sale.phase === FlashSalePhase.UPCOMING" class="countdown-label">距开始</text>
<text v-else-if="sale.phase === FlashSalePhase.ONGOING" class="countdown-label">剩余</text>
<view v-if="sale.phase === FlashSalePhase.UPCOMING || sale.phase === FlashSalePhase.ONGOING" class="countdown-blocks">
<text class="cd-block">{{ getSaleCountdown(sale).h }}</text>
<text class="cd-sep">:</text>
<text class="cd-block">{{ getSaleCountdown(sale).m }}</text>
<text class="cd-sep">:</text>
<text class="cd-block">{{ getSaleCountdown(sale).s }}</text>
</view>
</view>
</view>
<!-- Card body -->
<view class="card-body">
<text class="card-title">{{ sale.title }}</text>
<text class="card-type-name">{{ sale.cardType.name }}</text>
<!-- Price area -->
<view class="price-area">
<view class="flash-price-row">
<text class="flash-currency">¥</text>
<text class="flash-price">{{ formatPrice(sale.flashPrice) }}</text>
</view>
<text class="original-price">¥{{ formatPrice(sale.originalPrice) }}</text>
</view>
<!-- Stock progress -->
<view class="stock-area">
<view class="stock-bar">
<view
class="stock-fill"
:class="{ 'stock-fill--hot': getStockRatio(sale.soldCount, sale.totalStock) > 0.6 }"
:style="{ width: stockPercent(sale) }"
/>
</view>
<text class="stock-text">
{{ sale.phase === FlashSalePhase.SOLD_OUT ? '已售罄' : `${sale.remainingStock}/${sale.totalStock}` }}
</text>
</view>
</view>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { FlashSalePhase } from '@mp-pilates/shared'
import type { FlashSaleListItem } from '@mp-pilates/shared'
import { formatPrice, getFlashSalePhaseLabel, getCountdownParts, getStockRatio, getStockPercent } from '../utils/format'
import { get } from '../utils/request'
const flashSales = ref<FlashSaleListItem[]>([])
const tick = ref(0)
let timer: ReturnType<typeof setInterval> | null = null
const hasOngoing = computed(() =>
flashSales.value.some((s) => s.phase === FlashSalePhase.ONGOING),
)
async function fetchFlashSales() {
try {
const data = await get<FlashSaleListItem[]>('/flash-sales')
flashSales.value = [...data]
} catch {
flashSales.value = []
}
}
// Expose for parent page refresh
defineExpose({ fetchFlashSales })
function phaseLabel(phase: FlashSalePhase): string {
return getFlashSalePhaseLabel(phase)
}
function cardPhaseClass(phase: FlashSalePhase): string {
if (phase === FlashSalePhase.ONGOING) return 'card--ongoing'
if (phase === FlashSalePhase.UPCOMING) return 'card--upcoming'
if (phase === FlashSalePhase.SOLD_OUT) return 'card--soldout'
return 'card--ended'
}
function badgeClass(phase: FlashSalePhase): string {
if (phase === FlashSalePhase.ONGOING) return 'badge--ongoing'
if (phase === FlashSalePhase.UPCOMING) return 'badge--upcoming'
return 'badge--inactive'
}
function stockPercent(sale: FlashSaleListItem): string {
return getStockPercent(sale.soldCount, sale.totalStock)
}
function getSaleCountdown(sale: FlashSaleListItem) {
void tick.value
const target = sale.phase === FlashSalePhase.UPCOMING ? sale.startTime : sale.endTime
return getCountdownParts(target)
}
function goToDetail(id: string) {
uni.navigateTo({ url: `/pages/flash-sale/detail?id=${id}` })
}
onMounted(() => {
fetchFlashSales()
timer = setInterval(() => {
tick.value++
}, 1000)
})
onUnmounted(() => {
if (timer) {
clearInterval(timer)
timer = null
}
})
</script>
<style lang="scss" scoped>
.flash-sale-section {
background: #fff;
margin-top: 16rpx;
padding-bottom: 24rpx;
}
/* ── Section header ── */
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 28rpx 32rpx 16rpx;
}
.header-left {
display: flex;
align-items: center;
gap: 12rpx;
}
.flash-icon-wrap {
width: 44rpx;
height: 44rpx;
border-radius: 12rpx;
background: linear-gradient(135deg, #D4A59A, #C08B7E);
display: flex;
align-items: center;
justify-content: center;
}
/* CSS-drawn clock icon */
.flash-icon-clock {
width: 24rpx;
height: 24rpx;
border: 3rpx solid #fff;
border-radius: 50%;
position: relative;
&::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 2rpx;
height: 9rpx;
background: #fff;
transform-origin: bottom center;
transform: translate(-50%, -100%) rotate(0deg);
border-radius: 1rpx;
}
&::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 2rpx;
height: 7rpx;
background: #fff;
transform-origin: bottom center;
transform: translate(-50%, -100%) rotate(90deg);
border-radius: 1rpx;
}
}
.section-title {
font-size: 36rpx;
font-weight: 700;
color: #222;
}
.live-dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
background: #C08B7E;
animation: pulse 1.5s ease infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.4; transform: scale(0.8); }
}
/* ── Horizontal scroll ── */
.flash-scroll {
padding-left: 32rpx;
white-space: nowrap;
}
.flash-card-list {
display: inline-flex;
gap: 20rpx;
padding-right: 32rpx;
}
/* ── Flash card ── */
.flash-card {
width: 340rpx;
border-radius: 20rpx;
overflow: hidden;
flex-shrink: 0;
display: inline-flex;
flex-direction: column;
box-shadow: 0 4rpx 20rpx rgba(192, 139, 126, 0.18);
white-space: normal;
}
.card--ongoing { box-shadow: 0 6rpx 28rpx rgba(192, 139, 126, 0.28); }
.card--upcoming { opacity: 0.95; }
.card--soldout { opacity: 0.7; }
.card--ended { opacity: 0.5; }
/* Card top gradient — warm blush tones */
.card-top {
padding: 20rpx 20rpx 16rpx;
background: linear-gradient(135deg, #D4A59A 0%, #C9948A 40%, #B5836E 100%);
display: flex;
flex-direction: column;
gap: 10rpx;
}
.card--upcoming .card-top {
background: linear-gradient(135deg, #8FA89A 0%, #7BA5A0 100%);
}
.card--soldout .card-top,
.card--ended .card-top {
background: linear-gradient(135deg, #C4BAB0, #AEA49A);
}
/* Phase badge */
.phase-badge {
align-self: flex-start;
padding: 4rpx 14rpx;
border-radius: 12rpx;
}
.badge--ongoing { background: rgba(255, 255, 255, 0.3); }
.badge--upcoming { background: rgba(255, 255, 255, 0.25); }
.badge--inactive { background: rgba(0, 0, 0, 0.1); }
.phase-badge-text {
font-size: 20rpx;
font-weight: 600;
color: #fff;
}
/* Countdown */
.countdown-row {
display: flex;
align-items: center;
gap: 8rpx;
}
.countdown-label {
font-size: 20rpx;
color: rgba(255, 255, 255, 0.85);
}
.countdown-blocks {
display: flex;
align-items: center;
gap: 4rpx;
}
.cd-block {
background: rgba(255, 255, 255, 0.25);
color: #fff;
font-size: 22rpx;
font-weight: 700;
padding: 4rpx 8rpx;
border-radius: 6rpx;
font-family: 'DIN Alternate', monospace;
min-width: 36rpx;
text-align: center;
backdrop-filter: blur(4px);
}
.cd-sep {
color: rgba(255, 255, 255, 0.75);
font-size: 20rpx;
font-weight: 700;
}
/* Card body */
.card-body {
padding: 20rpx;
background: #fff;
display: flex;
flex-direction: column;
gap: 8rpx;
}
.card-title {
font-size: 28rpx;
font-weight: 700;
color: $text-primary;
line-height: 1.3;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.card-type-name {
font-size: 22rpx;
color: $text-hint;
}
/* Price area */
.price-area {
display: flex;
align-items: baseline;
gap: 12rpx;
margin-top: 4rpx;
}
.flash-price-row {
display: flex;
align-items: baseline;
}
.flash-currency {
font-size: 22rpx;
font-weight: 700;
color: #B5725E;
}
.flash-price {
font-size: 40rpx;
font-weight: 800;
color: #B5725E;
line-height: 1;
}
.original-price {
font-size: 22rpx;
color: #ccc;
text-decoration: line-through;
}
/* Stock area */
.stock-area {
display: flex;
flex-direction: column;
gap: 6rpx;
margin-top: 8rpx;
}
.stock-bar {
height: 10rpx;
background: #f5f0ed;
border-radius: 5rpx;
overflow: hidden;
}
.stock-fill {
height: 100%;
background: linear-gradient(90deg, #D4A59A, #C08B7E);
border-radius: 5rpx;
transition: width 0.3s;
&--hot {
animation: stockPulse 2s ease infinite;
}
}
@keyframes stockPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
.stock-text {
font-size: 20rpx;
color: $text-hint;
}
</style>

View File

@@ -104,6 +104,18 @@
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/admin/flash-sales",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/flash-sale/detail",
"style": {
"navigationStyle": "custom"
}
}
],
"globalStyle": {

View File

@@ -522,16 +522,16 @@ onMounted(fetchCardTypes)
justify-content: space-between;
}
.header--times { background: linear-gradient(90deg, #1a1a2e, #2d2d5e); }
.header--duration { background: linear-gradient(90deg, #6c3483, #9b59b6); }
.header--trial { background: linear-gradient(90deg, #5a7a8a, $primary-dark); }
.header--times { background: linear-gradient(90deg, #E8D5C4, #D4BFA8); }
.header--duration { background: linear-gradient(90deg, #D8C8DC, #C4AECB); }
.header--trial { background: linear-gradient(90deg, #C8D8D2, #A9C4BC); }
.ct-type-label { font-size: 22rpx; font-weight: 600; color: #ffffff; letter-spacing: 2rpx; }
.ct-type-label { font-size: 22rpx; font-weight: 600; color: $brand-color; letter-spacing: 2rpx; }
.ct-status-tag { border-radius: 20rpx; padding: 4rpx 16rpx; }
.tag--on { background: rgba(255,255,255,0.2); }
.tag--off { background: rgba(0,0,0,0.2); }
.ct-status-text { font-size: 20rpx; color: #ffffff; }
.tag--on { background: rgba(74, 64, 53, 0.1); }
.tag--off { background: rgba(74, 64, 53, 0.08); }
.ct-status-text { font-size: 20rpx; color: $brand-color; }
.ct-body { padding: 24rpx; }

View File

@@ -0,0 +1,863 @@
<template>
<view class="page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="秒杀管理" show-back />
<!-- Toolbar -->
<view class="toolbar">
<text class="toolbar-hint"> {{ total }} 个秒杀活动</text>
<view class="add-btn" @tap="openAdd">
<text class="add-btn-text"> 新建秒杀</text>
</view>
</view>
<!-- Loading skeleton -->
<view v-if="pageLoading" class="skeleton-list">
<view v-for="i in 3" :key="i" class="skeleton-item" />
</view>
<!-- Empty -->
<view v-else-if="!items.length" class="empty-state">
<text class="empty-icon"></text>
<text class="empty-text">暂无秒杀活动点击右上角新建</text>
</view>
<!-- Flash sale list -->
<view v-else class="fs-list">
<view
v-for="item in items"
:key="item.id"
class="fs-card"
>
<!-- Header band -->
<view class="fs-header" :class="headerStatusClass(item)">
<view class="fs-header-left">
<text class="fs-title">{{ item.title }}</text>
</view>
<view class="fs-status-tag" :class="phaseTagClass(item.phase)">
<text class="fs-status-text">{{ phaseLabel(item.phase) }}</text>
</view>
</view>
<!-- Body -->
<view class="fs-body">
<view class="fs-info-row">
<text class="fs-card-type">关联卡种: {{ item.cardType.name }}</text>
</view>
<view class="fs-price-row">
<view class="fs-price-block">
<text class="fs-price-label">秒杀价</text>
<text class="fs-price-value flash">¥{{ formatPrice(item.flashPrice) }}</text>
</view>
<view class="fs-price-block">
<text class="fs-price-label">原价</text>
<text class="fs-price-value original">¥{{ formatPrice(item.originalPrice) }}</text>
</view>
<view class="fs-price-block">
<text class="fs-price-label">库存</text>
<text class="fs-price-value">{{ item.soldCount }}/{{ item.totalStock }}</text>
</view>
</view>
<!-- Stock progress bar -->
<view class="fs-stock-bar">
<view
class="fs-stock-fill"
:style="{ width: stockPercent(item) }"
/>
</view>
<view class="fs-time-row">
<text class="fs-time">{{ formatDateTime(item.startTime) }} {{ formatDateTime(item.endTime) }}</text>
</view>
</view>
<!-- Actions -->
<view class="fs-actions">
<view class="fs-action-btn edit-btn" @tap.stop="openEdit(item)">
<text class="fs-action-text">编辑</text>
</view>
<view
v-if="item.status === 'DRAFT'"
class="fs-action-btn activate-btn"
@tap.stop="confirmActivate(item)"
>
<text class="fs-action-text">上线</text>
</view>
<view
v-else-if="item.status === 'ACTIVE'"
class="fs-action-btn end-btn"
@tap.stop="confirmEnd(item)"
>
<text class="fs-action-text">结束</text>
</view>
<view
v-if="item.soldCount === 0"
class="fs-action-btn delete-btn"
@tap.stop="confirmDelete(item)"
>
<text class="fs-action-text">删除</text>
</view>
</view>
</view>
</view>
<!-- Add / Edit modal -->
<view v-if="showModal" class="modal-mask" @tap.stop="closeModal">
<view class="modal-container" @tap.stop>
<scroll-view scroll-y class="modal-scroll">
<!-- Header -->
<view class="modal-header">
<text class="modal-title">{{ editTarget ? '编辑秒杀' : '新建秒杀' }}</text>
<view class="modal-close" @tap="closeModal">
<text class="modal-close-icon"></text>
</view>
</view>
<!-- Form fields -->
<view class="modal-body">
<!-- Card type picker -->
<view class="modal-field">
<text class="modal-label">关联卡种</text>
<picker
mode="selector"
:range="cardTypeOptions"
range-key="label"
:value="form.cardTypeIdx"
@change="onCardTypeChange"
:disabled="!!editTarget"
>
<view class="picker-display">
<text class="picker-text">{{ cardTypeOptions[form.cardTypeIdx]?.label || '请选择' }}</text>
<text class="picker-arrow"></text>
</view>
</picker>
</view>
<view class="modal-field">
<text class="modal-label">活动标题</text>
<input
class="modal-input"
v-model="form.title"
placeholder="如:新春限时秒杀"
placeholder-style="color:#bbb"
/>
</view>
<view class="modal-field">
<text class="modal-label">原价</text>
<input
class="modal-input"
type="digit"
v-model="form.originalPriceStr"
placeholder="展示划线价"
placeholder-style="color:#bbb"
/>
</view>
<view class="modal-field">
<text class="modal-label">秒杀价</text>
<input
class="modal-input"
type="digit"
v-model="form.flashPriceStr"
placeholder="实际支付价格"
placeholder-style="color:#bbb"
/>
</view>
<view class="modal-field">
<text class="modal-label">库存数量</text>
<input
class="modal-input"
type="number"
v-model="form.totalStockStr"
placeholder="秒杀总量"
placeholder-style="color:#bbb"
/>
</view>
<view class="modal-field">
<text class="modal-label">开始时间</text>
<view class="datetime-picker-group">
<picker
mode="date"
:value="form.startDate"
@change="onStartDateChange"
>
<text class="datetime-text">{{ form.startDate || '选择日期' }}</text>
</picker>
<picker
mode="time"
:value="form.startTimeStr"
@change="onStartTimeChange"
>
<text class="datetime-text">{{ form.startTimeStr || '选择时间' }}</text>
</picker>
</view>
</view>
<view class="modal-field">
<text class="modal-label">结束时间</text>
<view class="datetime-picker-group">
<picker
mode="date"
:value="form.endDate"
@change="onEndDateChange"
>
<text class="datetime-text">{{ form.endDate || '选择日期' }}</text>
</picker>
<picker
mode="time"
:value="form.endTimeStr"
@change="onEndTimeChange"
>
<text class="datetime-text">{{ form.endTimeStr || '选择时间' }}</text>
</picker>
</view>
</view>
<view class="modal-field">
<text class="modal-label">排序值</text>
<input
class="modal-input"
type="number"
v-model="form.sortOrderStr"
placeholder="越小越靠前"
placeholder-style="color:#bbb"
/>
</view>
<view class="modal-field modal-field--last">
<text class="modal-label">活动说明</text>
<textarea
class="modal-textarea"
v-model="form.description"
placeholder="可选,向用户展示"
placeholder-style="color:#bbb"
:maxlength="500"
auto-height
/>
</view>
</view>
<!-- Action buttons -->
<view class="modal-actions">
<view class="modal-cancel" @tap="closeModal">
<text class="modal-cancel-text">取消</text>
</view>
<view
class="modal-confirm"
:class="{ 'modal-confirm--loading': submitting }"
@tap="submitForm"
>
<text class="modal-confirm-text">{{ submitting ? '保存中...' : '确认保存' }}</text>
</view>
</view>
</scroll-view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system'
import { useAdminStore } from '../../stores/admin'
import { formatPrice, formatDateTime, getFlashSalePhaseLabel, getStockPercent, formatDateLocal, formatTimeLocal } from '../../utils/format'
import { FlashSaleStatus, FlashSalePhase } from '@mp-pilates/shared'
import type { FlashSaleAdminItem, CardType } from '@mp-pilates/shared'
const adminStore = useAdminStore()
const navBarHeight = ref('64px')
onMounted(() => {
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
})
// ─── Data ────────────────────────────────────────────
const items = ref<FlashSaleAdminItem[]>([])
const total = ref(0)
const pageLoading = ref(false)
const showModal = ref(false)
const submitting = ref(false)
const editTarget = ref<FlashSaleAdminItem | null>(null)
const cardTypes = ref<CardType[]>([])
const cardTypeOptions = computed(() =>
cardTypes.value.map((ct) => ({
label: `${ct.name}(¥${formatPrice(ct.price)}`,
value: ct.id,
})),
)
const defaultForm = () => ({
cardTypeIdx: 0,
title: '',
originalPriceStr: '',
flashPriceStr: '',
totalStockStr: '',
startDate: '',
startTimeStr: '',
endDate: '',
endTimeStr: '',
sortOrderStr: '0',
description: '',
})
const form = ref(defaultForm())
// ─── Data loading ─────────────────────────────────────
async function loadData() {
pageLoading.value = true
try {
const [salesResult, cardTypesResult] = await Promise.all([
adminStore.fetchFlashSales(),
adminStore.fetchCardTypes(),
])
items.value = [...salesResult.data]
total.value = salesResult.total
cardTypes.value = [...cardTypesResult]
} catch {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
pageLoading.value = false
}
}
async function reloadSales() {
try {
const result = await adminStore.fetchFlashSales()
items.value = [...result.data]
total.value = result.total
} catch {
// silent
}
}
// ─── Helpers ──────────────────────────────────────────
function phaseLabel(phase: FlashSalePhase): string {
return getFlashSalePhaseLabel(phase)
}
function phaseTagClass(phase: FlashSalePhase): string {
if (phase === FlashSalePhase.ONGOING) return 'tag--ongoing'
if (phase === FlashSalePhase.UPCOMING) return 'tag--upcoming'
if (phase === FlashSalePhase.SOLD_OUT) return 'tag--soldout'
return 'tag--ended'
}
function headerStatusClass(item: FlashSaleAdminItem): string {
if (item.status === FlashSaleStatus.DRAFT) return 'header--draft'
if (item.status === FlashSaleStatus.ENDED) return 'header--ended'
return 'header--active'
}
function stockPercent(item: FlashSaleAdminItem): string {
return getStockPercent(item.soldCount, item.totalStock)
}
// ─── Modal ────────────────────────────────────────────
function openAdd() {
editTarget.value = null
form.value = defaultForm()
showModal.value = true
}
function openEdit(item: FlashSaleAdminItem) {
editTarget.value = item
const startDt = new Date(item.startTime)
const endDt = new Date(item.endTime)
const ctIdx = cardTypes.value.findIndex((ct) => ct.id === item.cardTypeId)
form.value = {
cardTypeIdx: ctIdx >= 0 ? ctIdx : 0,
title: item.title,
originalPriceStr: String(item.originalPrice / 100),
flashPriceStr: String(item.flashPrice / 100),
totalStockStr: String(item.totalStock),
startDate: formatDateLocal(startDt),
startTimeStr: formatTimeLocal(startDt),
endDate: formatDateLocal(endDt),
endTimeStr: formatTimeLocal(endDt),
sortOrderStr: String(item.sortOrder),
description: item.description ?? '',
}
showModal.value = true
}
function closeModal() {
showModal.value = false
editTarget.value = null
}
function onCardTypeChange(e: { detail: { value: number } }) {
const idx = Number(e.detail.value)
form.value.cardTypeIdx = idx
// Auto-fill original price from card type
const ct = cardTypes.value[idx]
if (ct && !form.value.originalPriceStr) {
form.value.originalPriceStr = String(Number(ct.price) / 100)
}
}
function onStartDateChange(e: { detail: { value: string } }) {
form.value.startDate = e.detail.value
}
function onStartTimeChange(e: { detail: { value: string } }) {
form.value.startTimeStr = e.detail.value
}
function onEndDateChange(e: { detail: { value: string } }) {
form.value.endDate = e.detail.value
}
function onEndTimeChange(e: { detail: { value: string } }) {
form.value.endTimeStr = e.detail.value
}
// ─── Form submit ──────────────────────────────────────
async function submitForm() {
if (submitting.value) return
if (!form.value.title.trim()) {
uni.showToast({ title: '请填写活动标题', icon: 'none' }); return
}
const originalPrice = parseFloat(form.value.originalPriceStr)
if (isNaN(originalPrice) || originalPrice <= 0) {
uni.showToast({ title: '请填写有效原价', icon: 'none' }); return
}
const flashPrice = parseFloat(form.value.flashPriceStr)
if (isNaN(flashPrice) || flashPrice <= 0) {
uni.showToast({ title: '请填写有效秒杀价', icon: 'none' }); return
}
const totalStock = parseInt(form.value.totalStockStr, 10)
if (isNaN(totalStock) || totalStock < 1) {
uni.showToast({ title: '请填写有效库存', icon: 'none' }); return
}
if (!form.value.startDate || !form.value.startTimeStr) {
uni.showToast({ title: '请选择开始时间', icon: 'none' }); return
}
if (!form.value.endDate || !form.value.endTimeStr) {
uni.showToast({ title: '请选择结束时间', icon: 'none' }); return
}
const startTime = `${form.value.startDate}T${form.value.startTimeStr}:00`
const endTime = `${form.value.endDate}T${form.value.endTimeStr}:00`
if (new Date(endTime) <= new Date(startTime)) {
uni.showToast({ title: '结束时间须晚于开始时间', icon: 'none' }); return
}
submitting.value = true
try {
if (editTarget.value) {
await adminStore.updateFlashSale(editTarget.value.id, {
title: form.value.title.trim(),
originalPrice: Math.round(originalPrice * 100),
flashPrice: Math.round(flashPrice * 100),
totalStock,
startTime,
endTime,
description: form.value.description.trim() || undefined,
sortOrder: parseInt(form.value.sortOrderStr, 10) || 0,
})
} else {
const selectedCardType = cardTypes.value[form.value.cardTypeIdx]
if (!selectedCardType) {
uni.showToast({ title: '请选择卡种', icon: 'none' }); return
}
await adminStore.createFlashSale({
cardTypeId: selectedCardType.id,
title: form.value.title.trim(),
originalPrice: Math.round(originalPrice * 100),
flashPrice: Math.round(flashPrice * 100),
totalStock,
startTime,
endTime,
description: form.value.description.trim() || undefined,
sortOrder: parseInt(form.value.sortOrderStr, 10) || 0,
})
}
uni.showToast({ title: '保存成功', icon: 'success' })
closeModal()
await reloadSales()
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : '保存失败'
uni.showToast({ title: msg, icon: 'none' })
} finally {
submitting.value = false
}
}
// ─── Actions ──────────────────────────────────────────
function confirmActivate(item: FlashSaleAdminItem) {
uni.showModal({
title: '确认上线',
content: `上线后「${item.title}」将对用户可见,到达秒杀时间后用户可抢购。`,
confirmText: '上线',
confirmColor: '#27ae60',
success: async (res) => {
if (!res.confirm) return
uni.showLoading({ title: '上线中...' })
try {
await adminStore.updateFlashSale(item.id, { status: FlashSaleStatus.ACTIVE })
uni.hideLoading()
uni.showToast({ title: '已上线', icon: 'success' })
await reloadSales()
} catch {
uni.hideLoading()
uni.showToast({ title: '上线失败', icon: 'none' })
}
},
})
}
function confirmEnd(item: FlashSaleAdminItem) {
uni.showModal({
title: '确认结束',
content: `结束后「${item.title}」将停止售卖,已购买的不受影响。`,
confirmText: '结束',
confirmColor: '#e67e22',
success: async (res) => {
if (!res.confirm) return
uni.showLoading({ title: '结束中...' })
try {
await adminStore.updateFlashSale(item.id, { status: FlashSaleStatus.ENDED })
uni.hideLoading()
uni.showToast({ title: '已结束', icon: 'success' })
await reloadSales()
} catch {
uni.hideLoading()
uni.showToast({ title: '操作失败', icon: 'none' })
}
},
})
}
function confirmDelete(item: FlashSaleAdminItem) {
uni.showModal({
title: '确认删除',
content: `确定删除「${item.title}」?此操作不可恢复。`,
confirmText: '删除',
confirmColor: '#c0392b',
success: async (res) => {
if (!res.confirm) return
uni.showLoading({ title: '删除中...' })
try {
await adminStore.deleteFlashSale(item.id)
uni.hideLoading()
uni.showToast({ title: '已删除', icon: 'success' })
await reloadSales()
} catch {
uni.hideLoading()
uni.showToast({ title: '删除失败', icon: 'none' })
}
},
})
}
// ─── Lifecycle ────────────────────────────────────────
onMounted(loadData)
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background: #f5f3f0;
padding-bottom: 40rpx;
}
/* ── Toolbar ─────────────────────────────── */
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx 24rpx 16rpx;
}
.toolbar-hint { font-size: 24rpx; color: #999; }
.add-btn {
background: linear-gradient(135deg, #D4A59A, #C08B7E);
border-radius: 32rpx;
padding: 12rpx 28rpx;
display: flex;
align-items: center;
gap: 8rpx;
}
.add-btn-text { font-size: 26rpx; font-weight: 600; color: #fff; }
/* ── Skeleton ────────────────────────────── */
.skeleton-list { padding: 0 24rpx; }
.skeleton-item {
height: 300rpx;
border-radius: 16rpx;
margin-bottom: 20rpx;
background: linear-gradient(90deg, #f0ece8 25%, #e8e4df 50%, #f0ece8 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
}
/* ── Empty ───────────────────────────────── */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 100rpx 0;
gap: 20rpx;
}
.empty-icon { font-size: 80rpx; }
.empty-text { font-size: 28rpx; color: #bbb; }
/* ── Flash sale list ─────────────────────── */
.fs-list { padding: 0 24rpx; }
.fs-card {
background: #fff;
border-radius: 16rpx;
overflow: hidden;
margin-bottom: 20rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.08);
}
.fs-header {
padding: 20rpx 24rpx;
display: flex;
align-items: center;
justify-content: space-between;
}
.header--active { background: linear-gradient(90deg, #D4A59A, #C08B7E); }
.header--draft { background: linear-gradient(90deg, #AEA49A, #9E948A); }
.header--ended { background: linear-gradient(90deg, #B0A898, #9A928A); }
.fs-header-left { flex: 1; min-width: 0; }
.fs-title {
font-size: 28rpx;
font-weight: 700;
color: #fff;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fs-status-tag {
border-radius: 20rpx;
padding: 4rpx 16rpx;
flex-shrink: 0;
margin-left: 12rpx;
}
.tag--ongoing { background: rgba(255, 255, 255, 0.3); }
.tag--upcoming { background: rgba(255, 255, 255, 0.2); }
.tag--soldout { background: rgba(0, 0, 0, 0.2); }
.tag--ended { background: rgba(0, 0, 0, 0.3); }
.fs-status-text { font-size: 20rpx; color: #fff; font-weight: 600; }
.fs-body { padding: 24rpx; }
.fs-info-row { margin-bottom: 16rpx; }
.fs-card-type { font-size: 24rpx; color: #888; }
.fs-price-row {
display: flex;
gap: 32rpx;
margin-bottom: 16rpx;
}
.fs-price-block {
display: flex;
flex-direction: column;
gap: 4rpx;
}
.fs-price-label { font-size: 20rpx; color: #aaa; }
.fs-price-value {
font-size: 30rpx;
font-weight: 700;
color: #333;
&.flash { color: #B5725E; }
&.original { color: #aaa; text-decoration: line-through; font-weight: 400; }
}
/* Stock progress bar */
.fs-stock-bar {
height: 8rpx;
background: #f0f0f0;
border-radius: 4rpx;
overflow: hidden;
margin-bottom: 12rpx;
}
.fs-stock-fill {
height: 100%;
background: linear-gradient(90deg, #D4A59A, #C08B7E);
border-radius: 4rpx;
transition: width 0.3s;
}
.fs-time-row { margin-top: 4rpx; }
.fs-time { font-size: 22rpx; color: #999; }
/* ── Actions ─────────────────────────────── */
.fs-actions {
display: flex;
border-top: 1rpx solid #f5f5f5;
}
.fs-action-btn {
flex: 1;
padding: 20rpx 0;
display: flex;
align-items: center;
justify-content: center;
border-right: 1rpx solid #f5f5f5;
&:last-child { border-right: none; }
&:active { background: #f9f9f9; }
}
.fs-action-text { font-size: 26rpx; font-weight: 600; }
.edit-btn .fs-action-text { color: #1a1a2e; }
.activate-btn .fs-action-text { color: #27ae60; }
.end-btn .fs-action-text { color: #e67e22; }
.delete-btn .fs-action-text { color: #c0392b; }
/* ── Modal ───────────────────────────────── */
.modal-mask {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: flex-end;
z-index: 1000;
}
.modal-container {
width: 100%;
max-height: 85vh;
background: #fff;
border-radius: 24rpx 24rpx 0 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.modal-scroll { flex: 1; max-height: 85vh; }
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 32rpx 32rpx 16rpx;
position: sticky;
top: 0;
background: #fff;
z-index: 10;
}
.modal-title { font-size: 32rpx; font-weight: 700; color: #1a1a2e; }
.modal-close {
width: 56rpx;
height: 56rpx;
display: flex;
align-items: center;
justify-content: center;
background: #f5f5f5;
border-radius: 50%;
}
.modal-close-icon { font-size: 24rpx; color: #999; }
.modal-body { padding: 0 32rpx; }
.modal-field {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx 0;
border-bottom: 1rpx solid #f5f5f5;
gap: 16rpx;
&--last { border-bottom: none; align-items: flex-start; }
}
.modal-label { font-size: 26rpx; color: #555; width: 160rpx; flex-shrink: 0; }
.modal-input { flex: 1; text-align: right; font-size: 26rpx; color: #222; }
.picker-display { display: flex; align-items: center; gap: 8rpx; }
.picker-text { font-size: 26rpx; color: #222; }
.picker-arrow { font-size: 26rpx; color: #bbb; }
.datetime-picker-group {
display: flex;
align-items: center;
gap: 12rpx;
flex: 1;
justify-content: flex-end;
}
.datetime-text {
font-size: 26rpx;
color: #222;
padding: 8rpx 16rpx;
background: #f8f8f8;
border-radius: 8rpx;
}
.modal-textarea {
flex: 1;
font-size: 26rpx;
color: #222;
min-height: 80rpx;
text-align: right;
}
.modal-actions {
display: flex;
gap: 16rpx;
padding: 24rpx 32rpx calc(24rpx + env(safe-area-inset-bottom));
background: #fff;
}
.modal-cancel {
flex: 1;
height: 88rpx;
background: #f0f0f0;
border-radius: 44rpx;
display: flex;
align-items: center;
justify-content: center;
&:active { background: #e8e8e8; }
}
.modal-cancel-text { font-size: 28rpx; color: #555; }
.modal-confirm {
flex: 2;
height: 88rpx;
background: linear-gradient(90deg, #D4A59A, #C08B7E);
border-radius: 44rpx;
display: flex;
align-items: center;
justify-content: center;
&:active { opacity: 0.85; }
&--loading { opacity: 0.6; pointer-events: none; }
}
.modal-confirm-text { font-size: 28rpx; font-weight: 700; color: #fff; }
</style>

View File

@@ -131,6 +131,21 @@
<text class="arrow-text"></text>
</view>
</view>
<view class="list-item" @tap="navigate('/pages/admin/flash-sales')">
<view class="item-left">
<view class="item-icon-wrap icon--flash-sale">
<text class="item-icon-text"></text>
</view>
<view class="item-text-group">
<text class="item-title">秒杀管理</text>
<text class="item-desc">创建和管理限时秒杀活动</text>
</view>
</view>
<view class="item-arrow">
<text class="arrow-text"></text>
</view>
</view>
</view>
<!-- Section header: 系统 -->
@@ -330,6 +345,7 @@ onMounted(() => {
.icon--members { background: linear-gradient(135deg, $primary-color, $primary-dark); }
.icon--orders { background: linear-gradient(135deg, #7E9EC4, #6E8EB4); }
.icon--card { background: linear-gradient(135deg, #C48E7E, #B47E6E); }
.icon--flash-sale { background: linear-gradient(135deg, #D4A59A, #C08B7E); }
.icon--studio { background: linear-gradient(135deg, #9E9E7E, #8E8E6E); }
.item-text-group {

View File

@@ -37,28 +37,10 @@
class="card-row"
@tap="goToDetail(c.id)"
>
<!-- Card Cover horizontal -->
<!-- Card Cover clean minimal -->
<view class="card-cover" :class="getCardCoverClass(c.type)">
<view class="cover-accent-bar" />
<view class="cover-deco cover-deco--tl" />
<view class="cover-deco cover-deco--br" />
<view class="cover-icon" :class="`cover-icon--${c.type}`" />
<view class="cover-content">
<view class="cover-badge">
<text class="cover-badge-text">{{ getCardTypeLabel(c.type) }}</text>
</view>
<text class="cover-name">{{ c.name }}</text>
<view class="cover-price-row">
<text class="cover-currency">¥</text>
<text class="cover-price">{{ formatPrice(c.price) }}</text>
</view>
<text
v-if="c.originalPrice && c.originalPrice > c.price"
class="cover-original"
>
¥{{ formatPrice(c.originalPrice) }}
</text>
</view>
<view class="cover-deco cover-deco--1" />
<view class="cover-deco cover-deco--2" />
</view>
<!-- Card info aligns with card-cover height -->
@@ -461,15 +443,15 @@ onMounted(() => {
overflow: hidden;
&.hero--times {
background: linear-gradient(135deg, #1a1a2e 0%, #2d2d5e 100%);
background: linear-gradient(135deg, #E8D5C4 0%, #D4BFA8 100%);
}
&.hero--duration {
background: linear-gradient(135deg, #6c3483 0%, #9b59b6 100%);
background: linear-gradient(135deg, #D8C8DC 0%, #C4AECB 100%);
}
&.hero--trial {
background: linear-gradient(135deg, #5a7a8a 0%, $primary-dark 100%);
background: linear-gradient(135deg, #C8D8D2 0%, #A9C4BC 100%);
}
}
@@ -477,7 +459,7 @@ onMounted(() => {
.hero-deco {
position: absolute;
border-radius: 50%;
background: rgba(255, 255, 255, 0.05);
background: rgba(255, 255, 255, 0.35);
pointer-events: none;
&--1 {
@@ -499,14 +481,14 @@ onMounted(() => {
align-self: flex-start;
padding: 8rpx 22rpx;
border-radius: 20rpx;
background: rgba(255, 255, 255, 0.18);
border: 1rpx solid rgba(255, 255, 255, 0.3);
background: rgba(74, 64, 53, 0.1);
border: 1rpx solid rgba(74, 64, 53, 0.15);
z-index: 1;
}
.hero-badge-text {
font-size: 22rpx;
color: #fff;
color: $brand-color;
font-weight: 600;
letter-spacing: 1rpx;
}
@@ -514,7 +496,7 @@ onMounted(() => {
.hero-name {
font-size: 48rpx;
font-weight: 800;
color: #fff;
color: $brand-color;
letter-spacing: 1rpx;
z-index: 1;
}
@@ -529,20 +511,20 @@ onMounted(() => {
.hero-currency {
font-size: 28rpx;
font-weight: 600;
color: rgba(255, 255, 255, 0.85);
color: rgba(74, 64, 53, 0.7);
line-height: 1;
}
.hero-price {
font-size: 64rpx;
font-weight: 800;
color: #fff;
color: $brand-color;
line-height: 1;
}
.hero-original {
font-size: 28rpx;
color: rgba(255, 255, 255, 0.5);
color: rgba(74, 64, 53, 0.4);
text-decoration: line-through;
margin-left: 8rpx;
}
@@ -575,7 +557,7 @@ onMounted(() => {
.section-title {
font-size: 30rpx;
font-weight: 700;
color: #1a1a1a;
color: $text-primary;
}
/* ── Info grid card ──────────────────────────────────── */
@@ -741,239 +723,49 @@ onMounted(() => {
}
/* ══════════════════════════════════════════════════════════
CARD COVER — Horizontal premium card design
CARD COVER — Clean minimal design
══════════════════════════════════════════════════════════ */
.card-cover {
width: 240rpx;
width: 200rpx;
height: 130rpx;
border-radius: 16rpx;
overflow: hidden;
flex-shrink: 0;
position: relative;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
&::before {
content: '';
position: absolute;
top: -20rpx;
left: -20rpx;
right: -20rpx;
bottom: -20rpx;
background: inherit;
filter: blur(24rpx) brightness(0.8);
z-index: 0;
opacity: 0.4;
}
}
.cover-accent-bar {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 6rpx;
background: rgba(255, 255, 255, 0.4);
z-index: 1;
}
.cover-deco {
position: absolute;
border-radius: 50%;
z-index: 0;
pointer-events: none;
&--tl {
width: 60rpx;
height: 60rpx;
top: -16rpx;
right: 20rpx;
background: rgba(255, 255, 255, 0.1);
&--1 {
width: 100rpx;
height: 100rpx;
top: -30rpx;
right: -20rpx;
background: rgba(255, 255, 255, 0.4);
}
&--br {
width: 80rpx;
height: 80rpx;
bottom: -24rpx;
left: -16rpx;
background: rgba(255, 255, 255, 0.07);
}
}
.cover-icon {
width: 52rpx;
height: 52rpx;
position: relative;
z-index: 2;
flex-shrink: 0;
margin-left: 20rpx;
}
.cover-icon--TIMES {
&::before {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 36rpx;
height: 24rpx;
border: 2rpx solid rgba(255, 255, 255, 0.85);
border-radius: 5rpx;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.12);
}
&::after {
content: '';
position: absolute;
bottom: 10rpx;
left: 50%;
transform: translateX(-50%);
width: 36rpx;
height: 24rpx;
border: 2rpx solid rgba(255, 255, 255, 1);
border-radius: 5rpx;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.2);
}
}
.cover-icon--DURATION {
&::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 36rpx;
height: 30rpx;
border: 2rpx solid rgba(255, 255, 255, 0.9);
border-radius: 5rpx;
box-sizing: border-box;
}
&::after {
content: '';
position: absolute;
top: 9rpx;
left: 50%;
transform: translateX(-50%);
width: 24rpx;
height: 0;
border-top: 2rpx solid rgba(255, 255, 255, 1);
box-shadow:
-6rpx 5rpx 0 0 rgba(255, 255, 255, 0.9),
6rpx 5rpx 0 0 rgba(255, 255, 255, 0.9);
}
}
.cover-icon--TRIAL {
&::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 16rpx;
height: 16rpx;
border: 2rpx solid rgba(255, 255, 255, 1);
border-radius: 50%;
box-sizing: border-box;
&--2 {
width: 70rpx;
height: 70rpx;
bottom: -20rpx;
left: -10rpx;
background: rgba(255, 255, 255, 0.25);
}
&::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 2rpx;
height: 42rpx;
background: rgba(255, 255, 255, 0.8);
box-shadow:
0 -12rpx 0 0 rgba(255, 255, 255, 0.8),
0 12rpx 0 0 rgba(255, 255, 255, 0.8),
-12rpx 0 0 0 rgba(255, 255, 255, 0.8),
12rpx 0 0 0 rgba(255, 255, 255, 0.8),
-8rpx -8rpx 0 0 rgba(255, 255, 255, 0.8),
8rpx -8rpx 0 0 rgba(255, 255, 255, 0.8),
-8rpx 8rpx 0 0 rgba(255, 255, 255, 0.8),
8rpx 8rpx 0 0 rgba(255, 255, 255, 0.8);
}
}
.cover--times {
background: linear-gradient(135deg, #1e2340 0%, #2d2d5e 50%, #3a3a7a 100%);
background: linear-gradient(135deg, #E8D5C4 0%, #D4BFA8 100%);
}
.cover--duration {
background: linear-gradient(135deg, #4a1a6b 0%, #6c3483 50%, #8e4aaf 100%);
background: linear-gradient(135deg, #D8C8DC 0%, #C4AECB 100%);
}
.cover--trial {
background: linear-gradient(135deg, #14527a 0%, #1a6fa0 50%, #48a9a6 100%);
}
.cover-content {
flex: 1;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
padding: 0 16rpx 0 12rpx;
gap: 4rpx;
z-index: 2;
}
.cover-badge {
padding: 3rpx 10rpx;
border-radius: 8rpx;
background: rgba(255, 255, 255, 0.18);
border: 1rpx solid rgba(255, 255, 255, 0.28);
}
.cover-badge-text {
font-size: 16rpx;
color: rgba(255, 255, 255, 0.9);
font-weight: 600;
}
.cover-name {
font-size: 24rpx;
font-weight: 700;
color: #ffffff;
letter-spacing: 0.5rpx;
line-height: 1.2;
max-width: 130rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cover-price-row {
display: flex;
align-items: baseline;
gap: 2rpx;
}
.cover-currency {
font-size: 18rpx;
font-weight: 600;
color: rgba(255, 255, 255, 0.85);
}
.cover-price {
font-size: 28rpx;
font-weight: 800;
color: #ffffff;
line-height: 1;
}
.cover-original {
font-size: 16rpx;
color: rgba(255, 255, 255, 0.5);
text-decoration: line-through;
background: linear-gradient(135deg, #C8D8D2 0%, #A9C4BC 100%);
}
/* ── Card info — matches card-cover height ── */

View File

@@ -0,0 +1,837 @@
<template>
<view class="page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="限时秒杀" show-back />
<!-- Loading -->
<view v-if="loading" class="loading-wrap">
<view class="skeleton-hero" />
<view class="skeleton-body">
<view class="skeleton-line w80" />
<view class="skeleton-line w60" />
<view class="skeleton-line w40" />
</view>
</view>
<!-- Error -->
<view v-else-if="!detail" class="error-wrap">
<text class="error-icon"></text>
<text class="error-text">活动信息加载失败</text>
<view class="retry-btn" @tap="loadDetail">
<text class="retry-text">点击重试</text>
</view>
</view>
<template v-else>
<!-- Hero Section -->
<view class="hero" :class="heroPhaseClass">
<!-- Decorative elements -->
<view class="hero-deco hero-deco--1" />
<view class="hero-deco hero-deco--2" />
<view class="hero-deco hero-deco--3" />
<!-- Phase badge -->
<view class="hero-phase-badge" :class="phaseBadgeClass">
<text class="hero-phase-text">{{ phaseLabel }}</text>
</view>
<!-- Title -->
<text class="hero-title">{{ detail.title }}</text>
<!-- Price row -->
<view class="hero-price-row">
<text class="hero-currency">¥</text>
<text class="hero-price">{{ formatPrice(detail.flashPrice) }}</text>
<view class="hero-original-wrap">
<text class="hero-original-label">原价</text>
<text class="hero-original">¥{{ formatPrice(detail.originalPrice) }}</text>
</view>
</view>
<!-- Discount badge -->
<view class="hero-discount-badge">
<text class="hero-discount-text">立省 ¥{{ formatPrice(detail.originalPrice - detail.flashPrice) }}</text>
</view>
<!-- Countdown -->
<view
v-if="detail.phase === FlashSalePhase.UPCOMING || detail.phase === FlashSalePhase.ONGOING"
class="hero-countdown"
>
<text class="cd-label">
{{ detail.phase === FlashSalePhase.UPCOMING ? '距开始' : '距结束' }}
</text>
<view class="cd-blocks">
<text class="cd-block">{{ countdown.h }}</text>
<text class="cd-colon">:</text>
<text class="cd-block">{{ countdown.m }}</text>
<text class="cd-colon">:</text>
<text class="cd-block">{{ countdown.s }}</text>
</view>
</view>
</view>
<!-- Stock Bar -->
<view class="stock-section">
<view class="stock-info">
<text class="stock-label">抢购进度</text>
<text class="stock-count">
{{ detail.phase === FlashSalePhase.SOLD_OUT ? '已售罄' : `已抢 ${detail.soldCount}/${detail.totalStock}` }}
</text>
</view>
<view class="stock-bar">
<view
class="stock-fill"
:class="{ 'stock-fill--hot': stockRatio > 0.6 }"
:style="{ width: stockPercent }"
/>
</view>
</view>
<!-- Phone Auth Prompt -->
<view
v-if="userStore.loggedIn && !userStore.user?.phone"
class="phone-prompt-card"
>
<view class="phone-prompt-content">
<view class="phone-prompt-icon">📱</view>
<view class="phone-prompt-text">
<text class="phone-prompt-title">提前授权手机号</text>
<text class="phone-prompt-desc">授权后抢购更快也方便馆主联系您</text>
</view>
</view>
<button
class="phone-auth-btn"
open-type="getPhoneNumber"
@getphonenumber="handleGetPhone"
>
<text class="phone-auth-text">立即授权</text>
</button>
</view>
<!-- Card Info -->
<view class="detail-section">
<view class="info-card">
<view class="section-header-row">
<view class="section-dot" />
<text class="section-label">会员卡信息</text>
</view>
<view class="info-grid">
<view class="info-cell">
<text class="cell-value">{{ detail.cardType.name }}</text>
<text class="cell-label">卡种</text>
</view>
<view v-if="detail.cardType.totalTimes" class="info-cell">
<text class="cell-value">{{ detail.cardType.totalTimes }}</text>
<text class="cell-label">课时次数</text>
</view>
<view class="info-cell">
<text class="cell-value">{{ detail.cardType.durationDays }}</text>
<text class="cell-label">有效天数</text>
</view>
</view>
</view>
<!-- Description -->
<view v-if="detail.description" class="desc-card">
<view class="section-header-row">
<view class="section-dot" />
<text class="section-label">活动说明</text>
</view>
<text class="desc-content">{{ detail.description }}</text>
</view>
<!-- Purchase Notes -->
<view class="notes-card">
<view class="section-header-row">
<view class="section-dot" />
<text class="section-label">参与须知</text>
</view>
<view class="note-item">
<text class="note-dot"></text>
<text class="note-text">每位用户同一秒杀活动仅限参与一次</text>
</view>
<view class="note-item">
<text class="note-dot"></text>
<text class="note-text">购买后立即生效有效期 {{ detail.cardType.durationDays }} </text>
</view>
<view v-if="detail.cardType.totalTimes" class="note-item">
<text class="note-dot"></text>
<text class="note-text"> {{ detail.cardType.totalTimes }} 次课时可灵活预约</text>
</view>
<view class="note-item">
<text class="note-dot"></text>
<text class="note-text">需登录并授权手机号后方可参与秒杀</text>
</view>
<view class="note-item">
<text class="note-dot"></text>
<text class="note-text">建议提前完善账号信息及手机号授权方便馆主联系</text>
</view>
<view class="note-item">
<text class="note-dot"></text>
<text class="note-text">秒杀卡不可退款到期或课时用完后自动失效</text>
</view>
<view class="note-item">
<text class="note-dot"></text>
<text class="note-text">支持微信支付安全便捷</text>
</view>
<view class="note-item note-item--disclaimer">
<text class="note-text disclaimer-text">* 本活动最终解释权归普拉提馆所有</text>
</view>
</view>
</view>
<!-- Bottom Action Bar -->
<view class="bottom-bar">
<view class="bar-price-area">
<text class="bar-price-label">秒杀价</text>
<view class="bar-price-row">
<text class="bar-currency">¥</text>
<text class="bar-price">{{ formatPrice(detail.flashPrice) }}</text>
</view>
</view>
<view
class="action-btn"
:class="actionBtnClass"
@tap="handleAction"
>
<text class="action-btn-text">{{ actionBtnText }}</text>
</view>
</view>
</template>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import {
FlashSalePhase,
FlashSaleOrderStatus,
} from '@mp-pilates/shared'
import type { FlashSaleDetail } from '@mp-pilates/shared'
import { formatPrice, getFlashSalePhaseLabel, getCountdownParts, getStockRatio, getStockPercent } from '../../utils/format'
import { getSystemLayout } from '../../utils/system'
import { useUserStore } from '../../stores/user'
import { useFlashSaleStore } from '../../stores/flash-sale'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { post } from '../../utils/request'
const userStore = useUserStore()
const flashSaleStore = useFlashSaleStore()
const navBarHeight = ref('64px')
const loading = ref(false)
const buying = ref(false)
const detail = ref<FlashSaleDetail | null>(null)
const flashSaleId = ref('')
const tick = ref(0)
let timer: ReturnType<typeof setInterval> | null = null
// ─── Computed ─────────────────────────────────────────
const phaseLabel = computed(() => {
if (!detail.value) return ''
return getFlashSalePhaseLabel(detail.value.phase)
})
const heroPhaseClass = computed(() => {
if (!detail.value) return ''
if (detail.value.phase === FlashSalePhase.ONGOING) return 'hero--ongoing'
if (detail.value.phase === FlashSalePhase.UPCOMING) return 'hero--upcoming'
return 'hero--inactive'
})
const phaseBadgeClass = computed(() => {
if (!detail.value) return ''
if (detail.value.phase === FlashSalePhase.ONGOING) return 'pbadge--ongoing'
if (detail.value.phase === FlashSalePhase.UPCOMING) return 'pbadge--upcoming'
return 'pbadge--inactive'
})
const stockRatio = computed(() => {
if (!detail.value) return 0
return getStockRatio(detail.value.soldCount, detail.value.totalStock)
})
const stockPercent = computed(() => {
if (!detail.value) return '0%'
return getStockPercent(detail.value.soldCount, detail.value.totalStock)
})
const countdown = computed(() => {
void tick.value
if (!detail.value) return { h: '00', m: '00', s: '00' }
const target = detail.value.phase === FlashSalePhase.UPCOMING
? detail.value.startTime
: detail.value.endTime
return getCountdownParts(target)
})
const isDisabled = computed(() => {
if (!detail.value) return true
const d = detail.value
if (d.hasParticipated) return true
if (d.phase === FlashSalePhase.SOLD_OUT) return true
if (d.phase === FlashSalePhase.ENDED) return true
if (d.phase === FlashSalePhase.UPCOMING) return true
if (buying.value) return true
return false
})
const actionBtnText = computed(() => {
if (!detail.value) return ''
const d = detail.value
if (d.hasParticipated) {
if (d.userOrderStatus === FlashSaleOrderStatus.PAID) return '已成功抢购'
if (d.userOrderStatus === FlashSaleOrderStatus.RESERVED) return '待支付'
return '已参与'
}
if (d.phase === FlashSalePhase.SOLD_OUT) return '已售罄'
if (d.phase === FlashSalePhase.ENDED) return '活动已结束'
if (d.phase === FlashSalePhase.UPCOMING) return `距开始 ${countdown.value.h}:${countdown.value.m}:${countdown.value.s}`
if (!userStore.loggedIn) return '登录后参与'
if (!userStore.user?.phone) return '授权手机号后参与'
if (buying.value) return '抢购中...'
return `¥${formatPrice(d.flashPrice)} 立即抢购`
})
const actionBtnClass = computed(() => {
if (isDisabled.value) return 'action-btn--disabled'
return 'action-btn--active'
})
// ─── Data loading ────────────────────────────────────
async function loadDetail() {
if (!flashSaleId.value) return
loading.value = true
try {
detail.value = await flashSaleStore.fetchDetail(flashSaleId.value)
} catch {
detail.value = null
} finally {
loading.value = false
}
}
// ─── Phone auth ──────────────────────────────────────
async function handleGetPhone(e: { detail: { code?: string; errMsg?: string } }) {
if (!e.detail.code) return
try {
await post('/auth/phone', { code: e.detail.code })
await userStore.fetchProfile()
uni.showToast({ title: '授权成功', icon: 'success' })
} catch {
uni.showToast({ title: '授权失败,请重试', icon: 'none' })
}
}
// ─── Action handler ──────────────────────────────────
async function handleAction() {
if (!detail.value || isDisabled.value) return
// Check login
if (!userStore.loggedIn) {
uni.showModal({
title: '提示',
content: '请先登录后再参与秒杀',
confirmText: '去登录',
success: async (res) => {
if (res.confirm) {
try {
const { isNewUser } = await userStore.loginWithSetup()
if (!isNewUser) {
await loadDetail() // refresh participation status
}
} catch {
uni.showToast({ title: '登录失败', icon: 'none' })
}
}
},
})
return
}
// Check phone
if (!userStore.user?.phone) {
uni.showToast({ title: '请先授权手机号', icon: 'none' })
return
}
// Confirm purchase
uni.showModal({
title: '确认抢购',
content: `确认以 ¥${formatPrice(detail.value.flashPrice)} 抢购「${detail.value.title}」?`,
confirmText: '确认抢购',
success: async (res) => {
if (res.confirm) {
await doPurchase()
}
},
})
}
async function doPurchase() {
if (!detail.value || buying.value) return
buying.value = true
uni.showLoading({ title: '抢购中...' })
try {
const result = await flashSaleStore.purchase(detail.value.id)
uni.hideLoading()
// Launch WeChat Pay
await new Promise<void>((resolve, reject) => {
uni.requestPayment({
provider: 'wxpay',
timeStamp: result.paymentParams.timeStamp,
nonceStr: result.paymentParams.nonceStr,
package: result.paymentParams.package,
signType: result.paymentParams.signType as 'MD5' | 'HMAC-SHA256',
paySign: result.paymentParams.paySign,
success: () => resolve(),
fail: (err: { errMsg?: string }) => reject(new Error(err.errMsg ?? '支付取消')),
})
})
uni.showToast({ title: '抢购成功!', icon: 'success' })
await userStore.fetchMemberships()
await loadDetail() // refresh status
setTimeout(() => {
uni.navigateTo({ url: '/pages/profile/membership' })
}, 1500)
} catch (err: unknown) {
uni.hideLoading()
const msg = err instanceof Error ? err.message : '抢购失败'
if (!msg.includes('取消') && !msg.includes('cancel')) {
uni.showToast({ title: msg, icon: 'none', duration: 3000 })
}
// Refresh detail to show updated status
await loadDetail()
} finally {
buying.value = false
}
}
// ─── Lifecycle ───────────────────────────────────────
onMounted(() => {
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
const pages = getCurrentPages()
const current = pages[pages.length - 1]
const options = (current as { options?: Record<string, string> }).options ?? {}
flashSaleId.value = options.id ?? ''
loadDetail()
timer = setInterval(() => { tick.value++ }, 1000)
})
onUnmounted(() => {
if (timer) {
clearInterval(timer)
timer = null
}
})
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background: $bg-page;
padding-bottom: calc(180rpx + env(safe-area-inset-bottom));
}
/* ── Loading ────────────────────────────── */
.loading-wrap { padding: 0; }
.skeleton-hero {
height: 420rpx;
background: linear-gradient(90deg, #ede8e3 25%, #e4dfd9 50%, #ede8e3 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
}
.skeleton-body { padding: 32rpx 24rpx; display: flex; flex-direction: column; gap: 20rpx; }
.skeleton-line {
height: 28rpx;
border-radius: 14rpx;
background: linear-gradient(90deg, #f0ece8 25%, #e8e4df 50%, #f0ece8 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
&.w80 { width: 80%; }
&.w60 { width: 60%; }
&.w40 { width: 40%; }
}
/* ── Error ───────────────────────────────── */
.error-wrap {
display: flex;
flex-direction: column;
align-items: center;
padding: 160rpx 40rpx;
gap: 24rpx;
}
.error-icon { font-size: 80rpx; }
.error-text { font-size: 30rpx; color: $text-hint; }
.retry-btn {
padding: 20rpx 48rpx;
border-radius: 40rpx;
background: linear-gradient(135deg, #D4A59A, #C08B7E);
}
.retry-text { font-size: 28rpx; color: #fff; font-weight: 600; }
/* ═══════════════════════════════════════════
HERO — warm blush tones
═══════════════════════════════════════════ */
.hero {
padding: 56rpx 36rpx 48rpx;
display: flex;
flex-direction: column;
gap: 16rpx;
position: relative;
overflow: hidden;
}
.hero--ongoing {
background: linear-gradient(135deg, #D4A59A 0%, #C9948A 35%, #B5836E 100%);
}
.hero--upcoming {
background: linear-gradient(135deg, #8FA89A 0%, #7BA5A0 100%);
}
.hero--inactive {
background: linear-gradient(135deg, #C4BAB0 0%, #AEA49A 100%);
}
.hero-deco {
position: absolute;
border-radius: 50%;
background: rgba(255, 255, 255, 0.08);
pointer-events: none;
&--1 { width: 300rpx; height: 300rpx; top: -60rpx; right: -40rpx; }
&--2 { width: 200rpx; height: 200rpx; bottom: -60rpx; left: 30rpx; }
&--3 { width: 120rpx; height: 120rpx; top: 40rpx; left: -30rpx; background: rgba(255, 255, 255, 0.05); }
}
.hero-phase-badge {
align-self: flex-start;
padding: 8rpx 24rpx;
border-radius: 24rpx;
z-index: 1;
}
.pbadge--ongoing { background: rgba(255, 255, 255, 0.3); }
.pbadge--upcoming { background: rgba(255, 255, 255, 0.25); }
.pbadge--inactive { background: rgba(0, 0, 0, 0.12); }
.hero-phase-text { font-size: 24rpx; color: #fff; font-weight: 600; letter-spacing: 1rpx; }
.hero-title {
font-size: 44rpx;
font-weight: 800;
color: #fff;
z-index: 1;
line-height: 1.2;
}
.hero-price-row {
display: flex;
align-items: baseline;
gap: 4rpx;
z-index: 1;
}
.hero-currency { font-size: 30rpx; font-weight: 700; color: rgba(255, 255, 255, 0.9); }
.hero-price { font-size: 72rpx; font-weight: 800; color: #fff; line-height: 1; }
.hero-original-wrap {
display: flex;
flex-direction: column;
margin-left: 16rpx;
}
.hero-original-label { font-size: 18rpx; color: rgba(255, 255, 255, 0.65); }
.hero-original { font-size: 26rpx; color: rgba(255, 255, 255, 0.55); text-decoration: line-through; }
.hero-discount-badge {
align-self: flex-start;
padding: 6rpx 20rpx;
border-radius: 16rpx;
background: rgba(255, 255, 255, 0.22);
border: 1rpx solid rgba(255, 255, 255, 0.35);
z-index: 1;
}
.hero-discount-text { font-size: 22rpx; color: #fff; font-weight: 600; }
/* Countdown */
.hero-countdown {
display: flex;
align-items: center;
gap: 12rpx;
margin-top: 8rpx;
z-index: 1;
}
.cd-label { font-size: 24rpx; color: rgba(255, 255, 255, 0.85); }
.cd-blocks { display: flex; align-items: center; gap: 6rpx; }
.cd-block {
background: rgba(255, 255, 255, 0.25);
color: #fff;
font-size: 28rpx;
font-weight: 700;
padding: 8rpx 14rpx;
border-radius: 8rpx;
font-family: 'DIN Alternate', monospace;
min-width: 48rpx;
text-align: center;
backdrop-filter: blur(4px);
}
.cd-colon { color: #fff; font-size: 28rpx; font-weight: 700; }
/* ═══════════════════════════════════════════
STOCK
═══════════════════════════════════════════ */
.stock-section {
margin: 0 24rpx;
background: #fff;
border-radius: 20rpx;
padding: 24rpx;
margin-top: -20rpx;
position: relative;
z-index: 2;
box-shadow: 0 4rpx 20rpx rgba(180, 160, 130, 0.1);
}
.stock-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12rpx;
}
.stock-label { font-size: 26rpx; color: $text-secondary; font-weight: 600; }
.stock-count { font-size: 24rpx; color: #B5725E; font-weight: 600; }
.stock-bar {
height: 16rpx;
background: #f5f0ed;
border-radius: 8rpx;
overflow: hidden;
}
.stock-fill {
height: 100%;
background: linear-gradient(90deg, #D4A59A, #C08B7E);
border-radius: 8rpx;
transition: width 0.3s;
&--hot { animation: stockPulse 2s ease infinite; }
}
@keyframes stockPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
/* ═══════════════════════════════════════════
PHONE PROMPT
═══════════════════════════════════════════ */
.phone-prompt-card {
margin: 20rpx 24rpx 0;
background: linear-gradient(135deg, #FBF5F3, #F5ECEA);
border-radius: 20rpx;
padding: 24rpx;
display: flex;
align-items: center;
justify-content: space-between;
border: 1rpx solid rgba(192, 139, 126, 0.2);
}
.phone-prompt-content {
display: flex;
align-items: center;
gap: 16rpx;
flex: 1;
}
.phone-prompt-icon { font-size: 40rpx; }
.phone-prompt-text {
display: flex;
flex-direction: column;
gap: 4rpx;
}
.phone-prompt-title { font-size: 26rpx; font-weight: 700; color: #B5725E; }
.phone-prompt-desc { font-size: 22rpx; color: $text-hint; }
.phone-auth-btn {
background: linear-gradient(135deg, #D4A59A, #C08B7E) !important;
border-radius: 32rpx !important;
padding: 12rpx 28rpx !important;
border: none !important;
line-height: 1.4 !important;
font-size: 24rpx !important;
margin: 0 !important;
flex-shrink: 0;
&::after { border: none; }
}
.phone-auth-text { font-size: 24rpx; color: #fff; font-weight: 600; }
/* ═══════════════════════════════════════════
DETAIL SECTION
═══════════════════════════════════════════ */
.detail-section {
padding: 20rpx 24rpx 0;
display: flex;
flex-direction: column;
gap: 20rpx;
}
.section-header-row {
display: flex;
align-items: center;
gap: 12rpx;
margin-bottom: 16rpx;
}
.section-dot {
width: 6rpx;
height: 28rpx;
border-radius: 3rpx;
background: #C08B7E;
flex-shrink: 0;
}
.section-label { font-size: 30rpx; font-weight: 700; color: $text-primary; }
/* Info card */
.info-card {
background: #fff;
border-radius: 20rpx;
padding: 28rpx 24rpx;
box-shadow: 0 2rpx 12rpx rgba(180, 160, 130, 0.08);
}
.info-grid {
display: flex;
justify-content: space-around;
align-items: center;
}
.info-cell {
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
flex: 1;
position: relative;
& + & { border-left: 1rpx solid #f0ece8; }
}
.cell-value { font-size: 36rpx; font-weight: 800; color: $text-primary; line-height: 1.1; }
.cell-label { font-size: 22rpx; color: $text-hint; }
/* Description */
.desc-card {
background: #fff;
border-radius: 20rpx;
padding: 28rpx 24rpx;
box-shadow: 0 2rpx 12rpx rgba(180, 160, 130, 0.08);
}
.desc-content { font-size: 27rpx; color: $text-secondary; line-height: 1.75; }
/* Notes */
.notes-card {
background: #fff;
border-radius: 20rpx;
padding: 28rpx 24rpx;
box-shadow: 0 2rpx 12rpx rgba(180, 160, 130, 0.08);
}
.note-item {
display: flex;
align-items: flex-start;
gap: 12rpx;
padding: 6rpx 0;
}
.note-dot { font-size: 26rpx; color: #C08B7E; line-height: 1.65; flex-shrink: 0; }
.note-text { font-size: 26rpx; color: $text-secondary; line-height: 1.65; }
.note-item--disclaimer { margin-top: 12rpx; padding-top: 16rpx; border-top: 1rpx solid #f0ece8; }
.disclaimer-text { color: #bbb; font-size: 22rpx; }
/* ═══════════════════════════════════════════
BOTTOM BAR
═══════════════════════════════════════════ */
.bottom-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: #fff;
border-top: 1rpx solid #f0ece8;
padding: 20rpx 32rpx calc(20rpx + env(safe-area-inset-bottom));
display: flex;
align-items: center;
gap: 24rpx;
box-shadow: 0 -4rpx 20rpx rgba(180, 160, 130, 0.08);
z-index: 100;
}
.bar-price-area {
display: flex;
flex-direction: column;
gap: 2rpx;
}
.bar-price-label { font-size: 20rpx; color: $text-hint; }
.bar-price-row { display: flex; align-items: baseline; }
.bar-currency { font-size: 24rpx; font-weight: 700; color: #B5725E; }
.bar-price { font-size: 44rpx; font-weight: 800; color: #B5725E; line-height: 1; }
.action-btn {
flex: 1;
height: 88rpx;
border-radius: 44rpx;
display: flex;
align-items: center;
justify-content: center;
}
.action-btn--active {
background: linear-gradient(90deg, #D4A59A, #B5836E);
box-shadow: 0 4rpx 16rpx rgba(192, 139, 126, 0.35);
&:active { opacity: 0.85; }
}
.action-btn--disabled {
background: #d0cac4;
}
.action-btn-text {
font-size: 30rpx;
font-weight: 700;
color: #fff;
letter-spacing: 1rpx;
}
</style>

View File

@@ -35,6 +35,9 @@
<!-- Upcoming Bookings -->
<UpcomingBooking />
<!-- .5 Flash Sale Section -->
<FlashSaleSection ref="flashSaleRef" />
<!-- Card Shop (vertical list) -->
<view :id="cardShopAnchorId">
<CardShop ref="cardShopRef" />
@@ -55,6 +58,7 @@ import BrandBanner from '../../components/BrandBanner.vue'
import StudioInfo from '../../components/StudioInfo.vue'
import QuickEntry from '../../components/QuickEntry.vue'
import UpcomingBooking from '../../components/UpcomingBooking.vue'
import FlashSaleSection from '../../components/FlashSaleSection.vue'
import CardShop from '../../components/CardShop.vue'
import { useUserStore } from '../../stores/user'
@@ -84,6 +88,7 @@ onShareTimeline(() => {
// ─── Layout ───────────────────────────────────────────────
const refreshing = ref(false)
const cardShopRef = ref<InstanceType<typeof CardShop> | null>(null)
const flashSaleRef = ref<InstanceType<typeof FlashSaleSection> | null>(null)
const cardShopAnchorId = 'card-shop-anchor'
const scrollTop = ref(0)
const pendingScrollToCardShop = ref(false)
@@ -121,8 +126,9 @@ async function refreshData() {
await Promise.allSettled(tasks)
// Also refresh card shop
// Also refresh card shop and flash sales
cardShopRef.value?.fetchCardTypes()
flashSaleRef.value?.fetchFlashSales()
}
async function handleRefresh() {

View File

@@ -1,7 +1,6 @@
<template>
<view class="membership-page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="我的会员卡" show-back />
<!-- Pull-to-refresh scroll view -->
<scroll-view
class="scroll"
scroll-y
@@ -16,11 +15,14 @@
<!-- Empty state -->
<view v-else-if="allMemberships.length === 0" class="empty-wrap">
<text class="empty-icon">💳</text>
<text class="empty-title">暂无会员卡</text>
<text class="empty-sub">购买会员卡后即可预约课程</text>
<view class="empty-btn" @tap="goStore">
<text class="empty-btn-text">购买</text>
<view class="empty-card">
<view class="empty-deco empty-deco--1" />
<view class="empty-deco empty-deco--2" />
<text class="empty-title">还没有会员卡</text>
<text class="empty-sub">购买会员卡后即可预约课程</text>
<view class="empty-btn" @tap="goStore">
<text class="empty-btn-text">去选购</text>
</view>
</view>
</view>
@@ -29,7 +31,6 @@
<!-- Active cards -->
<view v-if="activeMemberships.length > 0" class="group-section">
<view class="group-header">
<view class="group-dot group-dot--active" />
<text class="group-title">有效会员卡</text>
<text class="group-count">{{ activeMemberships.length }} </text>
</view>
@@ -37,56 +38,60 @@
<view
v-for="m in activeMemberships"
:key="m.id"
class="card-item"
class="mc"
:class="cardBgClass(m.cardType.type)"
>
<!-- Colored left border strip -->
<view class="card-strip" :class="stripClass(m.cardType.type)" />
<!-- Decorative circles -->
<view class="mc-deco mc-deco--1" />
<view class="mc-deco mc-deco--2" />
<!-- Card header (colored gradient) -->
<view class="card-header" :class="headerClass(m.cardType.type)">
<view class="card-header-left">
<text class="card-name">{{ m.cardType.name }}</text>
<view class="card-type-badge">
<text class="card-type-badge-text">{{ getCardTypeLabel(m.cardType.type) }}</text>
<!-- Top row: name + status -->
<view class="mc-top">
<view class="mc-name-area">
<text class="mc-name">{{ m.cardType.name }}</text>
<view class="mc-type-tag">
<text class="mc-type-text">{{ getCardTypeLabel(m.cardType.type) }}</text>
</view>
</view>
<view class="status-badge status-badge--active">
<text class="status-badge-text">有效</text>
<view class="mc-status mc-status--active">
<view class="mc-status-dot" />
<text class="mc-status-text">有效</text>
</view>
</view>
<!-- Card body -->
<view class="card-body">
<!-- Times card: remaining times + progress -->
<template v-if="m.remainingTimes !== null">
<view class="highlight-row">
<text class="highlight-label">剩余课时</text>
<text class="highlight-value">
<text class="highlight-number">{{ m.remainingTimes }}</text>
<text class="highlight-unit"> </text>
</text>
<!-- Center: highlight number (times card) -->
<view v-if="m.remainingTimes !== null" class="mc-center">
<text class="mc-big-num">{{ m.remainingTimes }}</text>
<text class="mc-big-unit">次剩余</text>
<view v-if="m.cardType.totalTimes" class="mc-progress">
<view class="mc-progress-track">
<view
class="mc-progress-fill"
:style="{ width: getMembershipProgressWidth(m) }"
/>
</view>
<view v-if="m.cardType.totalTimes" class="progress-wrap">
<view class="progress-bar">
<view
class="progress-fill"
:style="{ width: getMembershipProgressWidth(m) }"
/>
</view>
<text class="progress-label">
已使用 {{ getMembershipUsedTimes(m) }} / {{ m.cardType.totalTimes }}
</text>
</view>
</template>
<!-- Duration card: expiry -->
<view class="info-row">
<text class="info-label">有效期至</text>
<text class="info-value">{{ m.expireDate.slice(0, 10) }}</text>
<text class="mc-progress-label">
已用 {{ getMembershipUsedTimes(m) }} {{ m.cardType.totalTimes }}
</text>
</view>
<view class="info-row">
<text class="info-label">开始日期</text>
<text class="info-value">{{ m.startDate.slice(0, 10) }}</text>
</view>
<!-- Center: duration card (no times) -->
<view v-else class="mc-center">
<text class="mc-big-num">{{ daysRemaining(m) }}</text>
<text class="mc-big-unit">天剩余</text>
</view>
<!-- Bottom: dates -->
<view class="mc-bottom">
<view class="mc-date-item">
<text class="mc-date-label">开始</text>
<text class="mc-date-value">{{ m.startDate.slice(0, 10) }}</text>
</view>
<view class="mc-date-sep" />
<view class="mc-date-item">
<text class="mc-date-label">到期</text>
<text class="mc-date-value">{{ m.expireDate.slice(0, 10) }}</text>
</view>
</view>
</view>
@@ -95,7 +100,6 @@
<!-- Expired / used up cards -->
<view v-if="inactiveMemberships.length > 0" class="group-section">
<view class="group-header">
<view class="group-dot group-dot--inactive" />
<text class="group-title">历史记录</text>
<text class="group-count">{{ inactiveMemberships.length }} </text>
</view>
@@ -103,28 +107,30 @@
<view
v-for="m in inactiveMemberships"
:key="m.id"
class="card-item card-item--inactive"
class="mc mc--inactive"
>
<view class="card-strip card-strip--inactive" />
<view class="card-header card-header--inactive">
<view class="card-header-left">
<text class="card-name card-name--dim">{{ m.cardType.name }}</text>
<view class="card-type-badge card-type-badge--dim">
<text class="card-type-badge-text">{{ getCardTypeLabel(m.cardType.type) }}</text>
<view class="mc-deco mc-deco--1" />
<view class="mc-top">
<view class="mc-name-area">
<text class="mc-name">{{ m.cardType.name }}</text>
<view class="mc-type-tag">
<text class="mc-type-text">{{ getCardTypeLabel(m.cardType.type) }}</text>
</view>
</view>
<view class="status-badge" :class="statusBadgeClass(m.status)">
<text class="status-badge-text">{{ statusLabel(m.status) }}</text>
<view class="mc-status" :class="inactiveStatusClass(m.status)">
<text class="mc-status-text">{{ statusLabel(m.status) }}</text>
</view>
</view>
<view class="card-body">
<view v-if="m.remainingTimes !== null" class="info-row">
<text class="info-label">剩余课时</text>
<text class="info-value">{{ m.remainingTimes }} </text>
<view class="mc-inactive-info">
<view v-if="m.remainingTimes !== null" class="mc-date-item">
<text class="mc-date-label">剩余</text>
<text class="mc-date-value">{{ m.remainingTimes }} </text>
</view>
<view class="info-row">
<text class="info-label">有效期至</text>
<text class="info-value">{{ m.expireDate.slice(0, 10) }}</text>
<view class="mc-date-item">
<text class="mc-date-label">有效期至</text>
<text class="mc-date-value">{{ m.expireDate.slice(0, 10) }}</text>
</view>
</view>
</view>
@@ -136,8 +142,7 @@
<!-- Buy more FAB -->
<view class="fab" @tap="goStore">
<text class="fab-icon">+</text>
<text class="fab-text">购买会员卡</text>
<text class="fab-text">+ 购买会员卡</text>
</view>
</view>
</template>
@@ -153,13 +158,10 @@ import CustomNavBar from '../../components/CustomNavBar.vue'
const userStore = useUserStore()
// ─── Nav bar height ──────────────────────────────────────
const navBarHeight = ref('64px')
// ─── State ────────────────────────────────────────────────
const loading = ref(false)
const refreshing = ref(false)
// ─── Computed from store ───────────────────────────────────
const allMemberships = computed(() => userStore.memberships as MembershipWithCardType[])
const activeMemberships = computed(() =>
@@ -170,8 +172,6 @@ const inactiveMemberships = computed(() =>
allMemberships.value.filter((m) => m.status !== MembershipStatus.ACTIVE),
)
// ─── Helpers ──────────────────────────────────────────────
function statusLabel(status: MembershipStatus): string {
const map: Record<MembershipStatus, string> = {
[MembershipStatus.ACTIVE]: '有效',
@@ -181,25 +181,22 @@ function statusLabel(status: MembershipStatus): string {
return map[status] ?? status
}
function statusBadgeClass(status: MembershipStatus): string {
if (status === MembershipStatus.EXPIRED) return 'status-badge--expired'
if (status === MembershipStatus.USED_UP) return 'status-badge--used'
return 'status-badge--expired'
function inactiveStatusClass(status: MembershipStatus): string {
if (status === MembershipStatus.USED_UP) return 'mc-status--used'
return 'mc-status--expired'
}
function stripClass(type: CardTypeCategory): string {
if (type === CardTypeCategory.TRIAL) return 'card-strip--trial'
if (type === CardTypeCategory.DURATION) return 'card-strip--duration'
return 'card-strip--times'
function cardBgClass(type: CardTypeCategory): string {
if (type === CardTypeCategory.TRIAL) return 'mc--trial'
if (type === CardTypeCategory.DURATION) return 'mc--duration'
return 'mc--times'
}
function headerClass(type: CardTypeCategory): string {
if (type === CardTypeCategory.TRIAL) return 'card-header--trial'
if (type === CardTypeCategory.DURATION) return 'card-header--duration'
return 'card-header--times'
function daysRemaining(m: MembershipWithCardType): number {
const diff = new Date(m.expireDate).getTime() - Date.now()
return Math.max(0, Math.ceil(diff / 86_400_000))
}
// ─── Data loading ─────────────────────────────────────────
async function loadMemberships() {
loading.value = true
try {
@@ -221,7 +218,6 @@ function goStore() {
uni.switchTab({ url: '/pages/home/index' })
}
// ─── Lifecycle ────────────────────────────────────────────
onMounted(() => {
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
loadMemberships()
@@ -231,313 +227,373 @@ onMounted(() => {
<style lang="scss" scoped>
.membership-page {
min-height: 100vh;
background: #f5f3f0;
background: $bg-page;
}
.scroll {
height: 100vh;
}
/* ── Loading ─────────────────────────────────────────── */
/* ── Loading ─────────────────────────────── */
.loading-wrap {
padding: 24rpx;
display: flex;
flex-direction: column;
gap: 20rpx;
gap: 24rpx;
}
.skeleton-card {
height: 220rpx;
border-radius: 20rpx;
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
height: 320rpx;
border-radius: 24rpx;
background: linear-gradient(90deg, #f0ece8 25%, #e8e4df 50%, #f0ece8 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
}
/* ── Empty ───────────────────────────────────────────── */
/* ── Empty ────────────────────────────────── */
.empty-wrap {
padding: 80rpx 24rpx;
}
.empty-card {
position: relative;
overflow: hidden;
background: linear-gradient(135deg, #E8D5C4, #D8C8DC);
border-radius: 24rpx;
padding: 64rpx 40rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 120rpx 40rpx;
gap: 20rpx;
gap: 16rpx;
}
.empty-icon {
font-size: 80rpx;
.empty-deco {
position: absolute;
border-radius: 50%;
pointer-events: none;
&--1 {
width: 200rpx;
height: 200rpx;
top: -60rpx;
right: -40rpx;
background: rgba(255, 255, 255, 0.3);
}
&--2 {
width: 140rpx;
height: 140rpx;
bottom: -40rpx;
left: -20rpx;
background: rgba(255, 255, 255, 0.2);
}
}
.empty-title {
font-size: 32rpx;
font-weight: 600;
color: #333;
font-size: 34rpx;
font-weight: 700;
color: $brand-color;
z-index: 1;
}
.empty-sub {
font-size: 26rpx;
color: #999;
color: $text-secondary;
z-index: 1;
}
.empty-btn {
margin-top: 12rpx;
padding: 22rpx 60rpx;
border-radius: 44rpx;
background: $primary-dark;
box-shadow: 0 4rpx 16rpx rgba(201, 168, 124, 0.35);
margin-top: 16rpx;
padding: 20rpx 56rpx;
border-radius: 40rpx;
background: rgba(74, 64, 53, 0.12);
z-index: 1;
&:active { background: rgba(74, 64, 53, 0.18); }
}
.empty-btn-text {
font-size: 30rpx;
color: #fff;
font-size: 28rpx;
color: $brand-color;
font-weight: 600;
}
/* ── List ────────────────────────────────────────────── */
/* ── List ─────────────────────────────────── */
.list {
padding: 24rpx 24rpx 0;
padding: 16rpx 24rpx 0;
}
/* ── Group section ───────────────────────────────────── */
/* ── Group ────────────────────────────────── */
.group-section {
margin-bottom: 8rpx;
margin-bottom: 16rpx;
}
.group-header {
display: flex;
flex-direction: row;
align-items: center;
gap: 10rpx;
padding: 8rpx 4rpx 14rpx;
}
.group-dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
flex-shrink: 0;
&--active { background: #4caf50; }
&--inactive { background: #bbb; }
justify-content: space-between;
padding: 12rpx 8rpx 16rpx;
}
.group-title {
font-size: 26rpx;
color: #555;
font-size: 28rpx;
color: $text-primary;
font-weight: 600;
flex: 1;
}
.group-count {
font-size: 22rpx;
color: #bbb;
color: $text-hint;
}
/* ── Card item ───────────────────────────────────────── */
.card-item {
background: #fff;
border-radius: 20rpx;
/* ══════════════════════════════════════════════
MEMBERSHIP CARD (mc)
══════════════════════════════════════════════ */
.mc {
position: relative;
overflow: hidden;
margin-bottom: 16rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.07);
border-radius: 24rpx;
padding: 28rpx 32rpx;
margin-bottom: 20rpx;
display: flex;
flex-direction: column;
gap: 24rpx;
}
&--inactive {
opacity: 0.72;
/* Card type backgrounds */
.mc--times {
background: linear-gradient(135deg, #EDE0D4 0%, #E2D2C2 100%);
box-shadow: 0 4rpx 20rpx rgba(212, 191, 168, 0.3);
}
.mc--duration {
background: linear-gradient(135deg, #E0D4E4 0%, #D4C6DA 100%);
box-shadow: 0 4rpx 20rpx rgba(196, 174, 203, 0.3);
}
.mc--trial {
background: linear-gradient(135deg, #D4E2DC 0%, #C6D8D0 100%);
box-shadow: 0 4rpx 20rpx rgba(169, 196, 188, 0.3);
}
.mc--inactive {
background: linear-gradient(135deg, #E8E4E0, #DDD9D5);
box-shadow: 0 2rpx 12rpx rgba(180, 160, 130, 0.12);
opacity: 0.75;
gap: 16rpx;
}
/* Decorative circles */
.mc-deco {
position: absolute;
border-radius: 50%;
pointer-events: none;
&--1 {
width: 180rpx;
height: 180rpx;
top: -50rpx;
right: -30rpx;
background: rgba(255, 255, 255, 0.3);
}
&--2 {
width: 120rpx;
height: 120rpx;
bottom: -30rpx;
left: 40rpx;
background: rgba(255, 255, 255, 0.2);
}
}
/* Colored left border strip */
.card-strip {
height: 6rpx;
&--times { background: linear-gradient(90deg, #1a1a2e, #2d2d5e); }
&--duration { background: linear-gradient(90deg, #6c3483, #9b59b6); }
&--trial { background: linear-gradient(90deg, #5a7a8a, $primary-dark); }
&--inactive { background: #ccc; }
}
/* Card header gradient area */
.card-header {
padding: 22rpx 28rpx;
/* ── Top row ──────────────────────────────── */
.mc-top {
display: flex;
flex-direction: row;
align-items: center;
align-items: flex-start;
justify-content: space-between;
&--times { background: linear-gradient(90deg, #1a1a2e, #2d2d5e); }
&--duration { background: linear-gradient(90deg, #6c3483, #9b59b6); }
&--trial { background: linear-gradient(90deg, #5a7a8a, $primary-dark); }
&--inactive { background: #888; }
z-index: 1;
}
.card-header-left {
.mc-name-area {
display: flex;
flex-direction: column;
gap: 8rpx;
}
.card-name {
font-size: 32rpx;
.mc-name {
font-size: 34rpx;
font-weight: 700;
color: #fff;
&--dim { color: #ddd; }
color: #2C2420;
line-height: 1.2;
}
.card-type-badge {
.mc-type-tag {
align-self: flex-start;
padding: 4rpx 14rpx;
border-radius: 12rpx;
background: rgba(255, 255, 255, 0.15);
border: 1rpx solid rgba(255, 255, 255, 0.25);
&--dim {
background: rgba(255, 255, 255, 0.08);
}
border-radius: 10rpx;
background: rgba(44, 36, 32, 0.1);
}
.card-type-badge-text {
.mc-type-text {
font-size: 20rpx;
color: rgba(255, 255, 255, 0.85);
color: rgba(44, 36, 32, 0.6);
font-weight: 500;
}
/* Status badge */
.status-badge {
padding: 8rpx 20rpx;
border-radius: 20rpx;
border: 1rpx solid rgba(255, 255, 255, 0.35);
/* Status */
.mc-status {
display: flex;
align-items: center;
gap: 8rpx;
padding: 6rpx 16rpx;
border-radius: 16rpx;
flex-shrink: 0;
&--active { background: rgba(76, 175, 80, 0.3); }
&--expired { background: rgba(0, 0, 0, 0.2); }
&--used { background: rgba(0, 0, 0, 0.2); }
}
.status-badge-text {
.mc-status--active {
background: rgba(122, 158, 126, 0.18);
}
.mc-status--expired,
.mc-status--used {
background: rgba(74, 64, 53, 0.08);
}
.mc-status-dot {
width: 10rpx;
height: 10rpx;
border-radius: 50%;
background: $success-color;
}
.mc-status-text {
font-size: 22rpx;
color: #fff;
color: #2C2420;
font-weight: 600;
}
/* Card body */
.card-body {
padding: 20rpx 28rpx 24rpx;
/* ── Center: big number ───────────────────── */
.mc-center {
display: flex;
flex-direction: column;
gap: 10rpx;
}
.highlight-row {
display: flex;
flex-direction: row;
align-items: baseline;
justify-content: space-between;
margin-bottom: 4rpx;
}
.highlight-label {
font-size: 26rpx;
color: #999;
}
.highlight-number {
font-size: 44rpx;
font-weight: 800;
color: $primary-dark;
line-height: 1;
}
.highlight-unit {
font-size: 22rpx;
color: $primary-dark;
font-weight: 500;
}
.info-row {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 8rpx 0;
z-index: 1;
}
.info-label {
font-size: 26rpx;
color: #999;
.mc-big-num {
font-size: 80rpx;
font-weight: 800;
color: #2C2420;
line-height: 1;
font-family: 'DIN Alternate', 'Helvetica Neue', Arial, sans-serif;
}
.info-value {
font-size: 26rpx;
color: #333;
.mc-big-unit {
font-size: 24rpx;
color: rgba(44, 36, 32, 0.55);
font-weight: 500;
margin-top: 4rpx;
}
/* ── Progress bar ────────────────────────────────────── */
.progress-wrap {
/* Progress */
.mc-progress {
width: 100%;
max-width: 400rpx;
margin-top: 20rpx;
display: flex;
flex-direction: column;
gap: 8rpx;
margin-bottom: 6rpx;
}
.progress-bar {
height: 8rpx;
background: #f0f0f0;
border-radius: 4rpx;
.mc-progress-track {
height: 10rpx;
background: rgba(44, 36, 32, 0.1);
border-radius: 5rpx;
overflow: hidden;
}
.progress-fill {
.mc-progress-fill {
height: 100%;
background: linear-gradient(90deg, $primary-dark, $primary-color);
border-radius: 4rpx;
background: rgba(44, 36, 32, 0.35);
border-radius: 5rpx;
transition: width 0.4s ease;
}
.progress-label {
font-size: 22rpx;
color: #bbb;
text-align: right;
.mc-progress-label {
font-size: 20rpx;
color: rgba(44, 36, 32, 0.45);
text-align: center;
}
/* ── FAB ─────────────────────────────────────────────── */
/* ── Bottom: dates ────────────────────────── */
.mc-bottom {
display: flex;
align-items: center;
justify-content: center;
gap: 0;
z-index: 1;
padding-top: 4rpx;
border-top: 1rpx solid rgba(44, 36, 32, 0.1);
}
.mc-date-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 4rpx;
}
.mc-date-sep {
width: 1rpx;
height: 40rpx;
background: rgba(44, 36, 32, 0.12);
flex-shrink: 0;
}
.mc-date-label {
font-size: 20rpx;
color: rgba(44, 36, 32, 0.4);
}
.mc-date-value {
font-size: 24rpx;
color: #2C2420;
font-weight: 600;
}
/* ── Inactive info ────────────────────────── */
.mc-inactive-info {
display: flex;
gap: 40rpx;
padding-left: 4rpx;
z-index: 1;
}
/* ── FAB ──────────────────────────────────── */
.fab {
position: fixed;
bottom: calc(32rpx + env(safe-area-inset-bottom));
right: 32rpx;
background: #1a1a2e;
background: $brand-color;
border-radius: 44rpx;
padding: 22rpx 36rpx;
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.2);
box-shadow: 0 6rpx 24rpx rgba(74, 64, 53, 0.25);
z-index: 100;
display: flex;
flex-direction: row;
align-items: center;
gap: 8rpx;
&:active {
opacity: 0.85;
}
}
.fab-icon {
font-size: 36rpx;
color: $primary-dark;
font-weight: 300;
line-height: 1;
&:active { opacity: 0.85; }
}
.fab-text {
font-size: 28rpx;
font-weight: 700;
color: $primary-dark;
font-weight: 600;
color: #fff;
letter-spacing: 1rpx;
}
/* ── Spacer ──────────────────────────────────────────── */
/* ── Spacer ───────────────────────────────── */
.scroll-bottom-spacer {
height: 120rpx;
height: 140rpx;
}
</style>

View File

@@ -15,6 +15,9 @@ import type {
PaginatedData,
ScheduleSlotPreview,
PublishDaySlotsDto,
FlashSaleAdminItem,
CreateFlashSaleDto,
UpdateFlashSaleDto,
} from '@mp-pilates/shared'
export interface AdminStats {
@@ -206,6 +209,26 @@ export const useAdminStore = defineStore('admin', () => {
return get<AdminStats>('/admin/stats')
}
// ── Flash sales ─────────────────────────────────────────────────
async function fetchFlashSales(params?: {
page?: number
limit?: number
}): Promise<PaginatedData<FlashSaleAdminItem>> {
return get<PaginatedData<FlashSaleAdminItem>>('/admin/flash-sales', params as Record<string, unknown>)
}
async function createFlashSale(dto: CreateFlashSaleDto): Promise<FlashSaleAdminItem> {
return post<FlashSaleAdminItem>('/admin/flash-sales', dto as unknown as Record<string, unknown>)
}
async function updateFlashSale(id: string, dto: UpdateFlashSaleDto): Promise<FlashSaleAdminItem> {
return put<FlashSaleAdminItem>(`/admin/flash-sales/${id}`, dto as unknown as Record<string, unknown>)
}
async function deleteFlashSale(id: string): Promise<{ deleted: boolean }> {
return del<{ deleted: boolean }>(`/admin/flash-sales/${id}`)
}
return {
// State
weekTemplates,
@@ -243,5 +266,10 @@ export const useAdminStore = defineStore('admin', () => {
publishDaySlots,
// Stats
fetchDashboardStats,
// Flash sales
fetchFlashSales,
createFlashSale,
updateFlashSale,
deleteFlashSale,
}
})

View File

@@ -0,0 +1,43 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type {
FlashSaleListItem,
FlashSaleDetail,
FlashSalePurchaseResponse,
} from '@mp-pilates/shared'
import { get, post } from '../utils/request'
export const useFlashSaleStore = defineStore('flash-sale', () => {
const flashSales = ref<FlashSaleListItem[]>([])
const loading = ref(false)
async function fetchFlashSales(): Promise<FlashSaleListItem[]> {
loading.value = true
try {
const data = await get<FlashSaleListItem[]>('/flash-sales')
flashSales.value = [...data]
return data
} catch {
flashSales.value = []
return []
} finally {
loading.value = false
}
}
async function fetchDetail(id: string): Promise<FlashSaleDetail> {
return get<FlashSaleDetail>(`/flash-sales/${id}`)
}
async function purchase(id: string): Promise<FlashSalePurchaseResponse> {
return post<FlashSalePurchaseResponse>(`/flash-sales/${id}/purchase`)
}
return {
flashSales,
loading,
fetchFlashSales,
fetchDetail,
purchase,
}
})

View File

@@ -1,4 +1,5 @@
import { CardTypeCategory } from '@mp-pilates/shared'
import { FlashSalePhase } from '@mp-pilates/shared'
/** Minimal membership shape needed by progress/usage helpers. */
interface MembershipLike {
@@ -83,15 +84,73 @@ export function getCardGradientClass(type: CardTypeCategory | string): string {
return 'gradient--times'
}
/** 会员卡进度百分比(剩余 / 总次数) */
/** 会员卡进度百分比(剩余 / 总次数clamp 到 0~100% */
export function getMembershipProgressWidth(membership: MembershipLike): string {
if (membership.remainingTimes === null || !membership.cardType.totalTimes) return '0%'
const pct = (membership.remainingTimes / membership.cardType.totalTimes) * 100
return `${Math.max(0, Math.min(100, pct))}%`
}
/** 已使用次数 */
/** 已使用次数(不低于 0防止管理员调高剩余次数导致负值 */
export function getMembershipUsedTimes(membership: MembershipLike): number {
if (membership.remainingTimes === null || !membership.cardType.totalTimes) return 0
return membership.cardType.totalTimes - membership.remainingTimes
return Math.max(0, membership.cardType.totalTimes - membership.remainingTimes)
}
/** 格式化倒计时HH:MM:SS */
export function formatCountdown(targetTime: string): string {
const { h, m, s } = getCountdownParts(targetTime)
return `${h}:${m}:${s}`
}
/** 获取倒计时各部分h/m/s 已补零) */
export function getCountdownParts(targetTime: string): { readonly h: string; readonly m: string; readonly s: string } {
const diff = Math.max(0, new Date(targetTime).getTime() - Date.now())
return {
h: String(Math.floor(diff / 3_600_000)).padStart(2, '0'),
m: String(Math.floor((diff % 3_600_000) / 60_000)).padStart(2, '0'),
s: String(Math.floor((diff % 60_000) / 1000)).padStart(2, '0'),
}
}
/** 秒杀阶段中文标签 */
export function getFlashSalePhaseLabel(phase: FlashSalePhase): string {
const map: Record<FlashSalePhase, string> = {
[FlashSalePhase.UPCOMING]: '即将开始',
[FlashSalePhase.ONGOING]: '抢购中',
[FlashSalePhase.SOLD_OUT]: '已售罄',
[FlashSalePhase.ENDED]: '已结束',
}
return map[phase]
}
/** 库存已售比例 */
export function getStockRatio(soldCount: number, totalStock: number): number {
if (totalStock === 0) return 0
return soldCount / totalStock
}
/** 库存已售百分比字符串 */
export function getStockPercent(soldCount: number, totalStock: number): string {
return `${Math.min(100, getStockRatio(soldCount, totalStock) * 100)}%`
}
/** 格式化日期时间为 MM-DD HH:mm */
export function formatDateTime(dateStr: string): string {
const d = new Date(dateStr)
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
const hour = String(d.getHours()).padStart(2, '0')
const min = String(d.getMinutes()).padStart(2, '0')
return `${month}-${day} ${hour}:${min}`
}
/** 格式化 Date 为 YYYY-MM-DD本地时间用于 picker */
export function formatDateLocal(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
/** 格式化 Date 为 HH:MM本地时间用于 picker */
export function formatTimeLocal(d: Date): string {
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}

View File

@@ -51,6 +51,18 @@ enum OrderStatus {
REFUNDED
}
enum FlashSaleStatus {
DRAFT
ACTIVE
ENDED
}
enum FlashSaleOrderStatus {
RESERVED
PAID
EXPIRED
}
// ===== Models =====
model User {
@@ -67,6 +79,7 @@ model User {
memberships Membership[]
bookings Booking[]
orders Order[]
flashSaleOrders FlashSaleOrder[]
@@map("users")
}
@@ -87,6 +100,7 @@ model CardType {
memberships Membership[]
orders Order[]
flashSales FlashSale[]
@@map("card_types")
}
@@ -197,11 +211,13 @@ model Order {
status OrderStatus @default(PENDING)
wxTransactionId String? @map("wx_transaction_id")
paidAt DateTime? @map("paid_at")
flashSaleId String? @map("flash_sale_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id])
cardType CardType @relation(fields: [cardTypeId], references: [id])
user User @relation(fields: [userId], references: [id])
cardType CardType @relation(fields: [cardTypeId], references: [id])
flashSaleOrder FlashSaleOrder?
@@index([userId])
@@index([status])
@@ -223,3 +239,48 @@ model StudioConfig {
@@map("studio_config")
}
model FlashSale {
id String @id @default(uuid())
cardTypeId String @map("card_type_id")
title String
originalPrice Decimal @map("original_price") @db.Decimal(10, 0)
flashPrice Decimal @map("flash_price") @db.Decimal(10, 0)
totalStock Int @map("total_stock")
soldCount Int @default(0) @map("sold_count")
startTime DateTime @map("start_time")
endTime DateTime @map("end_time")
status FlashSaleStatus @default(DRAFT)
description String? @db.Text
sortOrder Int @default(0) @map("sort_order")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
cardType CardType @relation(fields: [cardTypeId], references: [id])
orders FlashSaleOrder[]
@@index([status, startTime, endTime])
@@map("flash_sales")
}
model FlashSaleOrder {
id String @id @default(uuid())
flashSaleId String @map("flash_sale_id")
userId String @map("user_id")
orderId String? @unique @map("order_id")
status FlashSaleOrderStatus @default(RESERVED)
reservedAt DateTime @default(now()) @map("reserved_at")
paidAt DateTime? @map("paid_at")
expiredAt DateTime? @map("expired_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
flashSale FlashSale @relation(fields: [flashSaleId], references: [id])
user User @relation(fields: [userId], references: [id])
order Order? @relation(fields: [orderId], references: [id])
@@unique([flashSaleId, userId])
@@index([userId])
@@index([status])
@@map("flash_sale_orders")
}

View File

@@ -11,6 +11,7 @@ import { BookingModule } from './booking/booking.module'
import { SchedulerModule } from './scheduler/scheduler.module'
import { PaymentModule } from './payment/payment.module'
import { AdminModule } from './admin/admin.module'
import { FlashSaleModule } from './flash-sale/flash-sale.module'
@Module({
imports: [
@@ -28,6 +29,7 @@ import { AdminModule } from './admin/admin.module'
SchedulerModule,
PaymentModule,
AdminModule,
FlashSaleModule,
],
controllers: [AppController],
})

View File

@@ -0,0 +1,35 @@
import { IsUUID, IsString, IsInt, IsDateString, IsOptional, Min, IsNumber } from 'class-validator'
export class CreateFlashSaleDto {
@IsUUID()
cardTypeId!: string
@IsString()
title!: string
@IsNumber()
@Min(1)
originalPrice!: number
@IsNumber()
@Min(1)
flashPrice!: number
@IsInt()
@Min(1)
totalStock!: number
@IsDateString()
startTime!: string
@IsDateString()
endTime!: string
@IsOptional()
@IsString()
description?: string
@IsOptional()
@IsInt()
sortOrder?: number
}

View File

@@ -0,0 +1,43 @@
import { IsString, IsInt, IsDateString, IsOptional, Min, IsNumber, IsEnum } from 'class-validator'
import { FlashSaleStatus } from '@mp-pilates/shared'
export class UpdateFlashSaleDto {
@IsOptional()
@IsString()
title?: string
@IsOptional()
@IsNumber()
@Min(1)
originalPrice?: number
@IsOptional()
@IsNumber()
@Min(1)
flashPrice?: number
@IsOptional()
@IsInt()
@Min(1)
totalStock?: number
@IsOptional()
@IsDateString()
startTime?: string
@IsOptional()
@IsDateString()
endTime?: string
@IsOptional()
@IsString()
description?: string
@IsOptional()
@IsEnum(FlashSaleStatus)
status?: FlashSaleStatus
@IsOptional()
@IsInt()
sortOrder?: number
}

View File

@@ -0,0 +1,67 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
Put,
Query,
UseGuards,
ValidationPipe,
} from '@nestjs/common'
import { UserRole } from '@mp-pilates/shared'
import { JwtAuthGuard } from '../auth/jwt-auth.guard'
import { RolesGuard } from '../auth/roles.guard'
import { Roles } from '../auth/roles.decorator'
import { FlashSaleService } from './flash-sale.service'
import { CreateFlashSaleDto } from './dto/create-flash-sale.dto'
import { UpdateFlashSaleDto } from './dto/update-flash-sale.dto'
@Controller('admin/flash-sales')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
export class FlashSaleAdminController {
constructor(private readonly flashSaleService: FlashSaleService) {}
/** POST /admin/flash-sales — create */
@Post()
create(
@Body(new ValidationPipe({ whitelist: true })) dto: CreateFlashSaleDto,
) {
return this.flashSaleService.createFlashSale(dto)
}
/** GET /admin/flash-sales — list (paginated) */
@Get()
list(
@Query('page') page?: string,
@Query('limit') limit?: string,
) {
return this.flashSaleService.getAdminFlashSales(
page ? parseInt(page, 10) : 1,
limit ? parseInt(limit, 10) : 20,
)
}
/** GET /admin/flash-sales/:id — detail */
@Get(':id')
detail(@Param('id') id: string) {
return this.flashSaleService.getFlashSaleDetail(id)
}
/** PUT /admin/flash-sales/:id — update */
@Put(':id')
update(
@Param('id') id: string,
@Body(new ValidationPipe({ whitelist: true })) dto: UpdateFlashSaleDto,
) {
return this.flashSaleService.updateFlashSale(id, dto)
}
/** DELETE /admin/flash-sales/:id — delete (DRAFT only) */
@Delete(':id')
remove(@Param('id') id: string) {
return this.flashSaleService.deleteFlashSale(id)
}
}

View File

@@ -0,0 +1,40 @@
import {
Controller,
Get,
Param,
Post,
UseGuards,
} from '@nestjs/common'
import { JwtAuthGuard } from '../auth/jwt-auth.guard'
import { CurrentUser } from '../common/decorators/current-user.decorator'
import { FlashSaleService } from './flash-sale.service'
@Controller('flash-sales')
export class FlashSaleController {
constructor(private readonly flashSaleService: FlashSaleService) {}
/** GET /flash-sales — list active/upcoming (public) */
@Get()
getActiveFlashSales() {
return this.flashSaleService.getActiveFlashSales()
}
/** GET /flash-sales/:id — detail (optionally authenticated) */
@Get(':id')
getFlashSaleDetail(
@Param('id') id: string,
@CurrentUser('sub') userId?: string,
) {
return this.flashSaleService.getFlashSaleDetail(id, userId)
}
/** POST /flash-sales/:id/purchase — requires auth */
@Post(':id/purchase')
@UseGuards(JwtAuthGuard)
purchase(
@Param('id') flashSaleId: string,
@CurrentUser('sub') userId: string,
) {
return this.flashSaleService.purchase(flashSaleId, userId)
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common'
import { PrismaModule } from '../prisma/prisma.module'
import { PaymentModule } from '../payment/payment.module'
import { FlashSaleService } from './flash-sale.service'
import { FlashSaleController } from './flash-sale.controller'
import { FlashSaleAdminController } from './flash-sale-admin.controller'
@Module({
imports: [PrismaModule, PaymentModule],
controllers: [FlashSaleController, FlashSaleAdminController],
providers: [FlashSaleService],
exports: [FlashSaleService],
})
export class FlashSaleModule {}

View File

@@ -0,0 +1,409 @@
import {
BadRequestException,
ConflictException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common'
import { Prisma } from '@prisma/client'
import {
FlashSaleStatus,
FlashSaleOrderStatus,
MembershipStatus,
OrderStatus,
} from '@mp-pilates/shared'
import { FlashSalePhase } from '@mp-pilates/shared'
import { PrismaService } from '../prisma/prisma.service'
import { WechatPayService, WxPaymentParams } from '../payment/wechat-pay.service'
import { CreateFlashSaleDto } from './dto/create-flash-sale.dto'
import { UpdateFlashSaleDto } from './dto/update-flash-sale.dto'
// ── Helpers ─────────────────────────────────────────────────
function computePhase(sale: {
startTime: Date
endTime: Date
soldCount: number
totalStock: number
status: string
}): FlashSalePhase {
if (sale.status === FlashSaleStatus.ENDED) return FlashSalePhase.ENDED
const now = new Date()
if (now < sale.startTime) return FlashSalePhase.UPCOMING
if (now > sale.endTime) return FlashSalePhase.ENDED
if (sale.soldCount >= sale.totalStock) return FlashSalePhase.SOLD_OUT
return FlashSalePhase.ONGOING
}
function toNumber(val: Prisma.Decimal | number): number {
return typeof val === 'number' ? val : Number(val)
}
// ── Service ─────────────────────────────────────────────────
@Injectable()
export class FlashSaleService {
private readonly logger = new Logger(FlashSaleService.name)
constructor(
private readonly prisma: PrismaService,
private readonly wechatPayService: WechatPayService,
) {}
// ═══════════════════════════════════════════════════════════
// USER: List active/upcoming flash sales
// ═══════════════════════════════════════════════════════════
async getActiveFlashSales() {
const sales = await this.prisma.flashSale.findMany({
where: {
status: FlashSaleStatus.ACTIVE,
endTime: { gt: new Date() },
},
include: {
cardType: {
select: { name: true, type: true, totalTimes: true, durationDays: true },
},
},
orderBy: [{ sortOrder: 'asc' }, { startTime: 'asc' }],
})
return sales.map((s) => ({
...s,
originalPrice: toNumber(s.originalPrice),
flashPrice: toNumber(s.flashPrice),
phase: computePhase(s),
remainingStock: s.totalStock - s.soldCount,
cardType: s.cardType,
}))
}
// ═══════════════════════════════════════════════════════════
// USER: Get detail (with participation check)
// ═══════════════════════════════════════════════════════════
async getFlashSaleDetail(id: string, userId?: string) {
const sale = await this.prisma.flashSale.findUnique({
where: { id },
include: {
cardType: {
select: {
name: true,
type: true,
totalTimes: true,
durationDays: true,
description: true,
},
},
},
})
if (!sale) throw new NotFoundException('秒杀活动不存在')
let hasParticipated = false
let userOrderStatus: FlashSaleOrderStatus | null = null
if (userId) {
const existing = await this.prisma.flashSaleOrder.findUnique({
where: { flashSaleId_userId: { flashSaleId: id, userId } },
})
if (existing && existing.status !== FlashSaleOrderStatus.EXPIRED) {
hasParticipated = true
userOrderStatus = existing.status as FlashSaleOrderStatus
}
}
return {
...sale,
originalPrice: toNumber(sale.originalPrice),
flashPrice: toNumber(sale.flashPrice),
phase: computePhase(sale),
remainingStock: sale.totalStock - sale.soldCount,
cardType: { ...sale.cardType },
hasParticipated,
userOrderStatus,
serverTime: new Date().toISOString(),
}
}
// ═══════════════════════════════════════════════════════════
// PURCHASE — Atomic stock deduction
// ═══════════════════════════════════════════════════════════
async purchase(flashSaleId: string, userId: string) {
// ① Pre-validate (fast-fail before transaction)
const user = await this.prisma.user.findUnique({ where: { id: userId } })
if (!user) throw new NotFoundException('用户不存在')
if (!user.phone) throw new BadRequestException('请先授权手机号后再参与秒杀')
const sale = await this.prisma.flashSale.findUnique({
where: { id: flashSaleId },
include: { cardType: true },
})
if (!sale) throw new NotFoundException('秒杀活动不存在')
if (sale.status !== FlashSaleStatus.ACTIVE) {
throw new BadRequestException('秒杀活动未上线')
}
const now = new Date()
if (now < sale.startTime) throw new BadRequestException('秒杀尚未开始')
if (now > sale.endTime) throw new BadRequestException('秒杀已结束')
// ② Atomic transaction: reserve stock + create FlashSaleOrder + create Order
let result: { order: { id: string; orderNo: string; amount: Prisma.Decimal }; flashSaleOrderId: string }
try {
result = await this.prisma.$transaction(async (tx) => {
// ②-a: CAS optimistic lock stock deduction
const updated = await tx.flashSale.updateMany({
where: {
id: flashSaleId,
soldCount: { lt: sale.totalStock },
},
data: {
soldCount: { increment: 1 },
},
})
if (updated.count === 0) {
throw new BadRequestException('手慢了,已售罄')
}
// ②-b: Create Order with flash sale price
const orderNo = `FS${Date.now()}${Math.random().toString(36).substring(2, 8)}`
const order = await tx.order.create({
data: {
userId,
cardTypeId: sale.cardTypeId,
orderNo,
amount: sale.flashPrice,
status: OrderStatus.PENDING,
flashSaleId,
},
})
// ②-c: Create FlashSaleOrder (unique constraint prevents duplicate)
const flashSaleOrder = await tx.flashSaleOrder.create({
data: {
flashSaleId,
userId,
orderId: order.id,
status: FlashSaleOrderStatus.RESERVED,
},
})
return {
order: { id: order.id, orderNo: order.orderNo, amount: order.amount },
flashSaleOrderId: flashSaleOrder.id,
}
})
} catch (err) {
// Handle unique constraint violation (user already participated)
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
throw new ConflictException('您已参与过此秒杀活动')
}
throw err
}
// ③ Create WeChat unified order (outside transaction — network call)
const paymentParams = await this.wechatPayService.createUnifiedOrder({
orderNo: result.order.orderNo,
amount: toNumber(result.order.amount),
openid: user.openid,
description: `秒杀-${sale.title}`,
})
return {
flashSaleOrderId: result.flashSaleOrderId,
order: {
id: result.order.id,
orderNo: result.order.orderNo,
amount: toNumber(result.order.amount),
},
paymentParams,
}
}
// ═══════════════════════════════════════════════════════════
// ADMIN: Create flash sale
// ═══════════════════════════════════════════════════════════
async createFlashSale(dto: CreateFlashSaleDto) {
const cardType = await this.prisma.cardType.findUnique({
where: { id: dto.cardTypeId },
})
if (!cardType) throw new NotFoundException('卡种不存在')
const startTime = new Date(dto.startTime)
const endTime = new Date(dto.endTime)
if (endTime <= startTime) {
throw new BadRequestException('结束时间必须晚于开始时间')
}
const sale = await this.prisma.flashSale.create({
data: {
cardTypeId: dto.cardTypeId,
title: dto.title,
originalPrice: dto.originalPrice,
flashPrice: dto.flashPrice,
totalStock: dto.totalStock,
startTime,
endTime,
description: dto.description ?? null,
sortOrder: dto.sortOrder ?? 0,
status: FlashSaleStatus.DRAFT,
},
include: {
cardType: { select: { name: true, type: true } },
},
})
return {
...sale,
originalPrice: toNumber(sale.originalPrice),
flashPrice: toNumber(sale.flashPrice),
phase: computePhase(sale),
cardType: { ...sale.cardType },
}
}
// ═══════════════════════════════════════════════════════════
// ADMIN: Update flash sale
// ═══════════════════════════════════════════════════════════
async updateFlashSale(id: string, dto: UpdateFlashSaleDto) {
const existing = await this.prisma.flashSale.findUnique({ where: { id } })
if (!existing) throw new NotFoundException('秒杀活动不存在')
const data: Record<string, unknown> = {}
if (dto.title !== undefined) data.title = dto.title
if (dto.originalPrice !== undefined) data.originalPrice = dto.originalPrice
if (dto.flashPrice !== undefined) data.flashPrice = dto.flashPrice
if (dto.totalStock !== undefined) {
if (dto.totalStock < existing.soldCount) {
throw new BadRequestException('库存不能小于已售数量')
}
data.totalStock = dto.totalStock
}
if (dto.startTime !== undefined) data.startTime = new Date(dto.startTime)
if (dto.endTime !== undefined) data.endTime = new Date(dto.endTime)
if (dto.description !== undefined) data.description = dto.description
if (dto.status !== undefined) data.status = dto.status
if (dto.sortOrder !== undefined) data.sortOrder = dto.sortOrder
const sale = await this.prisma.flashSale.update({
where: { id },
data,
include: {
cardType: { select: { name: true, type: true } },
},
})
return {
...sale,
originalPrice: toNumber(sale.originalPrice),
flashPrice: toNumber(sale.flashPrice),
phase: computePhase(sale),
cardType: { ...sale.cardType },
}
}
// ═══════════════════════════════════════════════════════════
// ADMIN: Delete flash sale (only DRAFT)
// ═══════════════════════════════════════════════════════════
async deleteFlashSale(id: string) {
const existing = await this.prisma.flashSale.findUnique({ where: { id } })
if (!existing) throw new NotFoundException('秒杀活动不存在')
if (existing.soldCount > 0) {
throw new BadRequestException('已有用户参与,无法删除,请结束活动')
}
await this.prisma.flashSale.delete({ where: { id } })
return { deleted: true }
}
// ═══════════════════════════════════════════════════════════
// ADMIN: List all flash sales (paginated)
// ═══════════════════════════════════════════════════════════
async getAdminFlashSales(page = 1, limit = 20) {
const skip = (page - 1) * limit
const [data, total] = await Promise.all([
this.prisma.flashSale.findMany({
include: {
cardType: { select: { name: true, type: true } },
},
orderBy: { createdAt: 'desc' },
skip,
take: limit,
}),
this.prisma.flashSale.count(),
])
return {
data: data.map((s) => ({
...s,
originalPrice: toNumber(s.originalPrice),
flashPrice: toNumber(s.flashPrice),
phase: computePhase(s),
cardType: s.cardType,
})),
total,
page,
limit,
}
}
// ═══════════════════════════════════════════════════════════
// SCHEDULER: Expire unpaid reservations (release stock)
// ═══════════════════════════════════════════════════════════
async expireUnpaidReservations(expireMinutes = 15): Promise<number> {
const cutoff = new Date(Date.now() - expireMinutes * 60_000)
const expiredOrders = await this.prisma.flashSaleOrder.findMany({
where: {
status: FlashSaleOrderStatus.RESERVED,
reservedAt: { lt: cutoff },
},
})
if (expiredOrders.length === 0) return 0
// Group by flashSaleId to batch stock release
const stockDecrements = new Map<string, number>()
const orderIds: string[] = []
const flashSaleOrderIds: string[] = []
for (const fo of expiredOrders) {
flashSaleOrderIds.push(fo.id)
stockDecrements.set(fo.flashSaleId, (stockDecrements.get(fo.flashSaleId) ?? 0) + 1)
if (fo.orderId) orderIds.push(fo.orderId)
}
try {
await this.prisma.$transaction([
// Batch mark all as expired
this.prisma.flashSaleOrder.updateMany({
where: { id: { in: flashSaleOrderIds } },
data: { status: FlashSaleOrderStatus.EXPIRED, expiredAt: new Date() },
}),
// Release stock per flash sale
...Array.from(stockDecrements.entries()).map(([flashSaleId, count]) =>
this.prisma.flashSale.update({
where: { id: flashSaleId },
data: { soldCount: { decrement: count } },
}),
),
// Cancel associated payment orders
...(orderIds.length > 0
? [
this.prisma.order.updateMany({
where: { id: { in: orderIds } },
data: { status: OrderStatus.REFUNDED },
}),
]
: []),
])
} catch (err) {
this.logger.error('Failed to batch-expire flash sale orders', err)
return 0
}
return expiredOrders.length
}
}

View File

@@ -8,6 +8,6 @@ import { WechatPayService } from './wechat-pay.service'
imports: [PrismaModule],
controllers: [PaymentController],
providers: [PaymentService, WechatPayService],
exports: [PaymentService],
exports: [PaymentService, WechatPayService],
})
export class PaymentModule {}

View File

@@ -5,7 +5,7 @@ import {
NotFoundException,
} from '@nestjs/common'
import { CardType, Order } from '@prisma/client'
import { MembershipStatus, OrderStatus } from '@mp-pilates/shared'
import { MembershipStatus, OrderStatus, FlashSaleOrderStatus } from '@mp-pilates/shared'
import { PrismaService } from '../prisma/prisma.service'
import { WechatPayService, WxPaymentParams } from './wechat-pay.service'
@@ -136,6 +136,22 @@ export class PaymentService {
])
this.logger.log(`Order PAID and Membership created: orderNo=${notification.orderNo}`)
// ── Flash sale order: mark as PAID ──
if (existingOrder.flashSaleId) {
await this.prisma.flashSaleOrder.updateMany({
where: {
orderId: existingOrder.id,
status: FlashSaleOrderStatus.RESERVED,
},
data: {
status: FlashSaleOrderStatus.PAID,
paidAt: now,
},
})
this.logger.log(`Flash sale order marked PAID for orderNo=${notification.orderNo}`)
}
return this.buildSuccessXml()
}

View File

@@ -1,12 +1,14 @@
import { Module } from '@nestjs/common'
import { ScheduleModule } from '@nestjs/schedule'
import { TimeSlotModule } from '../time-slot/time-slot.module'
import { FlashSaleModule } from '../flash-sale/flash-sale.module'
import { SchedulerService } from './scheduler.service'
@Module({
imports: [
ScheduleModule.forRoot(),
TimeSlotModule,
FlashSaleModule,
],
providers: [SchedulerService],
})

View File

@@ -1,12 +1,16 @@
import { Injectable, Logger } from '@nestjs/common'
import { Cron } from '@nestjs/schedule'
import { SlotGeneratorService } from '../time-slot/slot-generator.service'
import { FlashSaleService } from '../flash-sale/flash-sale.service'
@Injectable()
export class SchedulerService {
private readonly logger = new Logger(SchedulerService.name)
constructor(private readonly slotGenerator: SlotGeneratorService) {}
constructor(
private readonly slotGenerator: SlotGeneratorService,
private readonly flashSaleService: FlashSaleService,
) {}
/** 02:00 daily — generate slots 14 days ahead from week templates */
@Cron('0 2 * * *')
@@ -51,4 +55,17 @@ export class SchedulerService {
this.logger.error('[handleCompleteBookings] Failed to complete bookings', err)
}
}
/** Every 5 min — expire unpaid flash sale reservations older than 15min */
@Cron('*/5 * * * *')
async handleExpireFlashSaleReservations(): Promise<void> {
try {
const count = await this.flashSaleService.expireUnpaidReservations(15)
if (count > 0) {
this.logger.log(`[handleExpireFlashSaleReservations] Expired ${count} unpaid reservations`)
}
} catch (err) {
this.logger.error('[handleExpireFlashSaleReservations] Failed', err)
}
}
}

View File

@@ -45,3 +45,16 @@ export enum OrderStatus {
PAID = 'PAID',
REFUNDED = 'REFUNDED',
}
// ===== FlashSale =====
export enum FlashSaleStatus {
DRAFT = 'DRAFT',
ACTIVE = 'ACTIVE',
ENDED = 'ENDED',
}
export enum FlashSaleOrderStatus {
RESERVED = 'RESERVED',
PAID = 'PAID',
EXPIRED = 'EXPIRED',
}

View File

@@ -7,6 +7,8 @@ export {
TimeSlotSource,
BookingStatus,
OrderStatus,
FlashSaleStatus,
FlashSaleOrderStatus,
} from './enums'
// Constants
@@ -54,4 +56,13 @@ export type {
PaginatedData,
PaginatedResponse,
PaginationQuery,
FlashSale,
FlashSaleListItem,
FlashSaleDetail,
FlashSaleAdminItem,
CreateFlashSaleDto,
UpdateFlashSaleDto,
FlashSalePurchaseResponse,
} from './types/index'
export { FlashSalePhase } from './types/index'

View File

@@ -0,0 +1,97 @@
import { FlashSaleStatus, FlashSaleOrderStatus, CardTypeCategory } from '../enums'
// ── Computed sale phase (derived from server time) ──────────
export enum FlashSalePhase {
UPCOMING = 'UPCOMING',
ONGOING = 'ONGOING',
SOLD_OUT = 'SOLD_OUT',
ENDED = 'ENDED',
}
// ── Core entity ─────────────────────────────────────────────
export interface FlashSale {
readonly id: string
readonly cardTypeId: string
readonly title: string
readonly originalPrice: number
readonly flashPrice: number
readonly totalStock: number
readonly soldCount: number
readonly startTime: string
readonly endTime: string
readonly status: FlashSaleStatus
readonly description: string | null
readonly sortOrder: number
readonly createdAt: string
readonly updatedAt: string
}
// ── User-facing list item (home page) ───────────────────────
export interface FlashSaleListItem extends FlashSale {
readonly phase: FlashSalePhase
readonly remainingStock: number
readonly cardType: {
readonly name: string
readonly type: CardTypeCategory
readonly totalTimes: number | null
readonly durationDays: number
}
}
// ── Detail (includes user participation status) ─────────────
export interface FlashSaleDetail extends FlashSaleListItem {
readonly hasParticipated: boolean
readonly userOrderStatus: FlashSaleOrderStatus | null
readonly serverTime: string
}
// ── Admin DTOs ──────────────────────────────────────────────
export interface CreateFlashSaleDto {
readonly cardTypeId: string
readonly title: string
readonly originalPrice: number
readonly flashPrice: number
readonly totalStock: number
readonly startTime: string
readonly endTime: string
readonly description?: string
readonly sortOrder?: number
}
export interface UpdateFlashSaleDto {
readonly title?: string
readonly originalPrice?: number
readonly flashPrice?: number
readonly totalStock?: number
readonly startTime?: string
readonly endTime?: string
readonly description?: string
readonly status?: FlashSaleStatus
readonly sortOrder?: number
}
// ── Purchase response ───────────────────────────────────────
export interface FlashSalePurchaseResponse {
readonly flashSaleOrderId: string
readonly order: {
readonly id: string
readonly orderNo: string
readonly amount: number
}
readonly paymentParams: {
readonly timeStamp: string
readonly nonceStr: string
readonly package: string
readonly signType: string
readonly paySign: string
}
}
// ── Admin list item ─────────────────────────────────────────
export interface FlashSaleAdminItem extends FlashSale {
readonly phase: FlashSalePhase
readonly cardType: {
readonly name: string
readonly type: CardTypeCategory
}
}

View File

@@ -7,3 +7,13 @@ export type { Booking, BookingWithDetails, BookingWithUser, BookingStatusHistory
export type { Order, OrderWithDetails, CreateOrderDto, PaymentParams, CreateOrderResponse } from './order'
export type { StudioConfig, UpdateStudioConfigDto } from './studio'
export type { ApiResponse, PaginatedData, PaginatedResponse, PaginationQuery } from './api'
export type {
FlashSale,
FlashSaleListItem,
FlashSaleDetail,
FlashSaleAdminItem,
CreateFlashSaleDto,
UpdateFlashSaleDto,
FlashSalePurchaseResponse,
} from './flash-sale'
export { FlashSalePhase } from './flash-sale'