perf: 移除秒杀相关功能

This commit is contained in:
richarjiang
2026-09-10 11:33:19 +08:00
parent 51dea488f6
commit 57edd8dcc0
32 changed files with 131 additions and 2880 deletions

View File

@@ -1,185 +0,0 @@
<template>
<view v-if="flashSales.length" class="flash-sale-section">
<!-- Section header -->
<view class="section-header">
<view class="header-left">
<text class="section-title">限时秒杀</text>
<text v-if="hasOngoing" class="live-note">进行中</text>
</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(invite.price(sale.flashPrice)) }}</text>
</view>
<text v-if="invite.eligible" class="original-price">好友 95 </text>
<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 { useInviteStore } from "../stores/invite"
const invite = useInviteStore()
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 { margin: 36rpx 32rpx 0; }
.section-header { margin-bottom: 20rpx; }
.header-left { display: flex; align-items: baseline; justify-content: space-between; gap: 16rpx; }
.section-title { font-size: 30rpx; font-weight: 500; color: #514943; }
.live-note { font-size: 22rpx; color: #9b7b66; }
.flash-scroll { width: 100%; white-space: nowrap; }
.flash-card-list { display: inline-flex; gap: 20rpx; }
.flash-card { width: 400rpx; border-radius: 28rpx; overflow: hidden; border: 1rpx solid #e7ded5; flex-shrink: 0; display: inline-flex; flex-direction: column; white-space: normal; background: #fff; }
.card-top { padding: 20rpx 24rpx; background: #f1e6de; display: flex; flex-direction: column; gap: 12rpx; }
.card--upcoming .card-top { background: #eaf0e8; }
.card--soldout .card-top, .card--ended .card-top { background: #eeeae5; }
.phase-badge-text { font-size: 22rpx; color: #7c6656; }
.countdown-row, .countdown-blocks { display: flex; align-items: baseline; gap: 8rpx; }
.countdown-label { font-size: 20rpx; color: #8b817b; }
.cd-block, .cd-sep { font-size: 24rpx; color: #7c6656; font-variant-numeric: tabular-nums; }
.card-body { padding: 24rpx; display: flex; flex-direction: column; gap: 10rpx; }
.card-title { font-size: 28rpx; font-weight: 500; color: #514943; line-height: 1.5; overflow-wrap: anywhere; }
.card-type-name { font-size: 22rpx; color: #8b817b; }
.price-area { display: flex; align-items: baseline; flex-wrap: wrap; gap: 12rpx; margin-top: 8rpx; }
.flash-price-row { display: flex; align-items: baseline; gap: 4rpx; }
.flash-currency { font-size: 22rpx; color: #8b6c5b; }
.flash-price { font-size: 38rpx; font-weight: 500; color: #8b6c5b; }
.original-price { font-size: 21rpx; color: #a59b93; text-decoration: line-through; }
.stock-area { margin-top: 12rpx; display: flex; flex-direction: column; gap: 10rpx; }
.stock-bar { height: 6rpx; background: #f3efea; border-radius: 6rpx; overflow: hidden; }
.stock-fill { height: 100%; border-radius: 6rpx; background: #bea28e; }
.stock-text { font-size: 20rpx; color: #8b817b; }
</style>

View File

@@ -75,12 +75,6 @@
"navigationStyle": "custom"
}
},
{
"path": "pages/flash-sale/detail",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/profile/progress",
"style": {
@@ -171,12 +165,6 @@
"navigationStyle": "custom"
}
},
{
"path": "flash-sales",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "member-progress",
"style": {

View File

@@ -1,863 +0,0 @@
<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.items]
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.items]
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

@@ -127,21 +127,6 @@
<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: 系统 -->
@@ -386,7 +371,6 @@ 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); }
.icon--subscribe { background: linear-gradient(135deg, #5D8C8A, #476D72); }

View File

@@ -15,9 +15,6 @@ import type {
PaginatedData,
ScheduleSlotPreview,
PublishDaySlotsDto,
FlashSaleAdminItem,
CreateFlashSaleDto,
UpdateFlashSaleDto,
CreateStudioUploadCredentialDto,
StudioUploadCredential,
AdminMemberSummary,
@@ -272,26 +269,7 @@ 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}`)
}
// ── Teaching analytics ─────────────────────────────────────────
async function fetchTeachingAnalytics(month: string): Promise<TeachingAnalytics> {
return get<TeachingAnalytics>('/admin/teaching-analytics', { month })
}
@@ -339,10 +317,5 @@ export const useAdminStore = defineStore('admin', () => {
publishDaySlots,
// Stats
fetchDashboardStats,
// Flash sales
fetchFlashSales,
createFlashSale,
updateFlashSale,
deleteFlashSale,
}
})

View File

@@ -1,848 +0,0 @@
<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 v-if="invite.eligible" class="hero-discount-text">好友 95 </text>
<text class="hero-price">{{ formatPrice(invite.price(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 - invite.price(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(invite.price(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 { useInviteStore } from "../../stores/invite"
const invite = useInviteStore()
import { ref, computed, onMounted, onUnmounted } from 'vue'
import {
FlashSalePhase,
FlashSaleOrderStatus,
} from '@mp-pilates/shared'
import type { FlashSaleDetail } from '@mp-pilates/shared'
import { getErrorMessage } from '../../utils/auth'
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'
import { requestOrderPaidSubscriptionMessage } from '../../utils/wechat-subscription'
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(invite.price(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 (err: unknown) {
uni.showToast({ title: getErrorMessage(err, '登录失败'), icon: 'none' })
}
}
},
})
return
}
// Check phone
if (!userStore.user?.phone) {
uni.showToast({ title: '请先授权手机号', icon: 'none' })
return
}
try { await invite.refresh() } catch (err) {
uni.showToast({ title: getErrorMessage(err, '暂时无法核对优惠,请重试'), icon: 'none' })
return
}
// Confirm purchase
uni.showModal({
title: '确认抢购',
content: `确认以 ¥${formatPrice(invite.price(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 ?? '支付取消')),
})
})
await requestOrderPaidSubscriptionMessage().catch(() => undefined)
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

@@ -13,7 +13,6 @@
<UpcomingBooking />
<ReviewSummaryCard />
<StudioInfo :studio-info="studioStore.studioInfo" />
<FlashSaleSection ref="flashSaleRef" />
<view :id="cardShopAnchorId">
<CardShop ref="cardShopRef" />
</view>
@@ -33,7 +32,6 @@ import ReviewSummaryCard from '../../components/ReviewSummaryCard.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 AboutSection from '../../components/AboutSection.vue'
@@ -64,7 +62,6 @@ 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 scrollTarget = ref('')
const pageHeight = ref(`${uni.getWindowInfo().windowHeight}px`)
@@ -105,10 +102,9 @@ async function refreshData() {
await Promise.allSettled(tasks)
// Also refresh card shop and flash sales
// Also refresh card shop
await Promise.allSettled([
cardShopRef.value?.fetchCardTypes(),
flashSaleRef.value?.fetchFlashSales(),
])
}

View File

@@ -1,43 +0,0 @@
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,5 +1,4 @@
import { CardTypeCategory } from '@mp-pilates/shared'
import { FlashSalePhase } from '@mp-pilates/shared'
/** Minimal membership shape needed by progress/usage helpers. */
interface MembershipLike {
@@ -128,17 +127,6 @@ export function getCountdownParts(targetTime: string): { readonly h: string; rea
}
}
/** 秒杀阶段中文标签 */
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

View File

@@ -0,0 +1,12 @@
-- Drop flash sale feature
-- See docs/flash-sale-removal.md for context
--
-- NOTE: production MySQL has no FK on orders.flash_sale_id, so we drop the
-- column directly without a prior DROP FOREIGN KEY.
-- Drop tables first
DROP TABLE IF EXISTS `flash_sale_orders`;
DROP TABLE IF EXISTS `flash_sales`;
-- Remove order.flash_sale_id column
ALTER TABLE `orders` DROP COLUMN `flash_sale_id`;

View File

@@ -51,18 +51,6 @@ enum OrderStatus {
REFUNDED
}
enum FlashSaleStatus {
DRAFT
ACTIVE
ENDED
}
enum FlashSaleOrderStatus {
RESERVED
PAID
EXPIRED
}
enum InviteReferralStatus {
REGISTERED
TRIAL_PURCHASED
@@ -92,7 +80,6 @@ model User {
memberships Membership[]
bookings Booking[]
orders Order[]
flashSaleOrders FlashSaleOrder[]
subscriptionMessageConsents SubscriptionMessageConsent[]
sentInviteReferrals InviteReferral[] @relation("InviteReferralInviter")
receivedInviteReferral InviteReferral[] @relation("InviteReferralInvitee")
@@ -143,7 +130,6 @@ model CardType {
memberships Membership[]
orders Order[]
flashSales FlashSale[]
@@map("card_types")
}
@@ -269,14 +255,12 @@ 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])
membership Membership? @relation(fields: [membershipId], references: [id])
flashSaleOrder FlashSaleOrder?
inviteReferrals InviteReferral[]
@@index([userId])
@@ -342,51 +326,6 @@ 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")
}
// Historical totals without fabricated scheduled dates.
model LessonSupplement {
id String @id @default(uuid())

View File

@@ -11,7 +11,6 @@ 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'
import { InviteModule } from './invite/invite.module'
@Module({
@@ -30,7 +29,6 @@ import { InviteModule } from './invite/invite.module'
SchedulerModule,
PaymentModule,
AdminModule,
FlashSaleModule,
InviteModule,
],
controllers: [AppController],

View File

@@ -1,25 +0,0 @@
import { FlashSaleService } from '../flash-sale.service'
import { PrismaService } from '../../prisma/prisma.service'
import { WechatPayService } from '../../payment/wechat-pay.service'
describe('Flash sale referral pricing', () => {
it.each([true, false])('snapshots referral and category with eligible=%s', async (eligible) => {
const prisma = {
user: { findUnique: jest.fn().mockResolvedValue({ id: 'buyer', phone: '123', openid: 'openid' }) },
flashSale: {
findUnique: jest.fn().mockResolvedValue({ id: 'sale', title: 'sale', cardTypeId: 'card', cardType: { type: 'TIMES' }, status: 'ACTIVE', startTime: new Date(0), endTime: new Date(Date.now() + 60000), totalStock: 10, flashPrice: 999 }),
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
},
inviteReferral: { findUnique: jest.fn().mockResolvedValue(eligible ? { inviterId: 'friend' } : null) },
order: { create: jest.fn().mockImplementation(({ data }) => ({ ...data, id: 'order' })) },
flashSaleOrder: { create: jest.fn().mockResolvedValue({ id: 'reserved' }) },
$transaction: jest.fn(),
}
prisma.$transaction.mockImplementation((fn) => fn(prisma))
const pay = { createUnifiedOrder: jest.fn().mockResolvedValue({}) }
const service = new FlashSaleService(prisma as unknown as PrismaService, pay as unknown as WechatPayService)
await service.purchase('sale', 'buyer')
expect(prisma.order.create).toHaveBeenCalledWith({ data: expect.objectContaining({ amount: eligible ? 949 : 999, inviteInviterId: eligible ? 'friend' : undefined, purchasedCategory: 'TIMES' }) })
expect(pay.createUnifiedOrder).toHaveBeenCalledWith(expect.objectContaining({ amount: eligible ? 949 : 999 }))
})
})

View File

@@ -1,35 +0,0 @@
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

@@ -1,43 +0,0 @@
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

@@ -1,67 +0,0 @@
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

@@ -1,40 +0,0 @@
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

@@ -1,14 +0,0 @@
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

@@ -1,412 +0,0 @@
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 referral = await tx.inviteReferral.findUnique({ where: { inviteeId: userId } })
const order = await tx.order.create({
data: {
userId,
cardTypeId: sale.cardTypeId,
orderNo,
amount: referral ? Math.round(Number(sale.flashPrice) * 95 / 100) : sale.flashPrice,
inviteInviterId: referral?.inviterId,
purchasedCategory: sale.cardType.type,
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

@@ -90,9 +90,6 @@ function buildPrismaMock() {
findFirst: jest.fn(),
update: jest.fn(),
},
flashSaleOrder: {
updateMany: jest.fn(),
},
$transaction: jest.fn(),
}
}

View File

@@ -5,7 +5,7 @@ import {
NotFoundException,
} from '@nestjs/common'
import { CardType, Order } from '@prisma/client'
import { OrderStatus, FlashSaleOrderStatus } from '@mp-pilates/shared'
import { OrderStatus } from '@mp-pilates/shared'
import { PrismaService } from '../prisma/prisma.service'
import { WechatPayService, WxPaymentParams } from './wechat-pay.service'
import { InviteService } from '../invite/invite.service'
@@ -154,21 +154,6 @@ export class PaymentService {
this.logger.log(`Order PAID and membership granted: 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,4 +1,3 @@
import { FlashSaleService } from '../../flash-sale/flash-sale.service'
import { Test, TestingModule } from '@nestjs/testing'
import { Logger } from '@nestjs/common'
import { SchedulerService } from '../scheduler.service'
@@ -34,7 +33,6 @@ describe('SchedulerService', () => {
providers: [
SchedulerService,
{ provide: SlotGeneratorService, useValue: mockSlotGenerator },
{ provide: FlashSaleService, useValue: { expireUnpaidReservations: jest.fn() } },
],
}).compile()

View File

@@ -4,7 +4,6 @@ import { ReviewReminderService } from './review-reminder.service'
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({
@@ -12,7 +11,6 @@ import { SchedulerService } from './scheduler.service'
ScheduleModule.forRoot(),
UserModule, ConfigModule,
TimeSlotModule,
FlashSaleModule,
],
providers: [SchedulerService, ReviewReminderService],
})

View File

@@ -1,7 +1,6 @@
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 {
@@ -9,7 +8,6 @@ export class SchedulerService {
constructor(
private readonly slotGenerator: SlotGeneratorService,
private readonly flashSaleService: FlashSaleService,
) {}
/** 02:00 daily — generate slots 14 days ahead from week templates */
@@ -55,17 +53,4 @@ 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

@@ -46,19 +46,6 @@ export enum OrderStatus {
REFUNDED = 'REFUNDED',
}
// ===== FlashSale =====
export enum FlashSaleStatus {
DRAFT = 'DRAFT',
ACTIVE = 'ACTIVE',
ENDED = 'ENDED',
}
export enum FlashSaleOrderStatus {
RESERVED = 'RESERVED',
PAID = 'PAID',
EXPIRED = 'EXPIRED',
}
// ===== Invite =====
export enum InviteReferralStatus {
REGISTERED = 'REGISTERED',

View File

@@ -7,8 +7,6 @@ export {
TimeSlotSource,
BookingStatus,
OrderStatus,
FlashSaleStatus,
FlashSaleOrderStatus,
InviteReferralStatus,
SubscriptionMessageScene,
} from './enums'
@@ -104,13 +102,6 @@ export type {
PaginatedData,
PaginatedResponse,
PaginationQuery,
FlashSale,
FlashSaleListItem,
FlashSaleDetail,
FlashSaleAdminItem,
CreateFlashSaleDto,
UpdateFlashSaleDto,
FlashSalePurchaseResponse,
InviteActivityReferral,
InviteRewardGrantRecord,
InviteActivitySummary,
@@ -122,6 +113,4 @@ export type {
SubscriptionMessageConsentSummary,
} from './types/index'
export { FlashSalePhase } from './types/index'
export * from './types/member-care'

View File

@@ -1,97 +0,0 @@
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

@@ -44,20 +44,10 @@ export type {
StudioUploadCredential,
} from './studio'
export type { ApiResponse, PaginatedData, PaginatedResponse, PaginationQuery } from './api'
export type {
FlashSale,
FlashSaleListItem,
FlashSaleDetail,
FlashSaleAdminItem,
CreateFlashSaleDto,
UpdateFlashSaleDto,
FlashSalePurchaseResponse,
} from './flash-sale'
export type {
InviteActivityReferral,
InviteRewardGrantRecord,
InviteActivitySummary,
} from './invite'
export { FlashSalePhase } from './flash-sale'
export type { TeachingAnalytics, TeachingAnalyticsRecord, TeachingAnalyticsSummary } from './teaching-analytics'