perf: 移除秒杀相关功能
This commit is contained in:
15
.deploy-tmp/check-fk.js
Normal file
15
.deploy-tmp/check-fk.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
// One-off diagnostic — list FK constraints on orders.flash_sale_id
|
||||||
|
// Run from packages/server so .env is loaded.
|
||||||
|
const { PrismaClient } = require('@prisma/client')
|
||||||
|
const p = new PrismaClient()
|
||||||
|
;(async () => {
|
||||||
|
const rows = await p.$queryRawUnsafe(`
|
||||||
|
SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE
|
||||||
|
FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME IN ('orders', 'flash_sales', 'flash_sale_orders')
|
||||||
|
ORDER BY TABLE_NAME, ORDINAL_POSITION
|
||||||
|
`)
|
||||||
|
console.log('FK rows:', JSON.stringify(rows, null, 2))
|
||||||
|
await p.$disconnect()
|
||||||
|
})().catch((e) => { console.error(e); process.exit(1) })
|
||||||
33
.deploy-tmp/resolve-migration.js
Normal file
33
.deploy-tmp/resolve-migration.js
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
// Mark the failed drop_flash_sale migration as rolled_back so deploy can retry.
|
||||||
|
const { PrismaClient } = require('@prisma/client')
|
||||||
|
const p = new PrismaClient()
|
||||||
|
const dump = (x) => JSON.stringify(x, (_, v) => typeof v === 'bigint' ? v.toString() : v, 2)
|
||||||
|
;(async () => {
|
||||||
|
const before = await p.$queryRawUnsafe(
|
||||||
|
`SELECT migration_name, finished_at, rolled_back_at, applied_steps_count
|
||||||
|
FROM _prisma_migrations
|
||||||
|
WHERE migration_name = ?`,
|
||||||
|
'20260910030338_drop_flash_sale'
|
||||||
|
)
|
||||||
|
console.log('before:', dump(before))
|
||||||
|
|
||||||
|
const result = await p.$executeRawUnsafe(
|
||||||
|
`UPDATE _prisma_migrations
|
||||||
|
SET rolled_back_at = NOW()
|
||||||
|
WHERE migration_name = ?
|
||||||
|
AND finished_at IS NULL
|
||||||
|
AND rolled_back_at IS NULL`,
|
||||||
|
'20260910030338_drop_flash_sale'
|
||||||
|
)
|
||||||
|
console.log('rows updated:', result)
|
||||||
|
|
||||||
|
const after = await p.$queryRawUnsafe(
|
||||||
|
`SELECT migration_name, finished_at, rolled_back_at, applied_steps_count
|
||||||
|
FROM _prisma_migrations
|
||||||
|
WHERE migration_name = ?`,
|
||||||
|
'20260910030338_drop_flash_sale'
|
||||||
|
)
|
||||||
|
console.log('after:', dump(after))
|
||||||
|
|
||||||
|
await p.$disconnect()
|
||||||
|
})().catch((e) => { console.error(e); process.exit(1) })
|
||||||
27
.deploy-tmp/verify-migration.js
Normal file
27
.deploy-tmp/verify-migration.js
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
// Verify migration result on production
|
||||||
|
const { PrismaClient } = require('@prisma/client')
|
||||||
|
const p = new PrismaClient()
|
||||||
|
;(async () => {
|
||||||
|
const tables = await p.$queryRawUnsafe(`
|
||||||
|
SELECT TABLE_NAME
|
||||||
|
FROM information_schema.TABLES
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME LIKE '%flash%'
|
||||||
|
`)
|
||||||
|
const ordersCol = await p.$queryRawUnsafe(`
|
||||||
|
SELECT COLUMN_NAME
|
||||||
|
FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'orders'
|
||||||
|
AND COLUMN_NAME = 'flash_sale_id'
|
||||||
|
`)
|
||||||
|
const mig = await p.$queryRawUnsafe(`
|
||||||
|
SELECT migration_name, finished_at, rolled_back_at
|
||||||
|
FROM _prisma_migrations
|
||||||
|
WHERE migration_name = '20260910030338_drop_flash_sale'
|
||||||
|
`)
|
||||||
|
console.log('flash tables:', tables)
|
||||||
|
console.log('orders.flash_sale_id col:', ordersCol)
|
||||||
|
console.log('migration row:', mig)
|
||||||
|
await p.$disconnect()
|
||||||
|
})().catch((e) => { console.error(e); process.exit(1) })
|
||||||
41
docs/flash-sale-removal.md
Normal file
41
docs/flash-sale-removal.md
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# 移除秒杀功能
|
||||||
|
|
||||||
|
按业务调整下线限时秒杀活动。前后端代码、共享类型、Prisma schema 与迁移均已同步清理。
|
||||||
|
|
||||||
|
## 清理范围
|
||||||
|
|
||||||
|
| 层 | 内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| 后端模块 | `packages/server/src/flash-sale/`(service、controller、admin controller、dto、测试) |
|
||||||
|
| 调度任务 | `SchedulerService.handleExpireFlashSaleReservations` 及其依赖注入 |
|
||||||
|
| 支付流程 | `PaymentService` 中 `flashSaleOrder` 标记为 PAID 的分支 |
|
||||||
|
| 前端页面 | `pages/flash-sale/detail.vue`、`pages/admin/flash-sales.vue` |
|
||||||
|
| 前端组件 | `components/FlashSaleSection.vue` |
|
||||||
|
| 前端状态 | `stores/flash-sale.ts` |
|
||||||
|
| 前端入口 | `pages/admin/index.vue` 秒杀管理菜单与对应样式 |
|
||||||
|
| 首页 | `pages/home/index.vue` 中 `FlashSaleSection` 引用 |
|
||||||
|
| 共享类型 | `shared/src/types/flash-sale.ts`,`enums.ts` 中 `FlashSaleStatus`、`FlashSaleOrderStatus`,以及 `index.ts` 的 re-export |
|
||||||
|
| 数据库 | Prisma 删除 `FlashSale`、`FlashSaleOrder` 模型、相关枚举与 `Order.flash_sale_id` 字段 |
|
||||||
|
|
||||||
|
## 数据库迁移
|
||||||
|
|
||||||
|
新增 `packages/server/prisma/migrations/20260910030338_drop_flash_sale/migration.sql`。
|
||||||
|
执行 `pnpm prisma migrate deploy` 后以下内容会被移除:
|
||||||
|
|
||||||
|
- `flash_sales`、`flash_sale_orders` 两张表
|
||||||
|
- `orders.flash_sale_id` 列与外键
|
||||||
|
- `FlashSaleStatus`、`FlashSaleOrderStatus` 枚举
|
||||||
|
|
||||||
|
迁移以 MySQL 方言编写;SQLite 开发环境由 Prisma shadow database 自动重建。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm build:shared
|
||||||
|
pnpm build:server
|
||||||
|
pnpm test # 包含 payment / scheduler 套件
|
||||||
|
```
|
||||||
|
|
||||||
|
## 回退说明
|
||||||
|
|
||||||
|
回退需要还原 schema 与源码,本项目不保留 git 自动恢复外的额外兜底。如需重新启用秒杀,按 git 历史恢复即可,并运行 `pnpm prisma migrate reset` 重新初始化数据库。
|
||||||
@@ -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>
|
|
||||||
@@ -75,12 +75,6 @@
|
|||||||
"navigationStyle": "custom"
|
"navigationStyle": "custom"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"path": "pages/flash-sale/detail",
|
|
||||||
"style": {
|
|
||||||
"navigationStyle": "custom"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"path": "pages/profile/progress",
|
"path": "pages/profile/progress",
|
||||||
"style": {
|
"style": {
|
||||||
@@ -171,12 +165,6 @@
|
|||||||
"navigationStyle": "custom"
|
"navigationStyle": "custom"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"path": "flash-sales",
|
|
||||||
"style": {
|
|
||||||
"navigationStyle": "custom"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"path": "member-progress",
|
"path": "member-progress",
|
||||||
"style": {
|
"style": {
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -127,21 +127,6 @@
|
|||||||
<text class="arrow-text">›</text>
|
<text class="arrow-text">›</text>
|
||||||
</view>
|
</view>
|
||||||
</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>
|
</view>
|
||||||
|
|
||||||
<!-- Section header: 系统 -->
|
<!-- Section header: 系统 -->
|
||||||
@@ -386,7 +371,6 @@ onMounted(() => {
|
|||||||
.icon--members { background: linear-gradient(135deg, $primary-color, $primary-dark); }
|
.icon--members { background: linear-gradient(135deg, $primary-color, $primary-dark); }
|
||||||
.icon--orders { background: linear-gradient(135deg, #7E9EC4, #6E8EB4); }
|
.icon--orders { background: linear-gradient(135deg, #7E9EC4, #6E8EB4); }
|
||||||
.icon--card { background: linear-gradient(135deg, #C48E7E, #B47E6E); }
|
.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--studio { background: linear-gradient(135deg, #9E9E7E, #8E8E6E); }
|
||||||
.icon--subscribe { background: linear-gradient(135deg, #5D8C8A, #476D72); }
|
.icon--subscribe { background: linear-gradient(135deg, #5D8C8A, #476D72); }
|
||||||
|
|
||||||
|
|||||||
@@ -15,9 +15,6 @@ import type {
|
|||||||
PaginatedData,
|
PaginatedData,
|
||||||
ScheduleSlotPreview,
|
ScheduleSlotPreview,
|
||||||
PublishDaySlotsDto,
|
PublishDaySlotsDto,
|
||||||
FlashSaleAdminItem,
|
|
||||||
CreateFlashSaleDto,
|
|
||||||
UpdateFlashSaleDto,
|
|
||||||
CreateStudioUploadCredentialDto,
|
CreateStudioUploadCredentialDto,
|
||||||
StudioUploadCredential,
|
StudioUploadCredential,
|
||||||
AdminMemberSummary,
|
AdminMemberSummary,
|
||||||
@@ -272,26 +269,7 @@ export const useAdminStore = defineStore('admin', () => {
|
|||||||
return get<AdminStats>('/admin/stats')
|
return get<AdminStats>('/admin/stats')
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Flash sales ─────────────────────────────────────────────────
|
// ── Teaching analytics ─────────────────────────────────────────
|
||||||
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}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchTeachingAnalytics(month: string): Promise<TeachingAnalytics> {
|
async function fetchTeachingAnalytics(month: string): Promise<TeachingAnalytics> {
|
||||||
return get<TeachingAnalytics>('/admin/teaching-analytics', { month })
|
return get<TeachingAnalytics>('/admin/teaching-analytics', { month })
|
||||||
}
|
}
|
||||||
@@ -339,10 +317,5 @@ export const useAdminStore = defineStore('admin', () => {
|
|||||||
publishDaySlots,
|
publishDaySlots,
|
||||||
// Stats
|
// Stats
|
||||||
fetchDashboardStats,
|
fetchDashboardStats,
|
||||||
// Flash sales
|
|
||||||
fetchFlashSales,
|
|
||||||
createFlashSale,
|
|
||||||
updateFlashSale,
|
|
||||||
deleteFlashSale,
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -13,7 +13,6 @@
|
|||||||
<UpcomingBooking />
|
<UpcomingBooking />
|
||||||
<ReviewSummaryCard />
|
<ReviewSummaryCard />
|
||||||
<StudioInfo :studio-info="studioStore.studioInfo" />
|
<StudioInfo :studio-info="studioStore.studioInfo" />
|
||||||
<FlashSaleSection ref="flashSaleRef" />
|
|
||||||
<view :id="cardShopAnchorId">
|
<view :id="cardShopAnchorId">
|
||||||
<CardShop ref="cardShopRef" />
|
<CardShop ref="cardShopRef" />
|
||||||
</view>
|
</view>
|
||||||
@@ -33,7 +32,6 @@ import ReviewSummaryCard from '../../components/ReviewSummaryCard.vue'
|
|||||||
import StudioInfo from '../../components/StudioInfo.vue'
|
import StudioInfo from '../../components/StudioInfo.vue'
|
||||||
import QuickEntry from '../../components/QuickEntry.vue'
|
import QuickEntry from '../../components/QuickEntry.vue'
|
||||||
import UpcomingBooking from '../../components/UpcomingBooking.vue'
|
import UpcomingBooking from '../../components/UpcomingBooking.vue'
|
||||||
import FlashSaleSection from '../../components/FlashSaleSection.vue'
|
|
||||||
import CardShop from '../../components/CardShop.vue'
|
import CardShop from '../../components/CardShop.vue'
|
||||||
import AboutSection from '../../components/AboutSection.vue'
|
import AboutSection from '../../components/AboutSection.vue'
|
||||||
|
|
||||||
@@ -64,7 +62,6 @@ onShareTimeline(() => {
|
|||||||
// ─── Layout ───────────────────────────────────────────────
|
// ─── Layout ───────────────────────────────────────────────
|
||||||
const refreshing = ref(false)
|
const refreshing = ref(false)
|
||||||
const cardShopRef = ref<InstanceType<typeof CardShop> | null>(null)
|
const cardShopRef = ref<InstanceType<typeof CardShop> | null>(null)
|
||||||
const flashSaleRef = ref<InstanceType<typeof FlashSaleSection> | null>(null)
|
|
||||||
const cardShopAnchorId = 'card-shop-anchor'
|
const cardShopAnchorId = 'card-shop-anchor'
|
||||||
const scrollTarget = ref('')
|
const scrollTarget = ref('')
|
||||||
const pageHeight = ref(`${uni.getWindowInfo().windowHeight}px`)
|
const pageHeight = ref(`${uni.getWindowInfo().windowHeight}px`)
|
||||||
@@ -105,10 +102,9 @@ async function refreshData() {
|
|||||||
|
|
||||||
await Promise.allSettled(tasks)
|
await Promise.allSettled(tasks)
|
||||||
|
|
||||||
// Also refresh card shop and flash sales
|
// Also refresh card shop
|
||||||
await Promise.allSettled([
|
await Promise.allSettled([
|
||||||
cardShopRef.value?.fetchCardTypes(),
|
cardShopRef.value?.fetchCardTypes(),
|
||||||
flashSaleRef.value?.fetchFlashSales(),
|
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { CardTypeCategory } from '@mp-pilates/shared'
|
import { CardTypeCategory } from '@mp-pilates/shared'
|
||||||
import { FlashSalePhase } from '@mp-pilates/shared'
|
|
||||||
|
|
||||||
/** Minimal membership shape needed by progress/usage helpers. */
|
/** Minimal membership shape needed by progress/usage helpers. */
|
||||||
interface MembershipLike {
|
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 {
|
export function getStockRatio(soldCount: number, totalStock: number): number {
|
||||||
if (totalStock === 0) return 0
|
if (totalStock === 0) return 0
|
||||||
|
|||||||
@@ -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`;
|
||||||
@@ -51,18 +51,6 @@ enum OrderStatus {
|
|||||||
REFUNDED
|
REFUNDED
|
||||||
}
|
}
|
||||||
|
|
||||||
enum FlashSaleStatus {
|
|
||||||
DRAFT
|
|
||||||
ACTIVE
|
|
||||||
ENDED
|
|
||||||
}
|
|
||||||
|
|
||||||
enum FlashSaleOrderStatus {
|
|
||||||
RESERVED
|
|
||||||
PAID
|
|
||||||
EXPIRED
|
|
||||||
}
|
|
||||||
|
|
||||||
enum InviteReferralStatus {
|
enum InviteReferralStatus {
|
||||||
REGISTERED
|
REGISTERED
|
||||||
TRIAL_PURCHASED
|
TRIAL_PURCHASED
|
||||||
@@ -92,7 +80,6 @@ model User {
|
|||||||
memberships Membership[]
|
memberships Membership[]
|
||||||
bookings Booking[]
|
bookings Booking[]
|
||||||
orders Order[]
|
orders Order[]
|
||||||
flashSaleOrders FlashSaleOrder[]
|
|
||||||
subscriptionMessageConsents SubscriptionMessageConsent[]
|
subscriptionMessageConsents SubscriptionMessageConsent[]
|
||||||
sentInviteReferrals InviteReferral[] @relation("InviteReferralInviter")
|
sentInviteReferrals InviteReferral[] @relation("InviteReferralInviter")
|
||||||
receivedInviteReferral InviteReferral[] @relation("InviteReferralInvitee")
|
receivedInviteReferral InviteReferral[] @relation("InviteReferralInvitee")
|
||||||
@@ -143,7 +130,6 @@ model CardType {
|
|||||||
|
|
||||||
memberships Membership[]
|
memberships Membership[]
|
||||||
orders Order[]
|
orders Order[]
|
||||||
flashSales FlashSale[]
|
|
||||||
|
|
||||||
@@map("card_types")
|
@@map("card_types")
|
||||||
}
|
}
|
||||||
@@ -269,14 +255,12 @@ model Order {
|
|||||||
status OrderStatus @default(PENDING)
|
status OrderStatus @default(PENDING)
|
||||||
wxTransactionId String? @map("wx_transaction_id")
|
wxTransactionId String? @map("wx_transaction_id")
|
||||||
paidAt DateTime? @map("paid_at")
|
paidAt DateTime? @map("paid_at")
|
||||||
flashSaleId String? @map("flash_sale_id")
|
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id])
|
user User @relation(fields: [userId], references: [id])
|
||||||
cardType CardType @relation(fields: [cardTypeId], references: [id])
|
cardType CardType @relation(fields: [cardTypeId], references: [id])
|
||||||
membership Membership? @relation(fields: [membershipId], references: [id])
|
membership Membership? @relation(fields: [membershipId], references: [id])
|
||||||
flashSaleOrder FlashSaleOrder?
|
|
||||||
inviteReferrals InviteReferral[]
|
inviteReferrals InviteReferral[]
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@ -342,51 +326,6 @@ model StudioConfig {
|
|||||||
@@map("studio_config")
|
@@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.
|
// Historical totals without fabricated scheduled dates.
|
||||||
model LessonSupplement {
|
model LessonSupplement {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import { BookingModule } from './booking/booking.module'
|
|||||||
import { SchedulerModule } from './scheduler/scheduler.module'
|
import { SchedulerModule } from './scheduler/scheduler.module'
|
||||||
import { PaymentModule } from './payment/payment.module'
|
import { PaymentModule } from './payment/payment.module'
|
||||||
import { AdminModule } from './admin/admin.module'
|
import { AdminModule } from './admin/admin.module'
|
||||||
import { FlashSaleModule } from './flash-sale/flash-sale.module'
|
|
||||||
import { InviteModule } from './invite/invite.module'
|
import { InviteModule } from './invite/invite.module'
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -30,7 +29,6 @@ import { InviteModule } from './invite/invite.module'
|
|||||||
SchedulerModule,
|
SchedulerModule,
|
||||||
PaymentModule,
|
PaymentModule,
|
||||||
AdminModule,
|
AdminModule,
|
||||||
FlashSaleModule,
|
|
||||||
InviteModule,
|
InviteModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
|
|||||||
@@ -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 }))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 {}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -90,9 +90,6 @@ function buildPrismaMock() {
|
|||||||
findFirst: jest.fn(),
|
findFirst: jest.fn(),
|
||||||
update: jest.fn(),
|
update: jest.fn(),
|
||||||
},
|
},
|
||||||
flashSaleOrder: {
|
|
||||||
updateMany: jest.fn(),
|
|
||||||
},
|
|
||||||
$transaction: jest.fn(),
|
$transaction: jest.fn(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common'
|
} from '@nestjs/common'
|
||||||
import { CardType, Order } from '@prisma/client'
|
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 { PrismaService } from '../prisma/prisma.service'
|
||||||
import { WechatPayService, WxPaymentParams } from './wechat-pay.service'
|
import { WechatPayService, WxPaymentParams } from './wechat-pay.service'
|
||||||
import { InviteService } from '../invite/invite.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}`)
|
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()
|
return this.buildSuccessXml()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { FlashSaleService } from '../../flash-sale/flash-sale.service'
|
|
||||||
import { Test, TestingModule } from '@nestjs/testing'
|
import { Test, TestingModule } from '@nestjs/testing'
|
||||||
import { Logger } from '@nestjs/common'
|
import { Logger } from '@nestjs/common'
|
||||||
import { SchedulerService } from '../scheduler.service'
|
import { SchedulerService } from '../scheduler.service'
|
||||||
@@ -34,7 +33,6 @@ describe('SchedulerService', () => {
|
|||||||
providers: [
|
providers: [
|
||||||
SchedulerService,
|
SchedulerService,
|
||||||
{ provide: SlotGeneratorService, useValue: mockSlotGenerator },
|
{ provide: SlotGeneratorService, useValue: mockSlotGenerator },
|
||||||
{ provide: FlashSaleService, useValue: { expireUnpaidReservations: jest.fn() } },
|
|
||||||
],
|
],
|
||||||
}).compile()
|
}).compile()
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { ReviewReminderService } from './review-reminder.service'
|
|||||||
import { Module } from '@nestjs/common'
|
import { Module } from '@nestjs/common'
|
||||||
import { ScheduleModule } from '@nestjs/schedule'
|
import { ScheduleModule } from '@nestjs/schedule'
|
||||||
import { TimeSlotModule } from '../time-slot/time-slot.module'
|
import { TimeSlotModule } from '../time-slot/time-slot.module'
|
||||||
import { FlashSaleModule } from '../flash-sale/flash-sale.module'
|
|
||||||
import { SchedulerService } from './scheduler.service'
|
import { SchedulerService } from './scheduler.service'
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -12,7 +11,6 @@ import { SchedulerService } from './scheduler.service'
|
|||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
UserModule, ConfigModule,
|
UserModule, ConfigModule,
|
||||||
TimeSlotModule,
|
TimeSlotModule,
|
||||||
FlashSaleModule,
|
|
||||||
],
|
],
|
||||||
providers: [SchedulerService, ReviewReminderService],
|
providers: [SchedulerService, ReviewReminderService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common'
|
import { Injectable, Logger } from '@nestjs/common'
|
||||||
import { Cron } from '@nestjs/schedule'
|
import { Cron } from '@nestjs/schedule'
|
||||||
import { SlotGeneratorService } from '../time-slot/slot-generator.service'
|
import { SlotGeneratorService } from '../time-slot/slot-generator.service'
|
||||||
import { FlashSaleService } from '../flash-sale/flash-sale.service'
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SchedulerService {
|
export class SchedulerService {
|
||||||
@@ -9,7 +8,6 @@ export class SchedulerService {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly slotGenerator: SlotGeneratorService,
|
private readonly slotGenerator: SlotGeneratorService,
|
||||||
private readonly flashSaleService: FlashSaleService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** 02:00 daily — generate slots 14 days ahead from week templates */
|
/** 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)
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,19 +46,6 @@ export enum OrderStatus {
|
|||||||
REFUNDED = 'REFUNDED',
|
REFUNDED = 'REFUNDED',
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== FlashSale =====
|
|
||||||
export enum FlashSaleStatus {
|
|
||||||
DRAFT = 'DRAFT',
|
|
||||||
ACTIVE = 'ACTIVE',
|
|
||||||
ENDED = 'ENDED',
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum FlashSaleOrderStatus {
|
|
||||||
RESERVED = 'RESERVED',
|
|
||||||
PAID = 'PAID',
|
|
||||||
EXPIRED = 'EXPIRED',
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Invite =====
|
// ===== Invite =====
|
||||||
export enum InviteReferralStatus {
|
export enum InviteReferralStatus {
|
||||||
REGISTERED = 'REGISTERED',
|
REGISTERED = 'REGISTERED',
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ export {
|
|||||||
TimeSlotSource,
|
TimeSlotSource,
|
||||||
BookingStatus,
|
BookingStatus,
|
||||||
OrderStatus,
|
OrderStatus,
|
||||||
FlashSaleStatus,
|
|
||||||
FlashSaleOrderStatus,
|
|
||||||
InviteReferralStatus,
|
InviteReferralStatus,
|
||||||
SubscriptionMessageScene,
|
SubscriptionMessageScene,
|
||||||
} from './enums'
|
} from './enums'
|
||||||
@@ -104,13 +102,6 @@ export type {
|
|||||||
PaginatedData,
|
PaginatedData,
|
||||||
PaginatedResponse,
|
PaginatedResponse,
|
||||||
PaginationQuery,
|
PaginationQuery,
|
||||||
FlashSale,
|
|
||||||
FlashSaleListItem,
|
|
||||||
FlashSaleDetail,
|
|
||||||
FlashSaleAdminItem,
|
|
||||||
CreateFlashSaleDto,
|
|
||||||
UpdateFlashSaleDto,
|
|
||||||
FlashSalePurchaseResponse,
|
|
||||||
InviteActivityReferral,
|
InviteActivityReferral,
|
||||||
InviteRewardGrantRecord,
|
InviteRewardGrantRecord,
|
||||||
InviteActivitySummary,
|
InviteActivitySummary,
|
||||||
@@ -122,6 +113,4 @@ export type {
|
|||||||
SubscriptionMessageConsentSummary,
|
SubscriptionMessageConsentSummary,
|
||||||
} from './types/index'
|
} from './types/index'
|
||||||
|
|
||||||
export { FlashSalePhase } from './types/index'
|
|
||||||
|
|
||||||
export * from './types/member-care'
|
export * from './types/member-care'
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -44,20 +44,10 @@ export type {
|
|||||||
StudioUploadCredential,
|
StudioUploadCredential,
|
||||||
} from './studio'
|
} from './studio'
|
||||||
export type { ApiResponse, PaginatedData, PaginatedResponse, PaginationQuery } from './api'
|
export type { ApiResponse, PaginatedData, PaginatedResponse, PaginationQuery } from './api'
|
||||||
export type {
|
|
||||||
FlashSale,
|
|
||||||
FlashSaleListItem,
|
|
||||||
FlashSaleDetail,
|
|
||||||
FlashSaleAdminItem,
|
|
||||||
CreateFlashSaleDto,
|
|
||||||
UpdateFlashSaleDto,
|
|
||||||
FlashSalePurchaseResponse,
|
|
||||||
} from './flash-sale'
|
|
||||||
export type {
|
export type {
|
||||||
InviteActivityReferral,
|
InviteActivityReferral,
|
||||||
InviteRewardGrantRecord,
|
InviteRewardGrantRecord,
|
||||||
InviteActivitySummary,
|
InviteActivitySummary,
|
||||||
} from './invite'
|
} from './invite'
|
||||||
export { FlashSalePhase } from './flash-sale'
|
|
||||||
|
|
||||||
export type { TeachingAnalytics, TeachingAnalyticsRecord, TeachingAnalyticsSummary } from './teaching-analytics'
|
export type { TeachingAnalytics, TeachingAnalyticsRecord, TeachingAnalyticsSummary } from './teaching-analytics'
|
||||||
|
|||||||
Reference in New Issue
Block a user