init: 初始化

This commit is contained in:
richarjiang
2026-04-04 10:03:06 +08:00
parent 7a06b5e336
commit 817d5a85c5
14 changed files with 478 additions and 394 deletions

99
CLAUDE.md Normal file
View File

@@ -0,0 +1,99 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 项目概述
普拉提工作室预约与会员管理的微信小程序。TypeScript monorepo包含三个包
- **packages/server** — NestJS 后端REST API、Prisma ORM、PostgreSQL
- **packages/app** — Vue 3 + Pinia 前端,基于 Uni-app目标平台 mp-weixin
- **packages/shared** — 前后端共用的 TypeScript 类型、枚举和常量
## 常用命令
### 开发
```bash
pnpm dev:server # NestJS watch 模式 (localhost:3000)
pnpm dev:app # 微信小程序开发服务器
pnpm build:shared # 必须先构建 shared再构建 server/app
```
### 测试(仅 server
```bash
cd packages/server
pnpm test # 运行全部测试
pnpm test -- auth.service.spec # 运行单个测试文件
pnpm test:watch # watch 模式
pnpm test:cov # 覆盖率报告
```
Jest 配置内联在 `packages/server/package.json`。测试文件位于 `__tests__/` 子目录(如 `src/auth/__tests__/auth.service.spec.ts`),匹配模式:`*.spec.ts`
### 数据库
```bash
cd packages/server
pnpm prisma:generate # schema 变更后重新生成 Prisma Client
pnpm prisma:migrate # 运行迁移(交互式)
pnpm prisma:seed # 填充种子数据
```
### 代码检查
```bash
pnpm lint # 所有包的 ESLint 检查
```
## 架构
### 数据流
```
微信小程序 → Uni-app (Vue 3) → REST API (NestJS) → Prisma → PostgreSQL
↕ ↕
Pinia stores @nestjs/schedule (定时任务)
```
### 后端模块结构
每个功能是一个 NestJS 模块,遵循 controller → service → Prisma 模式。核心模块:
- **auth** — 微信 OAuth 登录code2Session、JWT 令牌、手机号绑定
- **booking** — 创建/取消预约,含会员卡验证和容量检查
- **time-slot** — 课程时段管理;`SlotGeneratorService` 根据 `WeekTemplate` 自动生成
- **membership** — 基于卡的会员制TIMES 次卡、DURATION 时效卡、TRIAL 体验卡)
- **payment** — 微信支付集成,用于购卡
- **scheduler** — 定时任务02:00 自动生成时段02:30 清理过期时段
### 前端结构
- **pages/** — 按路由组织的页面home、booking、card、profile、admin
- **stores/** — Pinia 状态管理user、booking、studio、admin
- **utils/request.ts** — 封装 `uni.request` 的 HTTP 客户端,自动携带 JWT
- **utils/auth.ts** — 微信登录流程uni.login → 服务端 /auth/login → 存储 token
### Shared 包
所有 API 类型、DTO、枚举和业务常量定义在 `packages/shared/src/`,前后端通过 `@mp-pilates/shared` 引用。路径别名配置在 `tsconfig.base.json` 和 Jest 的 `moduleNameMapper` 中。
### 数据库 Schema
Prisma schema 位于 `packages/server/prisma/schema.prisma`,关键约定:
- Model 用 PascalCase表名用 snake_case`@@map`
- 字段用 camelCase列名用 snake_case`@map`
- 所有 ID 为 UUID
- 金额字段使用 `Decimal(10, 0)`
- 关键唯一约束:`TimeSlot``@@unique([date, startTime, endTime])``Booking``@@unique([userId, timeSlotId])`
### 核心业务规则
- 预约需要有效的会员卡(剩余次数或有效期内)
- 取消预约需在课程开始前 `cancelHoursLimit` 小时(默认 2 小时,可在 StudioConfig 中配置)
- 时段根据 WeekTemplate 自动生成未来 14 天的课程
- 默认时段容量为 1私教课
## 环境配置
需要 Node 20+.nvmrc、pnpm 8+、PostgreSQL。复制 `packages/server/.env.example``.env.local`,需配置 DATABASE_URL、JWT_SECRET 及微信相关凭证APPID、SECRET、MCH_ID、MCH_KEY、证书路径
## 开发约定
- **API 前缀**:所有路由在 `/api`setGlobalPrefix
- **参数校验**:全局 ValidationPipe启用 whitelist + forbidNonWhitelisted + transform
- **鉴权守卫**:受保护路由使用 `@UseGuards(JwtAuthGuard)`,通过 `@Req()` 从 JWT 载荷提取用户
- **角色**MEMBER 和 ADMIN管理员路由使用自定义角色守卫
- **异常处理**:使用 NestJS 内置异常BadRequestException、NotFoundException 等)
- **分页**:统一使用 `PaginatedResponse<T>`,包含 data、total、page、limit
- **pnpm**:使用 `shamefully-hoist=true`.npmrc为 Uni-app 兼容所需

View File

@@ -1,33 +1,42 @@
<template>
<view class="brand-banner" :style="bannerStyle">
<view class="brand-banner">
<!-- Background image layer -->
<image
v-if="studioInfo?.bannerUrl"
class="banner-bg"
:src="studioInfo.bannerUrl"
mode="aspectFill"
/>
<!-- Dark overlay for readability -->
<view class="banner-overlay" />
<!-- Status bar spacer -->
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }" />
<!-- Nav area -->
<view class="nav-bar">
<view class="studio-name-row">
<!-- Centered content -->
<view class="banner-content">
<!-- Circular logo -->
<view class="logo-circle">
<image
v-if="studioInfo?.logo"
class="logo"
class="logo-img"
:src="studioInfo.logo"
mode="aspectFit"
/>
<text class="studio-name">{{ studioInfo?.name || '普拉提工作室' }}</text>
<text v-else class="logo-placeholder">FC</text>
</view>
<text class="studio-slogan">专业 · 精致 · 健康</text>
</view>
<!-- Decorative circles -->
<view class="deco-circle deco-circle--1" />
<view class="deco-circle deco-circle--2" />
<!-- Studio name -->
<text class="studio-name">{{ studioInfo?.name || 'Focus Core' }}</text>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { onMounted, ref } from 'vue'
import type { StudioConfig } from '@mp-pilates/shared'
const props = defineProps<{
defineProps<{
studioInfo: StudioConfig | null
}>()
@@ -37,82 +46,79 @@ onMounted(() => {
const sysInfo = uni.getSystemInfoSync()
statusBarHeight.value = sysInfo.statusBarHeight ?? 20
})
const bannerStyle = computed(() => {
if (props.studioInfo?.bannerUrl) {
return {
backgroundImage: `url(${props.studioInfo.bannerUrl})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}
}
return {}
})
</script>
<style lang="scss" scoped>
.brand-banner {
position: relative;
width: 100%;
min-height: 300rpx;
background: linear-gradient(135deg, #1a1a2e 0%, #2d2d5e 50%, #1a1a2e 100%);
height: 580rpx;
overflow: hidden;
padding-bottom: 40rpx;
background: #2a2a2a;
}
.nav-bar {
.banner-bg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.banner-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.3);
}
.status-bar {
position: relative;
z-index: 2;
padding: 16rpx 40rpx 0;
}
.studio-name-row {
.banner-content {
position: relative;
z-index: 2;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding-top: 40rpx;
}
.logo-circle {
width: 200rpx;
height: 200rpx;
border-radius: 50%;
background: #ffffff;
display: flex;
align-items: center;
gap: 16rpx;
justify-content: center;
overflow: hidden;
box-shadow: 0 8rpx 40rpx rgba(0, 0, 0, 0.2);
}
.logo {
width: 64rpx;
height: 64rpx;
border-radius: 12rpx;
.logo-img {
width: 160rpx;
height: 160rpx;
}
.logo-placeholder {
font-size: 64rpx;
font-weight: 800;
color: #333;
letter-spacing: 4rpx;
}
.studio-name {
font-size: 44rpx;
margin-top: 28rpx;
font-size: 40rpx;
font-weight: 700;
color: #ffffff;
letter-spacing: 2rpx;
}
.studio-slogan {
display: block;
margin-top: 10rpx;
font-size: 24rpx;
color: #c9a87c;
letter-spacing: 6rpx;
}
/* Decorative blurred circles */
.deco-circle {
position: absolute;
border-radius: 50%;
opacity: 0.12;
background: #c9a87c;
}
.deco-circle--1 {
width: 300rpx;
height: 300rpx;
top: -80rpx;
right: -60rpx;
}
.deco-circle--2 {
width: 180rpx;
height: 180rpx;
bottom: -40rpx;
right: 120rpx;
opacity: 0.08;
text-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.3);
}
</style>

View File

@@ -1,84 +1,61 @@
<template>
<view class="card-shop">
<!-- Section header -->
<view class="section-header">
<text class="section-title">会员卡</text>
<text class="section-subtitle">选择适合您的套餐</text>
<text class="section-action" @tap="goToAllCards">全部</text>
</view>
<!-- Loading skeleton -->
<scroll-view
v-if="loading"
scroll-x
class="cards-scroll"
:show-scrollbar="false"
>
<view class="cards-row">
<view
v-for="i in 3"
:key="i"
class="card-item skeleton-card"
/>
<view v-if="loading" class="card-list">
<view
v-for="i in 3"
:key="i"
class="card-row skeleton-row"
>
<view class="skeleton-thumb" />
<view class="skeleton-info">
<view class="skeleton-line skeleton-line--title" />
<view class="skeleton-line skeleton-line--sub" />
<view class="skeleton-line skeleton-line--price" />
</view>
</view>
</scroll-view>
</view>
<!-- Card list -->
<scroll-view
v-else-if="cardTypes.length"
scroll-x
class="cards-scroll"
:show-scrollbar="false"
>
<view class="cards-row">
<view
v-for="card in cardTypes"
:key="card.id"
class="card-item"
:class="cardItemClass(card)"
@tap="goToDetail(card.id)"
>
<!-- Card header band -->
<view class="card-header" :class="cardHeaderClass(card)">
<text class="card-type-label">{{ typeLabel(card) }}</text>
<view v-else-if="cardTypes.length" class="card-list">
<view
v-for="card in cardTypes"
:key="card.id"
class="card-row"
@tap="goToDetail(card.id)"
>
<!-- Thumbnail -->
<view class="card-thumb" :class="thumbClass(card)">
<view class="thumb-fallback">
<text class="thumb-name">{{ truncate(card.name, 8) }}</text>
<text class="thumb-price">¥{{ formatPrice(card.price) }}</text>
</view>
</view>
<!-- Card name -->
<!-- Card info -->
<view class="card-info">
<text class="card-name">{{ card.name }}</text>
<!-- Pricing -->
<text class="card-validity">有效期:{{ card.durationDays }} </text>
<view class="price-row">
<text class="price-current">¥{{ formatPrice(card.price) }}</text>
<text class="price-label">价格:</text>
<text class="price-symbol">¥</text>
<text class="price-current">{{ formatPrice(card.price) }}</text>
<text
v-if="card.originalPrice && card.originalPrice > card.price"
class="price-original"
>
¥{{ formatPrice(card.originalPrice) }}
原价:¥{{ formatPrice(card.originalPrice) }}
</text>
</view>
<!-- Description -->
<text v-if="card.description" class="card-desc">
{{ truncate(card.description, 40) }}
</text>
<!-- Duration / Times -->
<view class="card-meta">
<view v-if="card.totalTimes" class="meta-item">
<text class="meta-value">{{ card.totalTimes }}</text>
<text class="meta-label"></text>
</view>
<view class="meta-item">
<text class="meta-value">{{ card.durationDays }}</text>
<text class="meta-label">天有效</text>
</view>
</view>
<!-- Buy button -->
<view class="buy-btn">
<text class="buy-btn-text">立即购买</text>
</view>
</view>
</view>
</scroll-view>
</view>
<!-- Empty -->
<view v-else class="empty-state">
@@ -120,25 +97,15 @@ function goToDetail(id: string) {
uni.navigateTo({ url: `/pages/card/detail?id=${id}` })
}
function typeLabel(card: CardType): string {
const map: Record<CardTypeCategory, string> = {
[CardTypeCategory.TIMES]: '次卡',
[CardTypeCategory.DURATION]: '月卡',
[CardTypeCategory.TRIAL]: '体验',
}
return map[card.type] ?? '会员卡'
function goToAllCards() {
// Navigate to all cards page or scroll behavior
uni.navigateTo({ url: '/pages/card/detail?showAll=1' })
}
function cardItemClass(card: CardType): string {
if (card.type === CardTypeCategory.TRIAL) return 'card-item--trial'
if (card.type === CardTypeCategory.DURATION) return 'card-item--duration'
return ''
}
function cardHeaderClass(card: CardType): string {
if (card.type === CardTypeCategory.TRIAL) return 'header--trial'
if (card.type === CardTypeCategory.DURATION) return 'header--duration'
return 'header--times'
function thumbClass(card: CardType): string {
if (card.type === CardTypeCategory.TRIAL) return 'thumb--trial'
if (card.type === CardTypeCategory.DURATION) return 'thumb--duration'
return 'thumb--times'
}
function truncate(str: string, maxLen: number): string {
@@ -148,166 +115,208 @@ function truncate(str: string, maxLen: number): string {
<style lang="scss" scoped>
.card-shop {
margin: 24rpx 0 0;
padding-bottom: 40rpx;
background: #ffffff;
margin-top: 16rpx;
padding-bottom: 20rpx;
}
/* ── Section header ── */
.section-header {
display: flex;
align-items: baseline;
gap: 16rpx;
padding: 0 24rpx;
margin-bottom: 20rpx;
align-items: center;
justify-content: space-between;
padding: 32rpx 32rpx 20rpx;
}
.section-title {
font-size: 32rpx;
font-size: 36rpx;
font-weight: 700;
color: #1a1a2e;
color: #222;
}
.section-subtitle {
font-size: 24rpx;
.section-action {
font-size: 26rpx;
color: #999;
padding: 8rpx 24rpx;
border: 1rpx solid #ddd;
border-radius: 24rpx;
}
.cards-scroll {
width: 100%;
/* ── Card list ── */
.card-list {
padding: 0 32rpx;
}
.cards-row {
.card-row {
display: flex;
flex-direction: row;
gap: 20rpx;
padding: 8rpx 24rpx 16rpx;
width: max-content;
align-items: center;
gap: 24rpx;
padding: 24rpx 0;
border-bottom: 1rpx solid #f0f0f0;
&:last-child {
border-bottom: none;
}
}
/* --- Individual Card --- */
.card-item {
width: 280rpx;
background: #ffffff;
border-radius: 16rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.10);
/* ── Thumbnail ── */
.card-thumb {
width: 200rpx;
height: 140rpx;
border-radius: 12rpx;
overflow: hidden;
flex-shrink: 0;
position: relative;
}
.thumb-fallback {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
flex-shrink: 0;
&--trial {
border: 2rpx solid #c9a87c;
}
&--duration {
border: 2rpx solid #9b59b6;
}
align-items: center;
justify-content: center;
gap: 8rpx;
padding: 12rpx;
}
.skeleton-card {
height: 360rpx;
.thumb--times .thumb-fallback {
background: linear-gradient(135deg, #3a3a3a, #555);
}
.thumb--duration .thumb-fallback {
background: linear-gradient(135deg, #6c3483, #9b59b6);
}
.thumb--trial .thumb-fallback {
background: linear-gradient(135deg, #7d6608, #c9a87c);
}
.thumb-name {
font-size: 22rpx;
font-weight: 600;
color: #ffffff;
text-align: center;
line-height: 1.3;
word-break: break-all;
}
.thumb-price {
font-size: 24rpx;
font-weight: 700;
color: #ffffff;
}
/* ── Card info ── */
.card-info {
flex: 1;
min-width: 0;
}
.card-name {
display: block;
font-size: 30rpx;
font-weight: 600;
color: #222;
margin-bottom: 8rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-validity {
display: block;
font-size: 24rpx;
color: #999;
margin-bottom: 12rpx;
}
.price-row {
display: flex;
align-items: baseline;
gap: 4rpx;
}
.price-label {
font-size: 24rpx;
color: #e53935;
}
.price-symbol {
font-size: 24rpx;
color: #e53935;
font-weight: 600;
}
.price-current {
font-size: 40rpx;
font-weight: 800;
color: #e53935;
}
.price-original {
font-size: 22rpx;
color: #bbb;
text-decoration: line-through;
margin-left: 12rpx;
}
/* ── Skeleton ── */
.skeleton-row {
display: flex;
align-items: center;
gap: 24rpx;
padding: 24rpx 0;
border-bottom: 1rpx solid #f0f0f0;
}
.skeleton-thumb {
width: 200rpx;
height: 140rpx;
border-radius: 12rpx;
flex-shrink: 0;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
}
.skeleton-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 16rpx;
}
.skeleton-line {
height: 24rpx;
border-radius: 6rpx;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
&--title {
width: 70%;
height: 30rpx;
}
&--sub {
width: 40%;
}
&--price {
width: 50%;
height: 36rpx;
}
}
@keyframes shimmer {
0% { background-position: 100% 0; }
100% { background-position: -100% 0; }
}
.card-header {
padding: 12rpx 20rpx;
}
.header--times { background: linear-gradient(90deg, #1a1a2e, #2d2d5e); }
.header--duration { background: linear-gradient(90deg, #6c3483, #9b59b6); }
.header--trial { background: linear-gradient(90deg, #7d6608, #c9a87c); }
.card-type-label {
font-size: 22rpx;
font-weight: 600;
color: #ffffff;
letter-spacing: 2rpx;
}
.card-name {
font-size: 30rpx;
font-weight: 700;
color: #1a1a2e;
padding: 20rpx 20rpx 8rpx;
display: block;
}
.price-row {
display: flex;
align-items: baseline;
gap: 12rpx;
padding: 0 20rpx 12rpx;
}
.price-current {
font-size: 40rpx;
font-weight: 800;
color: #c9a87c;
}
.price-original {
font-size: 24rpx;
color: #bbb;
text-decoration: line-through;
}
.card-desc {
font-size: 22rpx;
color: #888;
padding: 0 20rpx 16rpx;
line-height: 1.5;
display: block;
}
.card-meta {
display: flex;
gap: 20rpx;
padding: 0 20rpx 20rpx;
flex: 1;
align-items: flex-end;
}
.meta-item {
display: flex;
align-items: baseline;
gap: 4rpx;
}
.meta-value {
font-size: 28rpx;
font-weight: 700;
color: #1a1a2e;
}
.meta-label {
font-size: 22rpx;
color: #999;
}
.buy-btn {
margin: 0 20rpx 24rpx;
background: #1a1a2e;
border-radius: 40rpx;
padding: 16rpx 0;
display: flex;
align-items: center;
justify-content: center;
}
.buy-btn-text {
font-size: 26rpx;
font-weight: 600;
color: #c9a87c;
}
/* ── Empty state ── */
.empty-state {
padding: 60rpx;
padding: 80rpx;
display: flex;
align-items: center;
justify-content: center;

View File

@@ -1,57 +1,34 @@
<template>
<view class="studio-info card">
<!-- Photo Swiper -->
<swiper
<view class="studio-info">
<!-- Horizontal photo strip -->
<scroll-view
v-if="studioInfo?.photos?.length"
class="photo-swiper"
:indicator-dots="studioInfo.photos.length > 1"
:autoplay="true"
:interval="4000"
:duration="500"
indicator-color="rgba(255,255,255,0.5)"
indicator-active-color="#c9a87c"
circular
scroll-x
class="photo-strip"
:show-scrollbar="false"
>
<swiper-item
v-for="(photo, idx) in studioInfo.photos"
:key="idx"
>
<view class="photo-strip-inner">
<image
class="photo"
v-for="(photo, idx) in studioInfo.photos"
:key="idx"
class="strip-photo"
:src="photo"
mode="aspectFill"
@tap="previewPhoto(idx)"
/>
</swiper-item>
</swiper>
</view>
</scroll-view>
<!-- Placeholder when no photos -->
<view v-else class="photo-placeholder">
<text class="placeholder-icon">🏃</text>
<text class="placeholder-text">专业普拉提工作室</text>
</view>
<!-- Info rows -->
<view class="info-rows">
<!-- Address -->
<view class="info-row" @tap="handleAddressTap">
<view class="icon-wrap">
<text class="iconfont">📍</text>
</view>
<text class="info-text address-text">
<!-- Address + Phone row -->
<view class="location-row">
<view class="location-left" @tap="handleAddressTap">
<text class="location-icon">📍</text>
<text class="location-text">
{{ studioInfo?.address || '地址加载中…' }}
</text>
<text class="info-action">
{{ studioInfo?.latitude ? '导航' : '复制' }}
</text>
</view>
<!-- Phone -->
<view class="info-row" @tap="handlePhoneTap">
<view class="icon-wrap">
<text class="iconfont">📞</text>
</view>
<text class="info-text">{{ studioInfo?.phone || '—' }}</text>
<text class="info-action">拨打</text>
<view class="phone-btn" @tap="handlePhoneTap">
<text class="phone-icon">📞</text>
</view>
</view>
</view>
@@ -64,6 +41,14 @@ const props = defineProps<{
studioInfo: StudioConfig | null
}>()
function previewPhoto(index: number) {
if (!props.studioInfo?.photos?.length) return
uni.previewImage({
current: index,
urls: props.studioInfo.photos,
})
}
function handleAddressTap() {
if (!props.studioInfo) return
@@ -73,8 +58,8 @@ function handleAddressTap() {
uni.openLocation({
latitude,
longitude,
name: name || '普拉提工作室',
address: address,
name: name || 'Focus Core',
address,
fail() {
copyAddress()
},
@@ -109,95 +94,73 @@ function handlePhoneTap() {
<style lang="scss" scoped>
.studio-info {
margin: 24rpx 24rpx 0;
overflow: hidden;
}
.card {
background: #ffffff;
border-radius: 16rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.08);
}
.photo-swiper {
/* ── Photo strip ── */
.photo-strip {
width: 100%;
height: 360rpx;
border-radius: 16rpx 16rpx 0 0;
overflow: hidden;
padding: 24rpx 0;
}
.photo {
width: 100%;
height: 100%;
}
.photo-placeholder {
width: 100%;
height: 280rpx;
background: linear-gradient(135deg, #f0f0f0, #e8e8e8);
.photo-strip-inner {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex-direction: row;
gap: 16rpx;
border-radius: 16rpx 16rpx 0 0;
padding: 0 24rpx;
width: max-content;
}
.placeholder-icon {
font-size: 80rpx;
}
.placeholder-text {
font-size: 28rpx;
color: #999;
}
.info-rows {
padding: 16rpx 32rpx;
}
.info-row {
display: flex;
align-items: center;
padding: 20rpx 0;
border-bottom: 1rpx solid #f0f0f0;
gap: 16rpx;
&:last-child {
border-bottom: none;
}
}
.icon-wrap {
width: 48rpx;
height: 48rpx;
display: flex;
align-items: center;
justify-content: center;
.strip-photo {
width: 240rpx;
height: 160rpx;
border-radius: 12rpx;
flex-shrink: 0;
}
.iconfont {
font-size: 36rpx;
/* ── Location row ── */
.location-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx 32rpx 28rpx;
gap: 24rpx;
}
.info-text {
.location-left {
display: flex;
align-items: flex-start;
gap: 12rpx;
flex: 1;
min-width: 0;
}
.location-icon {
font-size: 28rpx;
color: #333;
line-height: 1.4;
}
.address-text {
font-size: 26rpx;
}
.info-action {
font-size: 24rpx;
color: #c9a87c;
padding: 6rpx 16rpx;
border: 1rpx solid #c9a87c;
border-radius: 24rpx;
flex-shrink: 0;
margin-top: 4rpx;
}
.location-text {
font-size: 26rpx;
color: #666;
line-height: 1.5;
word-break: break-all;
}
.phone-btn {
width: 72rpx;
height: 72rpx;
border-radius: 50%;
background: #f5f5f5;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.phone-icon {
font-size: 36rpx;
color: #4CAF50;
}
</style>

View File

@@ -100,19 +100,19 @@
"list": [
{
"pagePath": "pages/home/index",
"text": "首页",
"text": "场馆首页",
"iconPath": "static/tab/home.png",
"selectedIconPath": "static/tab/home-active.png"
},
{
"pagePath": "pages/booking/index",
"text": "预约",
"text": "课程预约",
"iconPath": "static/tab/booking.png",
"selectedIconPath": "static/tab/booking-active.png"
},
{
"pagePath": "pages/profile/index",
"text": "我的",
"text": "个人中心",
"iconPath": "static/tab/profile.png",
"selectedIconPath": "static/tab/profile-active.png"
}

View File

@@ -9,19 +9,22 @@
@refresherrefresh="handleRefresh"
@refresherrestore="refreshing = false"
>
<!-- Brand Banner (custom nav) -->
<!-- Brand Banner (hero with bg image + centered logo) -->
<BrandBanner :studio-info="studioStore.studioInfo" />
<!-- Studio Info (swiper + address + phone) -->
<!-- Studio Info (photo strip + address/phone) -->
<StudioInfo :studio-info="studioStore.studioInfo" />
<!-- Divider -->
<view class="section-divider" />
<!-- Quick Entry (login / trial / book / renew) -->
<QuickEntry @scroll-to-card-shop="scrollToCardShop" />
<!-- Upcoming Bookings -->
<UpcomingBooking />
<!-- Card Shop (horizontal scroll) -->
<!-- Card Shop (vertical list) -->
<view :id="cardShopAnchorId">
<CardShop ref="cardShopRef" />
</view>
@@ -102,6 +105,11 @@ function scrollToCardShop() {
height: 100vh;
}
.section-divider {
height: 16rpx;
background: #f5f5f5;
}
.bottom-padding {
height: 120rpx;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 738 B

After

Width:  |  Height:  |  Size: 378 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 679 B

After

Width:  |  Height:  |  Size: 371 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 722 B

After

Width:  |  Height:  |  Size: 387 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 573 B

After

Width:  |  Height:  |  Size: 382 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 728 B

After

Width:  |  Height:  |  Size: 460 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 622 B

After

Width:  |  Height:  |  Size: 455 B

View File

@@ -2,7 +2,6 @@
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"module": "CommonJS",
"moduleResolution": "node",
"emitDecoratorMetadata": true,

File diff suppressed because one or more lines are too long