feat: add comprehensive membership and stored value mall

This commit is contained in:
gqt
2026-07-12 20:38:33 +08:00
parent f3f8aee9a0
commit 62d59a2f03
6 changed files with 339 additions and 1 deletions
+2 -1
View File
@@ -13,7 +13,8 @@
"verify:home-switch": "node scripts/verify-home-venue-switch.mjs",
"verify:comprehensive-auth": "node scripts/verify-comprehensive-auth-state.mjs",
"verify:comprehensive-session": "node scripts/verify-comprehensive-session-contract.mjs",
"verify:auth-agreements": "node scripts/verify-auth-agreements.mjs"
"verify:auth-agreements": "node scripts/verify-auth-agreements.mjs",
"verify:comprehensive-commerce": "node scripts/verify-comprehensive-commerce.mjs"
},
"dependencies": {
"@dcloudio/uni-app": "3.0.0-alpha-5000120260211001",
@@ -0,0 +1,34 @@
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, resolve } from 'node:path'
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const srcRoot = resolve(root, 'src')
const failures = []
const expectIncludes = (content, expected, label) => {
if (!content.includes(expected)) failures.push(`${label} is missing: ${expected}`)
}
const membershipApi = readFileSync(resolve(srcRoot, 'modules/comprehensive/api/membership.js'), 'utf8')
const storedValueApi = readFileSync(resolve(srcRoot, 'modules/comprehensive/api/storedValue.js'), 'utf8')
const pagesJson = readFileSync(resolve(srcRoot, 'pages.json'), 'utf8')
const mallPage = readFileSync(resolve(srcRoot, 'pages/comprehensive/mall/index.vue'), 'utf8')
expectIncludes(membershipApi, "url: '/api/membership/plans'", 'membership plans')
expectIncludes(membershipApi, "url: '/api/membership/orders'", 'membership order')
expectIncludes(membershipApi, 'data: { plan_id: planId }', 'membership payload')
expectIncludes(storedValueApi, "url: '/api/stored-value/cards'", 'stored value cards')
expectIncludes(storedValueApi, "url: '/api/stored-value/orders'", 'stored value order')
expectIncludes(storedValueApi, 'data: { card_id: cardId }', 'stored value payload')
expectIncludes(pagesJson, 'pages/comprehensive/mall/index', 'mall route')
expectIncludes(mallPage, '储蓄卡', 'stored value UI copy')
expectIncludes(mallPage, 'face_value', 'face value display')
if (failures.length) {
console.error(`Comprehensive commerce verification failed with ${failures.length} issue(s):`)
for (const failure of failures) console.error(`- ${failure}`)
process.exit(1)
}
console.log('Comprehensive commerce verification passed.')
@@ -0,0 +1,31 @@
import { request } from './request'
import { toArray, toNumber } from './mappers'
import { toOrderBrief } from './order'
const toPlan = (item = {}) => ({
id: item.id ?? null,
name: item.name || '',
price: item.price || '0.00',
total_count: toNumber(item.total_count),
is_active: item.is_active === true,
courses: toArray(item.courses).map((course) => ({
id: course.id ?? null,
title: course.title || ''
})),
created_at: item.created_at || ''
})
export const membershipApi = {
getPlans() {
return request({
url: '/api/membership/plans'
}).then((data) => toArray(data.items || data).map(toPlan))
},
createMembershipOrder(planId) {
return request({
url: '/api/membership/orders',
method: 'POST',
data: { plan_id: planId }
}).then(toOrderBrief)
}
}
@@ -0,0 +1,27 @@
import { request } from './request'
import { toArray } from './mappers'
import { toOrderBrief } from './order'
const toStoredValueCard = (item = {}) => ({
id: item.id ?? null,
name: item.name || '',
price: item.price || '0.00',
face_value: item.face_value || '0.00',
is_active: item.is_active === true,
created_at: item.created_at || ''
})
export const storedValueApi = {
getCards() {
return request({
url: '/api/stored-value/cards'
}).then((data) => toArray(data.items || data).map(toStoredValueCard))
},
createOrder(cardId) {
return request({
url: '/api/stored-value/orders',
method: 'POST',
data: { card_id: cardId }
}).then(toOrderBrief)
}
}
+7
View File
@@ -137,6 +137,13 @@
"navigationBarTitleText": "个人信息"
}
},
{
"path": "pages/comprehensive/mall/index",
"style": {
"navigationBarTitleText": "商城",
"enablePullDownRefresh": true
}
},
{
"path": "pages/ice/home/index",
"style": {
@@ -0,0 +1,238 @@
<template>
<view class="mall-page">
<view class="section">
<view class="section-header">
<text class="section-title">次卡套餐</text>
</view>
<view class="card-list">
<view v-for="plan in plans" :key="`plan-${plan.id}`" class="card plan-card">
<view class="card-top">
<text class="item-name">{{ plan.name }}</text>
<text class="item-price">¥{{ plan.price }}</text>
</view>
<text class="item-meta">总次数 {{ plan.total_count }}</text>
<view v-if="plan.courses.length" class="course-tags">
<text v-for="course in plan.courses" :key="course.id" class="course-tag">{{ course.title }}</text>
</view>
<button
class="primary-button buy-btn"
:disabled="submittingId === `plan-${plan.id}`"
@tap="buyPlan(plan)"
>
{{ submittingId === `plan-${plan.id}` ? '提交中...' : '立即购买' }}
</button>
</view>
<view v-if="!isLoading && plans.length === 0" class="empty-state">暂无可购买的次卡套餐</view>
</view>
</view>
<view class="section">
<view class="section-header">
<text class="section-title">储蓄卡</text>
</view>
<view class="card-list">
<view v-for="card in cards" :key="`card-${card.id}`" class="card value-card">
<view class="card-top">
<text class="item-name">{{ card.name }}</text>
<text class="item-price">¥{{ card.price }}</text>
</view>
<view class="value-row">
<text class="value-label">支付</text>
<text class="value-pay">¥{{ card.price }}</text>
<text class="value-arrow">到账</text>
<text class="value-face">¥{{ card.face_value }}</text>
</view>
<button
class="primary-button buy-btn"
:disabled="submittingId === `card-${card.id}`"
@tap="buyCard(card)"
>
{{ submittingId === `card-${card.id}` ? '提交中...' : '立即购买' }}
</button>
</view>
<view v-if="!isLoading && cards.length === 0" class="empty-state">暂无可购买的储蓄卡</view>
</view>
</view>
<view v-if="isLoading" class="load-more-tip">加载中...</view>
</view>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { onPullDownRefresh } from '@dcloudio/uni-app'
import { membershipApi } from '@/modules/comprehensive/api/membership'
import { storedValueApi } from '@/modules/comprehensive/api/storedValue'
import { useComprehensiveSessionStore } from '@/modules/comprehensive/stores/session'
const session = useComprehensiveSessionStore()
const plans = ref([])
const cards = ref([])
const isLoading = ref(false)
const submittingId = ref('')
onMounted(async () => {
await session.init()
await loadPage()
})
onPullDownRefresh(() => {
loadPage().finally(() => uni.stopPullDownRefresh())
})
const loadPage = async () => {
isLoading.value = true
try {
const [planData, cardData] = await Promise.all([
membershipApi.getPlans(),
storedValueApi.getCards()
])
plans.value = planData
cards.value = cardData
} finally {
isLoading.value = false
}
}
const ensureLogin = async () => {
if (session.isLoggedIn) return true
try {
await session.login()
return true
} catch (error) {
uni.showToast({ title: '登录失败,请重试', icon: 'none' })
return false
}
}
const buyPlan = async (plan) => {
if (submittingId.value) return
if (!(await ensureLogin())) return
submittingId.value = `plan-${plan.id}`
try {
const order = await membershipApi.createMembershipOrder(plan.id)
uni.navigateTo({ url: `/pages/comprehensive/pay/index?order_id=${order.id}` })
} finally {
submittingId.value = ''
}
}
const buyCard = async (card) => {
if (submittingId.value) return
if (!(await ensureLogin())) return
submittingId.value = `card-${card.id}`
try {
const order = await storedValueApi.createOrder(card.id)
uni.navigateTo({ url: `/pages/comprehensive/pay/index?order_id=${order.id}` })
} finally {
submittingId.value = ''
}
}
</script>
<style lang="scss" scoped>
.mall-page {
min-height: 100vh;
padding: $spacing-md $spacing-md calc($spacing-lg + env(safe-area-inset-bottom));
background: $bg-color-soft;
}
.section {
margin-bottom: $spacing-lg;
}
.section-header {
margin-bottom: $spacing-sm;
}
.section-title {
color: $text-color-main;
font-size: 32rpx;
font-weight: 900;
}
.card-list {
display: flex;
flex-direction: column;
gap: $spacing-md;
}
.plan-card,
.value-card {
display: flex;
flex-direction: column;
}
.card-top {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: $spacing-sm;
}
.item-name {
flex: 1;
color: $text-color-main;
font-size: 30rpx;
font-weight: 900;
line-height: 1.35;
}
.item-price {
color: $color-primary;
font-size: 36rpx;
font-weight: 900;
}
.item-meta {
display: block;
margin-top: $spacing-sm;
color: $text-color-muted;
font-size: 24rpx;
}
.course-tags {
display: flex;
flex-wrap: wrap;
gap: $spacing-xs;
margin-top: $spacing-sm;
}
.course-tag {
padding: 4rpx 14rpx;
border-radius: $border-radius-sm;
background: $color-primary-light;
color: $color-primary;
font-size: 22rpx;
font-weight: 700;
}
.value-row {
display: flex;
align-items: center;
gap: $spacing-xs;
margin-top: $spacing-sm;
color: $text-color-muted;
font-size: 24rpx;
}
.value-label,
.value-arrow {
color: $text-color-light;
}
.value-pay {
color: $text-color-main;
font-weight: 800;
}
.value-face {
color: $color-success;
font-weight: 900;
}
.buy-btn {
margin-top: $spacing-md;
height: 80rpx;
}
</style>