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:
14
packages/app/index.html
Normal file
14
packages/app/index.html
Normal 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
32
packages/app/package.json
Normal 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
17
packages/app/src/App.vue
Normal 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
10
packages/app/src/main.ts
Normal 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 }
|
||||||
|
}
|
||||||
22
packages/app/src/manifest.json
Normal file
22
packages/app/src/manifest.json
Normal 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
121
packages/app/src/pages.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
15
packages/app/src/pages/admin/card-types.vue
Normal file
15
packages/app/src/pages/admin/card-types.vue
Normal 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>
|
||||||
15
packages/app/src/pages/admin/index.vue
Normal file
15
packages/app/src/pages/admin/index.vue
Normal 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>
|
||||||
15
packages/app/src/pages/admin/members.vue
Normal file
15
packages/app/src/pages/admin/members.vue
Normal 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>
|
||||||
15
packages/app/src/pages/admin/orders.vue
Normal file
15
packages/app/src/pages/admin/orders.vue
Normal 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>
|
||||||
15
packages/app/src/pages/admin/slot-adjust.vue
Normal file
15
packages/app/src/pages/admin/slot-adjust.vue
Normal 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>
|
||||||
15
packages/app/src/pages/admin/studio.vue
Normal file
15
packages/app/src/pages/admin/studio.vue
Normal 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>
|
||||||
15
packages/app/src/pages/admin/week-template.vue
Normal file
15
packages/app/src/pages/admin/week-template.vue
Normal 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>
|
||||||
23
packages/app/src/pages/booking/index.vue
Normal file
23
packages/app/src/pages/booking/index.vue
Normal 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>
|
||||||
15
packages/app/src/pages/card/detail.vue
Normal file
15
packages/app/src/pages/card/detail.vue
Normal 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>
|
||||||
23
packages/app/src/pages/home/index.vue
Normal file
23
packages/app/src/pages/home/index.vue
Normal 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>
|
||||||
15
packages/app/src/pages/profile/bookings.vue
Normal file
15
packages/app/src/pages/profile/bookings.vue
Normal 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>
|
||||||
23
packages/app/src/pages/profile/index.vue
Normal file
23
packages/app/src/pages/profile/index.vue
Normal 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>
|
||||||
15
packages/app/src/pages/profile/info.vue
Normal file
15
packages/app/src/pages/profile/info.vue
Normal 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>
|
||||||
15
packages/app/src/pages/profile/membership.vue
Normal file
15
packages/app/src/pages/profile/membership.vue
Normal 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>
|
||||||
BIN
packages/app/src/static/tab/booking-active.png
Normal file
BIN
packages/app/src/static/tab/booking-active.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 738 B |
BIN
packages/app/src/static/tab/booking.png
Normal file
BIN
packages/app/src/static/tab/booking.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 679 B |
BIN
packages/app/src/static/tab/home-active.png
Normal file
BIN
packages/app/src/static/tab/home-active.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 722 B |
BIN
packages/app/src/static/tab/home.png
Normal file
BIN
packages/app/src/static/tab/home.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 573 B |
BIN
packages/app/src/static/tab/profile-active.png
Normal file
BIN
packages/app/src/static/tab/profile-active.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 728 B |
BIN
packages/app/src/static/tab/profile.png
Normal file
BIN
packages/app/src/static/tab/profile.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 622 B |
71
packages/app/src/stores/booking.ts
Normal file
71
packages/app/src/stores/booking.ts
Normal 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,
|
||||||
|
}
|
||||||
|
})
|
||||||
27
packages/app/src/stores/studio.ts
Normal file
27
packages/app/src/stores/studio.ts
Normal 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,
|
||||||
|
}
|
||||||
|
})
|
||||||
105
packages/app/src/stores/user.ts
Normal file
105
packages/app/src/stores/user.ts
Normal 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
21
packages/app/src/uni.scss
Normal 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;
|
||||||
44
packages/app/src/utils/auth.ts
Normal file
44
packages/app/src/utils/auth.ts
Normal 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')
|
||||||
|
}
|
||||||
46
packages/app/src/utils/format.ts
Normal file
46
packages/app/src/utils/format.ts
Normal 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
|
||||||
|
}
|
||||||
69
packages/app/src/utils/request.ts
Normal file
69
packages/app/src/utils/request.ts
Normal 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 })
|
||||||
|
}
|
||||||
19
packages/app/tsconfig.json
Normal file
19
packages/app/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
12
packages/app/vite.config.ts
Normal file
12
packages/app/vite.config.ts
Normal 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
4627
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user