feat(app): initialize uni-app with routing, stores, and infrastructure

- Vue 3 + TypeScript + Pinia + SCSS
- 3-tab navigation (home, booking, profile) + 11 sub-pages
- HTTP client with JWT auth, request interceptors
- Pinia stores: user (auth, profile, memberships), studio, booking
- Utility functions: price formatting, date helpers
- WeChat login helper
- All pages as stubs ready for implementation
This commit is contained in:
richarjiang
2026-04-02 12:51:28 +08:00
parent b9d55c9e9f
commit 554fc30954
36 changed files with 5438 additions and 53 deletions

14
packages/app/index.html Normal file
View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>普拉提约课</title>
<!--preload-links-->
<!--app-context-->
</head>
<body>
<div id="app"><!--app-html--></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

32
packages/app/package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "@mp-pilates/app",
"version": "0.1.0",
"private": true,
"scripts": {
"dev:mp-weixin": "uni -p mp-weixin",
"build:mp-weixin": "uni build -p mp-weixin",
"type-check": "vue-tsc --noEmit"
},
"dependencies": {
"@dcloudio/uni-app": "3.0.0-4060620250520001",
"@dcloudio/uni-app-plus": "3.0.0-4060620250520001",
"@dcloudio/uni-components": "3.0.0-4060620250520001",
"@dcloudio/uni-h5": "3.0.0-4060620250520001",
"@dcloudio/uni-mp-weixin": "3.0.0-4060620250520001",
"@mp-pilates/shared": "workspace:*",
"pinia": "^2.1.7",
"vue": "^3.4.0"
},
"devDependencies": {
"@dcloudio/types": "^3.4.0",
"@dcloudio/uni-automator": "3.0.0-4060620250520001",
"@dcloudio/uni-cli-shared": "3.0.0-4060620250520001",
"@dcloudio/uni-stacktracey": "3.0.0-4060620250520001",
"@dcloudio/vite-plugin-uni": "3.0.0-4060620250520001",
"@types/node": "^20.0.0",
"typescript": "^5.4.0",
"vite": "^5.4.0",
"vue-tsc": "^2.0.0",
"sass": "^1.77.0"
}
}

17
packages/app/src/App.vue Normal file
View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import { onLaunch } from '@dcloudio/uni-app'
import { useUserStore } from './stores/user'
onLaunch(() => {
console.log('App Launch')
const userStore = useUserStore()
userStore.checkAuth()
})
</script>
<style>
page {
background-color: #f5f5f5;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
</style>

10
packages/app/src/main.ts Normal file
View File

@@ -0,0 +1,10 @@
import { createSSRApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
export function createApp() {
const app = createSSRApp(App)
const pinia = createPinia()
app.use(pinia)
return { app }
}

View File

@@ -0,0 +1,22 @@
{
"name": "普拉提约课",
"appid": "",
"description": "普拉提工作室约课小程序",
"versionName": "0.1.0",
"versionCode": "100",
"transformPx": false,
"mp-weixin": {
"appid": "",
"setting": {
"urlCheck": false,
"es6": true,
"minified": true
},
"usingComponents": true,
"permission": {
"scope.userLocation": {
"desc": "用于获取工作室位置导航"
}
}
}
}

121
packages/app/src/pages.json Normal file
View File

@@ -0,0 +1,121 @@
{
"pages": [
{
"path": "pages/home/index",
"style": {
"navigationBarTitleText": "首页",
"navigationStyle": "custom"
}
},
{
"path": "pages/booking/index",
"style": {
"navigationBarTitleText": "预约课程"
}
},
{
"path": "pages/profile/index",
"style": {
"navigationBarTitleText": "我的"
}
},
{
"path": "pages/card/detail",
"style": {
"navigationBarTitleText": "购买会员卡"
}
},
{
"path": "pages/profile/membership",
"style": {
"navigationBarTitleText": "我的会员卡"
}
},
{
"path": "pages/profile/bookings",
"style": {
"navigationBarTitleText": "我的预约"
}
},
{
"path": "pages/profile/info",
"style": {
"navigationBarTitleText": "个人信息"
}
},
{
"path": "pages/admin/index",
"style": {
"navigationBarTitleText": "管理中心"
}
},
{
"path": "pages/admin/week-template",
"style": {
"navigationBarTitleText": "排课设置"
}
},
{
"path": "pages/admin/slot-adjust",
"style": {
"navigationBarTitleText": "时段调整"
}
},
{
"path": "pages/admin/members",
"style": {
"navigationBarTitleText": "会员管理"
}
},
{
"path": "pages/admin/orders",
"style": {
"navigationBarTitleText": "订单管理"
}
},
{
"path": "pages/admin/card-types",
"style": {
"navigationBarTitleText": "卡种管理"
}
},
{
"path": "pages/admin/studio",
"style": {
"navigationBarTitleText": "工作室设置"
}
}
],
"globalStyle": {
"navigationBarTextStyle": "black",
"navigationBarTitleText": "普拉提约课",
"navigationBarBackgroundColor": "#ffffff",
"backgroundColor": "#f5f5f5"
},
"tabBar": {
"color": "#999999",
"selectedColor": "#1a1a2e",
"backgroundColor": "#ffffff",
"borderStyle": "black",
"list": [
{
"pagePath": "pages/home/index",
"text": "首页",
"iconPath": "static/tab/home.png",
"selectedIconPath": "static/tab/home-active.png"
},
{
"pagePath": "pages/booking/index",
"text": "预约",
"iconPath": "static/tab/booking.png",
"selectedIconPath": "static/tab/booking-active.png"
},
{
"pagePath": "pages/profile/index",
"text": "我的",
"iconPath": "static/tab/profile.png",
"selectedIconPath": "static/tab/profile-active.png"
}
]
}
}

View File

@@ -0,0 +1,15 @@
<template>
<view class="page">
<view class="placeholder">
<text>卡种管理 - 待实现</text>
</view>
</view>
</template>
<script setup lang="ts">
</script>
<style lang="scss" scoped>
.page { min-height: 100vh; background: #f5f5f5; }
.placeholder { display: flex; align-items: center; justify-content: center; height: 400rpx; color: #999; }
</style>

View File

@@ -0,0 +1,15 @@
<template>
<view class="page">
<view class="placeholder">
<text>管理中心 - 待实现</text>
</view>
</view>
</template>
<script setup lang="ts">
</script>
<style lang="scss" scoped>
.page { min-height: 100vh; background: #f5f5f5; }
.placeholder { display: flex; align-items: center; justify-content: center; height: 400rpx; color: #999; }
</style>

View File

@@ -0,0 +1,15 @@
<template>
<view class="page">
<view class="placeholder">
<text>会员管理 - 待实现</text>
</view>
</view>
</template>
<script setup lang="ts">
</script>
<style lang="scss" scoped>
.page { min-height: 100vh; background: #f5f5f5; }
.placeholder { display: flex; align-items: center; justify-content: center; height: 400rpx; color: #999; }
</style>

View File

@@ -0,0 +1,15 @@
<template>
<view class="page">
<view class="placeholder">
<text>订单管理 - 待实现</text>
</view>
</view>
</template>
<script setup lang="ts">
</script>
<style lang="scss" scoped>
.page { min-height: 100vh; background: #f5f5f5; }
.placeholder { display: flex; align-items: center; justify-content: center; height: 400rpx; color: #999; }
</style>

View File

@@ -0,0 +1,15 @@
<template>
<view class="page">
<view class="placeholder">
<text>时段调整 - 待实现</text>
</view>
</view>
</template>
<script setup lang="ts">
</script>
<style lang="scss" scoped>
.page { min-height: 100vh; background: #f5f5f5; }
.placeholder { display: flex; align-items: center; justify-content: center; height: 400rpx; color: #999; }
</style>

View File

@@ -0,0 +1,15 @@
<template>
<view class="page">
<view class="placeholder">
<text>工作室设置 - 待实现</text>
</view>
</view>
</template>
<script setup lang="ts">
</script>
<style lang="scss" scoped>
.page { min-height: 100vh; background: #f5f5f5; }
.placeholder { display: flex; align-items: center; justify-content: center; height: 400rpx; color: #999; }
</style>

View File

@@ -0,0 +1,15 @@
<template>
<view class="page">
<view class="placeholder">
<text>排课设置 - 待实现</text>
</view>
</view>
</template>
<script setup lang="ts">
</script>
<style lang="scss" scoped>
.page { min-height: 100vh; background: #f5f5f5; }
.placeholder { display: flex; align-items: center; justify-content: center; height: 400rpx; color: #999; }
</style>

View File

@@ -0,0 +1,23 @@
<template>
<view class="booking-page">
<view class="placeholder">
<text>预约课程 - 待实现</text>
</view>
</view>
</template>
<script setup lang="ts">
</script>
<style lang="scss" scoped>
.booking-page {
min-height: 100vh;
}
.placeholder {
display: flex;
align-items: center;
justify-content: center;
height: 400rpx;
color: #999;
}
</style>

View File

@@ -0,0 +1,15 @@
<template>
<view class="page">
<view class="placeholder">
<text>购买会员卡 - 待实现</text>
</view>
</view>
</template>
<script setup lang="ts">
</script>
<style lang="scss" scoped>
.page { min-height: 100vh; background: #f5f5f5; }
.placeholder { display: flex; align-items: center; justify-content: center; height: 400rpx; color: #999; }
</style>

View File

@@ -0,0 +1,23 @@
<template>
<view class="home-page">
<view class="placeholder">
<text>首页 - 待实现</text>
</view>
</view>
</template>
<script setup lang="ts">
</script>
<style lang="scss" scoped>
.home-page {
min-height: 100vh;
}
.placeholder {
display: flex;
align-items: center;
justify-content: center;
height: 400rpx;
color: #999;
}
</style>

View File

@@ -0,0 +1,15 @@
<template>
<view class="page">
<view class="placeholder">
<text>我的预约 - 待实现</text>
</view>
</view>
</template>
<script setup lang="ts">
</script>
<style lang="scss" scoped>
.page { min-height: 100vh; background: #f5f5f5; }
.placeholder { display: flex; align-items: center; justify-content: center; height: 400rpx; color: #999; }
</style>

View File

@@ -0,0 +1,23 @@
<template>
<view class="profile-page">
<view class="placeholder">
<text>我的 - 待实现</text>
</view>
</view>
</template>
<script setup lang="ts">
</script>
<style lang="scss" scoped>
.profile-page {
min-height: 100vh;
}
.placeholder {
display: flex;
align-items: center;
justify-content: center;
height: 400rpx;
color: #999;
}
</style>

View File

@@ -0,0 +1,15 @@
<template>
<view class="page">
<view class="placeholder">
<text>个人信息 - 待实现</text>
</view>
</view>
</template>
<script setup lang="ts">
</script>
<style lang="scss" scoped>
.page { min-height: 100vh; background: #f5f5f5; }
.placeholder { display: flex; align-items: center; justify-content: center; height: 400rpx; color: #999; }
</style>

View File

@@ -0,0 +1,15 @@
<template>
<view class="page">
<view class="placeholder">
<text>我的会员卡 - 待实现</text>
</view>
</view>
</template>
<script setup lang="ts">
</script>
<style lang="scss" scoped>
.page { min-height: 100vh; background: #f5f5f5; }
.placeholder { display: flex; align-items: center; justify-content: center; height: 400rpx; color: #999; }
</style>

Binary file not shown.

After

Width:  |  Height:  |  Size: 738 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 679 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 722 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 573 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 728 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 622 B

View File

@@ -0,0 +1,71 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type {
TimeSlotWithBookingStatus,
BookingWithDetails,
CreateBookingDto,
} from '@mp-pilates/shared'
import { get, post, put } from '../utils/request'
export const useBookingStore = defineStore('booking', () => {
const slots = ref<readonly TimeSlotWithBookingStatus[]>([])
const myBookings = ref<readonly BookingWithDetails[]>([])
const upcomingBookings = ref<readonly BookingWithDetails[]>([])
const loadingSlots = ref(false)
const loadingBookings = ref(false)
async function fetchSlots(date: string) {
loadingSlots.value = true
try {
slots.value = await get<TimeSlotWithBookingStatus[]>('/time-slot/available', { date })
} catch (err) {
console.error('Fetch slots failed:', err)
slots.value = []
} finally {
loadingSlots.value = false
}
}
async function createBooking(dto: CreateBookingDto) {
const result = await post<BookingWithDetails>('/booking', dto as unknown as Record<string, unknown>)
return result
}
async function cancelBooking(bookingId: string) {
const result = await put<BookingWithDetails>(`/booking/${bookingId}/cancel`)
return result
}
async function fetchMyBookings(status?: string) {
loadingBookings.value = true
try {
const params = status ? { status } : {}
myBookings.value = await get<BookingWithDetails[]>('/booking/my', params)
} catch (err) {
console.error('Fetch bookings failed:', err)
} finally {
loadingBookings.value = false
}
}
async function fetchUpcomingBookings() {
try {
upcomingBookings.value = await get<BookingWithDetails[]>('/booking/my/upcoming')
} catch (err) {
console.error('Fetch upcoming bookings failed:', err)
}
}
return {
slots,
myBookings,
upcomingBookings,
loadingSlots,
loadingBookings,
fetchSlots,
createBooking,
cancelBooking,
fetchMyBookings,
fetchUpcomingBookings,
}
})

View File

@@ -0,0 +1,27 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { StudioConfig } from '@mp-pilates/shared'
import { get } from '../utils/request'
export const useStudioStore = defineStore('studio', () => {
const studioInfo = ref<StudioConfig | null>(null)
const loading = ref(false)
async function fetchStudioInfo() {
if (loading.value) return
loading.value = true
try {
studioInfo.value = await get<StudioConfig>('/studio/info')
} catch (err) {
console.error('Fetch studio info failed:', err)
} finally {
loading.value = false
}
}
return {
studioInfo,
loading,
fetchStudioInfo,
}
})

View File

@@ -0,0 +1,105 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type {
UserProfileResponse,
UserStatsResponse,
MembershipWithCardType,
} from '@mp-pilates/shared'
import { UserRole, MembershipStatus } from '@mp-pilates/shared'
import { wxLogin, isLoggedIn, logout as authLogout } from '../utils/auth'
import { get, put } from '../utils/request'
export const useUserStore = defineStore('user', () => {
// State
const user = ref<UserProfileResponse | null>(null)
const stats = ref<UserStatsResponse | null>(null)
const memberships = ref<readonly MembershipWithCardType[]>([])
const token = ref<string>(uni.getStorageSync('token') as string || '')
// Getters
const loggedIn = computed(() => !!token.value && !!user.value)
const isAdmin = computed(() => user.value?.role === UserRole.ADMIN)
const activeMemberships = computed(() =>
memberships.value.filter((m) => m.status === MembershipStatus.ACTIVE),
)
const hasValidMembership = computed(() => activeMemberships.value.length > 0)
// Actions
async function login() {
try {
const result = await wxLogin()
token.value = result.token
user.value = result.user
return result.user
} catch (err) {
console.error('Login failed:', err)
throw err
}
}
async function fetchProfile() {
if (!isLoggedIn()) return
try {
user.value = await get<UserProfileResponse>('/user/profile')
} catch (err) {
console.error('Fetch profile failed:', err)
}
}
async function fetchStats() {
if (!isLoggedIn()) return
try {
stats.value = await get<UserStatsResponse>('/user/stats')
} catch (err) {
console.error('Fetch stats failed:', err)
}
}
async function fetchMemberships() {
if (!isLoggedIn()) return
try {
memberships.value = await get<MembershipWithCardType[]>('/membership/my')
} catch (err) {
console.error('Fetch memberships failed:', err)
}
}
async function updateProfile(data: { nickname?: string; avatarUrl?: string }) {
const updated = await put<UserProfileResponse>('/user/profile', data)
user.value = updated
return updated
}
function checkAuth() {
if (isLoggedIn()) {
fetchProfile()
fetchMemberships()
}
}
function logout() {
authLogout()
token.value = ''
user.value = null
stats.value = null
memberships.value = []
}
return {
user,
stats,
memberships,
token,
loggedIn,
isAdmin,
activeMemberships,
hasValidMembership,
login,
fetchProfile,
fetchStats,
fetchMemberships,
updateProfile,
checkAuth,
logout,
}
})

21
packages/app/src/uni.scss Normal file
View File

@@ -0,0 +1,21 @@
/* uni.scss - 全局样式变量 */
$brand-color: #1a1a2e;
$brand-light: #e2d1c3;
$accent-color: #c9a87c;
$text-primary: #333333;
$text-secondary: #666666;
$text-hint: #999999;
$bg-page: #f5f5f5;
$bg-card: #ffffff;
$border-color: #eeeeee;
$success-color: #52c41a;
$warning-color: #faad14;
$error-color: #ff4d4f;
$radius-sm: 8rpx;
$radius-md: 16rpx;
$radius-lg: 24rpx;
$spacing-xs: 8rpx;
$spacing-sm: 16rpx;
$spacing-md: 24rpx;
$spacing-lg: 32rpx;
$spacing-xl: 48rpx;

View File

@@ -0,0 +1,44 @@
import { post } from './request'
import type { UserProfileResponse } from '@mp-pilates/shared'
interface LoginResponse {
readonly token: string
readonly user: UserProfileResponse
}
export async function wxLogin(): Promise<LoginResponse> {
return new Promise((resolve, reject) => {
uni.login({
provider: 'weixin',
success: async (loginRes) => {
try {
const result = await post<LoginResponse>('/auth/login', {
code: loginRes.code,
})
uni.setStorageSync('token', result.token)
resolve(result)
} catch (err) {
reject(err)
}
},
fail: (err) => {
reject(new Error(err.errMsg || '微信登录失败'))
},
})
})
}
export async function wxBindPhone(e: {
readonly detail: { readonly encryptedData: string; readonly iv: string }
}): Promise<UserProfileResponse> {
const { encryptedData, iv } = e.detail
return post<UserProfileResponse>('/auth/phone', { encryptedData, iv })
}
export function isLoggedIn(): boolean {
return !!uni.getStorageSync('token')
}
export function logout(): void {
uni.removeStorageSync('token')
}

View File

@@ -0,0 +1,46 @@
/** 格式化金额:分 → 元 */
export function formatPrice(cents: number): string {
return (cents / 100).toFixed(2)
}
/** 格式化日期为 YYYY-MM-DD */
export function formatDate(date: Date | string): string {
const d = typeof date === 'string' ? new Date(date) : date
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
/** 获取星期几中文 */
export function getWeekdayLabel(date: Date | string): string {
const d = typeof date === 'string' ? new Date(date) : date
const labels = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
return labels[d.getDay()]
}
/** 判断是否是今天 */
export function isToday(date: Date | string): boolean {
const d = typeof date === 'string' ? new Date(date) : date
const today = new Date()
return (
d.getFullYear() === today.getFullYear() &&
d.getMonth() === today.getMonth() &&
d.getDate() === today.getDate()
)
}
/** 生成未来 N 天的日期列表 */
export function getDateRange(days: number): ReadonlyArray<{ readonly date: string; readonly weekday: string; readonly isToday: boolean }> {
const result = []
const now = new Date()
for (let i = 0; i < days; i++) {
const d = new Date(now.getTime() + i * 86400000)
result.push({
date: formatDate(d),
weekday: getWeekdayLabel(d),
isToday: i === 0,
})
}
return result
}

View File

@@ -0,0 +1,69 @@
import type { ApiResponse, PaginatedData } from '@mp-pilates/shared'
const BASE_URL = 'http://localhost:3000/api'
interface RequestOptions {
readonly url: string
readonly method?: 'GET' | 'POST' | 'PUT' | 'DELETE'
readonly data?: Record<string, unknown>
readonly header?: Record<string, string>
}
export function request<T>(options: RequestOptions): Promise<T> {
return new Promise((resolve, reject) => {
const token = uni.getStorageSync('token') as string
uni.request({
url: `${BASE_URL}${options.url}`,
method: options.method || 'GET',
data: options.data,
header: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options.header,
},
success: (res) => {
if (res.statusCode === 401) {
uni.removeStorageSync('token')
uni.showToast({ title: '请重新登录', icon: 'none' })
reject(new Error('Unauthorized'))
return
}
if (res.statusCode >= 400) {
const body = res.data as ApiResponse<unknown>
reject(new Error(body?.message || `请求失败 (${res.statusCode})`))
return
}
const body = res.data as ApiResponse<T>
if (body.success) {
resolve(body.data as T)
} else {
reject(new Error(body.message || '请求失败'))
}
},
fail: (err) => {
reject(new Error(err.errMsg || '网络请求失败'))
},
})
})
}
export function get<T>(url: string, data?: Record<string, unknown>): Promise<T> {
return request<T>({ url, method: 'GET', data })
}
export function post<T>(url: string, data?: Record<string, unknown>): Promise<T> {
return request<T>({ url, method: 'POST', data })
}
export function put<T>(url: string, data?: Record<string, unknown>): Promise<T> {
return request<T>({ url, method: 'PUT', data })
}
export function del<T>(url: string, data?: Record<string, unknown>): Promise<T> {
return request<T>({ url, method: 'DELETE', data })
}
export function getPaginated<T>(url: string, params?: Record<string, unknown>): Promise<PaginatedData<T>> {
return request<PaginatedData<T>>({ url, method: 'GET', data: params })
}

View File

@@ -0,0 +1,19 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "preserve",
"jsxImportSource": "vue",
"sourceMap": true,
"declaration": false,
"declarationMap": false,
"types": [
"@dcloudio/types",
"@types/node"
]
},
"include": ["src/**/*.ts", "src/**/*.vue", "src/**/*.d.ts"],
"exclude": ["node_modules", "dist", "unpackage"]
}

View File

@@ -0,0 +1,12 @@
import { defineConfig } from 'vite'
import uni from '@dcloudio/vite-plugin-uni'
import { resolve } from 'path'
export default defineConfig({
plugins: [uni()],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
})

4627
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff