Compare commits
62 Commits
9c5dd4a911
...
v0.0.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7e339e40c | ||
|
|
8e2db3a74a | ||
|
|
f54a12efbd | ||
|
|
b937895bc9 | ||
|
|
b4b3caac70 | ||
|
|
d1f193e13e | ||
|
|
301f9ae385 | ||
|
|
87d946adb5 | ||
|
|
86ad9ee64f | ||
|
|
f5c7b7eaac | ||
|
|
88cd8419c8 | ||
|
|
801e2f47da | ||
|
|
15d96c722b | ||
|
|
14d7c03b05 | ||
|
|
bd3d519b4f | ||
|
|
9575210b06 | ||
|
|
b02f38dcc7 | ||
|
|
4dacd908a6 | ||
|
|
6ab16f508a | ||
|
|
7ce7cef77c | ||
|
|
52cc3a2985 | ||
|
|
497837c1d8 | ||
|
|
d45a5b2c14 | ||
|
|
f78cdcc9d1 | ||
|
|
1f45c3dc3f | ||
|
|
6cee28bf66 | ||
|
|
c60821c5ff | ||
|
|
9639f44698 | ||
|
|
0810f71250 | ||
|
|
54e30da003 | ||
|
|
57e3227af0 | ||
|
|
54104c16d2 | ||
|
|
a40b4e47e5 | ||
|
|
74551085e3 | ||
|
|
23bdd05811 | ||
|
|
91abedcb86 | ||
|
|
0a20aef678 | ||
|
|
e6056bcab1 | ||
|
|
f14da2c538 | ||
|
|
b281990808 | ||
|
|
cea78aa8d0 | ||
|
|
0776bd8630 | ||
|
|
867461f892 | ||
|
|
13b75c3bed | ||
|
|
f7f18f5178 | ||
|
|
0ca93ec97e | ||
|
|
58c7588a96 | ||
|
|
f94b48203f | ||
|
|
168968f073 | ||
|
|
ba4e4bb301 | ||
|
|
f8268cb6f6 | ||
|
|
66d47ec162 | ||
|
|
3a9982209f | ||
|
|
f71ff968ad | ||
|
|
c0e0d31ae7 | ||
|
|
4633ceea8c | ||
|
|
fdb13c32c2 | ||
|
|
694330b7a6 | ||
|
|
9eee4f6b87 | ||
|
|
9811c9a13b | ||
|
|
a85270efd4 | ||
|
|
b6986ba30c |
252
.claude/skills/wechat-devtools-http-preview/SKILL.md
Normal file
252
.claude/skills/wechat-devtools-http-preview/SKILL.md
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
---
|
||||||
|
name: wechat-devtools-http-preview
|
||||||
|
description: '通过微信开发者工具 HTTP V2 接口完成小程序登录检查、自动预览、上传体验版等操作。适用于用户提到微信开发者工具 HTTP、自动预览、体验版上传、命令行发布体验版、流水线触发开发者工具发布时。'
|
||||||
|
license: MIT
|
||||||
|
allowed-tools: Bash
|
||||||
|
---
|
||||||
|
|
||||||
|
# 微信开发者工具 HTTP V2 体验版发布
|
||||||
|
|
||||||
|
## 适用场景
|
||||||
|
|
||||||
|
当用户出现以下意图时使用本 skill:
|
||||||
|
|
||||||
|
- 希望通过命令行或脚本调用微信开发者工具。
|
||||||
|
- 希望上传小程序体验版。
|
||||||
|
- 希望生成或刷新预览二维码。
|
||||||
|
- 希望把开发者工具 HTTP 能力接入本地自动化流程。
|
||||||
|
- 提到 `HTTP V2`、`/v2/upload`、`/v2/preview`、`/v2/autopreview`、`微信开发者工具端口` 等关键词。
|
||||||
|
|
||||||
|
## 核心结论
|
||||||
|
|
||||||
|
微信开发者工具在启动后会自动开启本地 HTTP 服务。对于“通过命令行发布体验版”这个目标,最直接的路径是:
|
||||||
|
|
||||||
|
1. 确认开发者工具已启动并拿到本地端口。
|
||||||
|
2. 确认工具已登录。
|
||||||
|
3. 如有需要先执行 `/v2/open` 打开项目。
|
||||||
|
4. 如项目依赖 npm,必要时先执行 `/v2/buildnpm`。
|
||||||
|
5. 调用 `/v2/upload` 上传体验版代码。
|
||||||
|
6. 如需同时给测试同学扫码,调用 `/v2/preview` 或 `/v2/autopreview`。
|
||||||
|
|
||||||
|
文档同时明确说明:如果场景是完全不依赖开发者工具的 CI/CD,官方更推荐 `miniprogram-ci`。因此本 skill 的定位是“基于本机已安装且已运行的微信开发者工具做自动化”,不是替代纯 CI 方案。
|
||||||
|
|
||||||
|
## 文档沉淀
|
||||||
|
|
||||||
|
根据微信开发者工具 HTTP 文档,需要记住这些约束:
|
||||||
|
|
||||||
|
- 接口路径统一使用 `/v2` 前缀。
|
||||||
|
- HTTP 服务会在开发者工具启动后自动开启。
|
||||||
|
- 端口号记录在用户目录下的 `.ide` 文件。
|
||||||
|
- `project` 参数一般都要求传项目绝对路径,且必须 URL encode。
|
||||||
|
- 项目目录必须存在合法的 `project.config.json`,并至少包含 `appid` 与 `projectname`。
|
||||||
|
- `upload` 用于上传代码,也就是生成体验版。
|
||||||
|
- `preview` 返回预览二维码。
|
||||||
|
- `autopreview` 会自动刷新并预览项目,适合频繁本地联调。
|
||||||
|
- `islogin` 可用于判断当前开发者工具是否已登录。
|
||||||
|
- `login` 可输出二维码,支持 `image`、`base64`、`terminal` 三种格式。
|
||||||
|
- `info-output` 可把预览或上传附加信息输出到 JSON 文件,适合自动化流程记录产物。
|
||||||
|
|
||||||
|
## 端口定位
|
||||||
|
|
||||||
|
开发者工具端口号文件:
|
||||||
|
|
||||||
|
- macOS: `~/Library/Application Support/微信开发者工具/<MD5>/Default/.ide`
|
||||||
|
- Windows: `~/AppData/Local/微信开发者工具/User Data/<MD5>/Default/.ide`
|
||||||
|
|
||||||
|
文档给出的 MD5 规则:`MD5(${installPath}${nwVersion})`
|
||||||
|
|
||||||
|
已知默认值:
|
||||||
|
|
||||||
|
- macOS: `installPath = /Applications/wechatwebdevtools.app/Contents/MacOS`
|
||||||
|
- macOS: `nwVersion = ''`
|
||||||
|
- Windows: `installPath = 微信开发者工具.exe 所在目录`
|
||||||
|
- Windows: `nwVersion = installPath/version` 文件中 `latestNw` 的值
|
||||||
|
|
||||||
|
## 标准工作流
|
||||||
|
|
||||||
|
### 1. 检查工具是否启动
|
||||||
|
|
||||||
|
先读取 `.ide` 文件中的端口号;如果没有端口文件,说明工具大概率未启动,先提示用户启动微信开发者工具。
|
||||||
|
|
||||||
|
### 2. 检查是否已登录
|
||||||
|
|
||||||
|
调用:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "http://127.0.0.1:${PORT}/v2/islogin"
|
||||||
|
```
|
||||||
|
|
||||||
|
如果未登录,调用 `/v2/login`,按需要输出二维码:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "http://127.0.0.1:${PORT}/v2/login?qr-format=terminal"
|
||||||
|
curl "http://127.0.0.1:${PORT}/v2/login?qr-format=base64&qr-output=%2Ftmp%2Fwechat-login.txt"
|
||||||
|
curl "http://127.0.0.1:${PORT}/v2/login?result-output=%2Ftmp%2Fwechat-login-result.json"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 打开或刷新项目
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "http://127.0.0.1:${PORT}/v2/open?project=${ENCODED_PROJECT}"
|
||||||
|
```
|
||||||
|
|
||||||
|
当用户只要求上传或预览时,这一步不是必选,但执行后通常更稳定。
|
||||||
|
|
||||||
|
### 4. 构建 npm
|
||||||
|
|
||||||
|
当项目启用了小程序 npm 并且近期依赖有变更时执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "http://127.0.0.1:${PORT}/v2/buildnpm?project=${ENCODED_PROJECT}&compile-type=miniprogram"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 上传体验版
|
||||||
|
|
||||||
|
体验版发布的核心接口就是:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "http://127.0.0.1:${PORT}/v2/upload?project=${ENCODED_PROJECT}&version=${VERSION}&desc=${DESC}"
|
||||||
|
```
|
||||||
|
|
||||||
|
推荐同时加 `info-output`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "http://127.0.0.1:${PORT}/v2/upload?project=${ENCODED_PROJECT}&version=${VERSION}&desc=${DESC}&info-output=${ENCODED_INFO_OUTPUT}"
|
||||||
|
```
|
||||||
|
|
||||||
|
参数要求:
|
||||||
|
|
||||||
|
- `project`:必填,项目绝对路径。
|
||||||
|
- `version`:必填,版本号。
|
||||||
|
- `desc`:可选,版本备注。
|
||||||
|
- `info-output`:可选,输出上传附加信息 JSON。
|
||||||
|
|
||||||
|
### 6. 生成预览二维码
|
||||||
|
|
||||||
|
如果用户还需要扫码体验,可继续调用:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "http://127.0.0.1:${PORT}/v2/preview?project=${ENCODED_PROJECT}&qr-format=terminal"
|
||||||
|
curl "http://127.0.0.1:${PORT}/v2/preview?project=${ENCODED_PROJECT}&qr-format=base64&qr-output=${ENCODED_QR_PATH}"
|
||||||
|
curl "http://127.0.0.1:${PORT}/v2/autopreview?project=${ENCODED_PROJECT}&info-output=${ENCODED_INFO_OUTPUT}"
|
||||||
|
```
|
||||||
|
|
||||||
|
区别:
|
||||||
|
|
||||||
|
- `/v2/preview`:单次预览,拿二维码最直接。
|
||||||
|
- `/v2/autopreview`:更适合联调时自动刷新预览。
|
||||||
|
|
||||||
|
## 推荐命令模板
|
||||||
|
|
||||||
|
### macOS 读取端口
|
||||||
|
|
||||||
|
如果已知是默认安装路径,可用下面的方式快速算出 `.ide` 路径:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MD5=$(printf '/Applications/wechatwebdevtools.app/Contents/MacOS' | md5)
|
||||||
|
PORT_FILE="$HOME/Library/Application Support/微信开发者工具/${MD5}/Default/.ide"
|
||||||
|
PORT=$(cat "$PORT_FILE")
|
||||||
|
```
|
||||||
|
|
||||||
|
### URL encode 项目路径
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PROJECT="/absolute/path/to/miniprogram"
|
||||||
|
ENCODED_PROJECT=$(python3 - <<'PY'
|
||||||
|
import os, urllib.parse
|
||||||
|
print(urllib.parse.quote(os.environ['PROJECT'], safe=''))
|
||||||
|
PY
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 上传体验版完整示例
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PROJECT="/absolute/path/to/miniprogram"
|
||||||
|
VERSION="1.2.3"
|
||||||
|
DESC="体验版发布:修复预约课表"
|
||||||
|
INFO_OUTPUT="/tmp/wechat-upload-info.json"
|
||||||
|
|
||||||
|
ENCODED_PROJECT=$(python3 - <<'PY'
|
||||||
|
import os, urllib.parse
|
||||||
|
print(urllib.parse.quote(os.environ['PROJECT'], safe=''))
|
||||||
|
PY
|
||||||
|
)
|
||||||
|
|
||||||
|
ENCODED_DESC=$(python3 - <<'PY'
|
||||||
|
import os, urllib.parse
|
||||||
|
print(urllib.parse.quote(os.environ['DESC'], safe=''))
|
||||||
|
PY
|
||||||
|
)
|
||||||
|
|
||||||
|
ENCODED_INFO_OUTPUT=$(python3 - <<'PY'
|
||||||
|
import os, urllib.parse
|
||||||
|
print(urllib.parse.quote(os.environ['INFO_OUTPUT'], safe=''))
|
||||||
|
PY
|
||||||
|
)
|
||||||
|
|
||||||
|
curl "http://127.0.0.1:${PORT}/v2/upload?project=${ENCODED_PROJECT}&version=${VERSION}&desc=${ENCODED_DESC}&info-output=${ENCODED_INFO_OUTPUT}"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 执行规则
|
||||||
|
|
||||||
|
当你代表用户执行这个流程时,按下面顺序做:
|
||||||
|
|
||||||
|
1. 先确认当前系统是否安装并启动了微信开发者工具。
|
||||||
|
2. 优先读取 `.ide` 端口文件,而不是盲猜端口。
|
||||||
|
3. 上传前先验证 `project.config.json` 是否存在且含 `appid`、`projectname`。
|
||||||
|
4. 涉及路径、备注、输出文件参数时,一律 URL encode。
|
||||||
|
5. 如果 `islogin` 未登录,先引导用户扫码登录,不要直接继续上传。
|
||||||
|
6. 如果用户目标是“发布体验版”,优先使用 `/v2/upload`;不要误用 `/preview` 代替。
|
||||||
|
7. 如果用户目标是“出二维码给别人扫”,优先使用 `/v2/preview`。
|
||||||
|
8. 如果用户目标是“边改边自动刷新”,优先使用 `/v2/autopreview`。
|
||||||
|
|
||||||
|
## 故障排查
|
||||||
|
|
||||||
|
### 端口文件不存在
|
||||||
|
|
||||||
|
说明通常是开发者工具没启动,或者安装路径/MD5 推导错了。
|
||||||
|
|
||||||
|
排查顺序:
|
||||||
|
|
||||||
|
1. 让用户先手动打开微信开发者工具。
|
||||||
|
2. 检查是否使用了正确安装路径。
|
||||||
|
3. 重新计算 MD5。
|
||||||
|
|
||||||
|
### 调用 HTTP 接口失败
|
||||||
|
|
||||||
|
优先检查:
|
||||||
|
|
||||||
|
1. 是否能访问 `http://127.0.0.1:${PORT}/v2/islogin`
|
||||||
|
2. `PORT` 是否来自正确的 `.ide` 文件。
|
||||||
|
3. 开发者工具版本是否支持 `HTTP V2`。
|
||||||
|
|
||||||
|
### 上传失败
|
||||||
|
|
||||||
|
优先检查:
|
||||||
|
|
||||||
|
1. `project` 是否为绝对路径。
|
||||||
|
2. 是否已 URL encode。
|
||||||
|
3. `project.config.json` 是否存在。
|
||||||
|
4. `project.config.json` 里是否包含 `appid` 和 `projectname`。
|
||||||
|
5. 是否已登录工具。
|
||||||
|
6. npm 依赖是否需要先执行 `/v2/buildnpm`。
|
||||||
|
|
||||||
|
## 输出要求
|
||||||
|
|
||||||
|
当用户让你“帮我发布体验版”时,最终回复必须明确交代:
|
||||||
|
|
||||||
|
- 是否成功调用了 `/v2/upload`
|
||||||
|
- 使用的版本号与备注
|
||||||
|
- 是否生成了 `info-output` 文件
|
||||||
|
- 如果还做了预览,二维码输出到哪里或以什么格式返回
|
||||||
|
|
||||||
|
如果没有成功执行,必须明确停在哪一步,不要模糊地说“可能发布了”。
|
||||||
|
|
||||||
|
## 引用来源
|
||||||
|
|
||||||
|
本 skill 基于微信开放文档:
|
||||||
|
|
||||||
|
- 页面:微信开发者工具 HTTP V2
|
||||||
|
- 关键接口:`/v2/login`、`/v2/islogin`、`/v2/open`、`/v2/buildnpm`、`/v2/upload`、`/v2/preview`、`/v2/autopreview`
|
||||||
|
|
||||||
37
AGENTS.md
Normal file
37
AGENTS.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# Repository Guidelines
|
||||||
|
|
||||||
|
## Project Structure & Module Organization
|
||||||
|
This repository is a `pnpm` workspace with three packages under `packages/`:
|
||||||
|
|
||||||
|
- `packages/app`: Vue 3 + `uni-app` WeChat mini-program. Pages live in `src/pages`, shared UI in `src/components`, state in `src/stores`, and utilities in `src/utils`.
|
||||||
|
- `packages/server`: NestJS API with Prisma. Feature modules live in `src/<domain>`, unit tests are colocated in `__tests__`, and the database schema and seed script are in `prisma/`.
|
||||||
|
- `packages/shared`: shared TypeScript enums, constants, and types used by both app and server.
|
||||||
|
|
||||||
|
Reference docs and implementation notes are under `docs/`.
|
||||||
|
|
||||||
|
## Build, Test, and Development Commands
|
||||||
|
- `pnpm dev:server`: start the NestJS API with watch mode.
|
||||||
|
- `pnpm dev:app`: build and serve the `uni-app` target for WeChat mini-program development.
|
||||||
|
- `pnpm build:shared`: compile shared types before consuming package changes.
|
||||||
|
- `pnpm build:server`: build the backend into `packages/server/dist`.
|
||||||
|
- `pnpm build:app`: produce the WeChat mini-program build.
|
||||||
|
- `pnpm test`: run workspace tests; today this mainly executes server Jest tests.
|
||||||
|
- `pnpm lint`: run workspace linting; currently defined for the server.
|
||||||
|
|
||||||
|
For Prisma tasks, work in `packages/server`: `pnpm prisma:generate`, `pnpm prisma:migrate`, `pnpm prisma:seed`.
|
||||||
|
|
||||||
|
## Coding Style & Naming Conventions
|
||||||
|
Use TypeScript throughout. Follow the existing style: 2-space indentation in JSON/Markdown, `camelCase` for variables/functions, `PascalCase` for Vue components and NestJS classes, and `kebab-case` for page/component filenames such as `flash-sales.vue`. Keep modules feature-oriented and prefer colocating DTOs and tests with their domain module.
|
||||||
|
|
||||||
|
Linting is configured in the server via ESLint. The app relies on `vue-tsc` for type checks: run `pnpm --filter @mp-pilates/app type-check` before shipping UI changes.
|
||||||
|
|
||||||
|
## Testing Guidelines
|
||||||
|
Server tests use Jest with `*.spec.ts` naming. Place tests in `packages/server/src/**/__tests__/` and focus on service-level behavior and edge cases around booking, membership, payment, and scheduling logic. Run `pnpm test` for the full suite or `pnpm --filter @mp-pilates/server test:cov` when touching business-critical paths.
|
||||||
|
|
||||||
|
## Commit & Pull Request Guidelines
|
||||||
|
Recent history uses Conventional Commit prefixes such as `feat:`, `fix:`, `fix(app):`, and `perf:`. Keep subjects short and specific, preferably describing the user-visible effect.
|
||||||
|
|
||||||
|
Pull requests should include a concise summary, linked issue or task reference, test notes, and screenshots or recordings for mini-program UI changes. Call out Prisma schema changes, new environment variables, or deployment steps explicitly.
|
||||||
|
|
||||||
|
## Security & Configuration Tips
|
||||||
|
Do not commit real secrets. Review `packages/server/certs/` and environment-specific payment or WeChat credentials carefully before pushing. When changing shared types or enums, update both consumers and rebuild `@mp-pilates/shared` to avoid runtime drift.
|
||||||
152
CLAUDE.md
152
CLAUDE.md
@@ -1,99 +1,93 @@
|
|||||||
# CLAUDE.md
|
# CLAUDE.md
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
本文档为 Claude Code (claude.ai/code) 在本项目中工作时提供指导。
|
||||||
|
|
||||||
## 项目概述
|
## 项目概述
|
||||||
|
|
||||||
普拉提工作室预约与会员管理的微信小程序。TypeScript monorepo,包含三个包:
|
这是一个普拉提预约微信小程序项目,后端采用 NestJS 框架。项目使用 pnpm monorepo 结构,包含 3 个包:
|
||||||
|
|
||||||
- **packages/server** — NestJS 后端(REST API、Prisma ORM、PostgreSQL)
|
- **packages/app** - Vue 3 + uni-app(微信小程序前端)
|
||||||
- **packages/app** — Vue 3 + Pinia 前端,基于 Uni-app(目标平台 mp-weixin)
|
- **packages/server** - NestJS(后端 API 服务)
|
||||||
- **packages/shared** — 前后端共用的 TypeScript 类型、枚举和常量
|
- **packages/shared** - TypeScript 类型定义、枚举、常量(前后端共用)
|
||||||
|
|
||||||
## 常用命令
|
## 常用命令
|
||||||
|
|
||||||
### 开发
|
|
||||||
```bash
|
```bash
|
||||||
pnpm dev:server # NestJS watch 模式 (localhost:3000)
|
# 开发
|
||||||
pnpm dev:app # 微信小程序开发服务器
|
pnpm dev:server # 启动 NestJS 后端(热重载)
|
||||||
pnpm build:shared # 必须先构建 shared,再构建 server/app
|
pnpm dev:app # 构建 uni-app 为微信小程序
|
||||||
```
|
|
||||||
|
|
||||||
### 测试(仅 server)
|
# 构建
|
||||||
```bash
|
pnpm build:shared # 编译共享类型
|
||||||
|
pnpm build:server # 构建 NestJS 后端
|
||||||
|
pnpm build:app # 构建微信小程序
|
||||||
|
|
||||||
|
# 测试与代码检查
|
||||||
|
pnpm test # 运行所有测试(仅 server)
|
||||||
|
pnpm lint # 运行 ESLint(仅 server)
|
||||||
|
|
||||||
|
# 数据库相关(位于 packages/server 目录)
|
||||||
cd packages/server
|
cd packages/server
|
||||||
pnpm test # 运行全部测试
|
pnpm prisma:generate # 生成 Prisma 客户端
|
||||||
pnpm test -- auth.service.spec # 运行单个测试文件
|
pnpm prisma:migrate # 执行数据库迁移
|
||||||
pnpm test:watch # watch 模式
|
pnpm prisma:seed # 填充测试数据
|
||||||
pnpm test:cov # 覆盖率报告
|
pnpm test:watch # 监听模式运行测试
|
||||||
|
|
||||||
|
# 部署
|
||||||
|
pnpm deploy:server # 部署后端到生产环境
|
||||||
```
|
```
|
||||||
|
|
||||||
Jest 配置内联在 `packages/server/package.json`。测试文件位于 `__tests__/` 子目录(如 `src/auth/__tests__/auth.service.spec.ts`),匹配模式:`*.spec.ts`。
|
## 架构说明
|
||||||
|
|
||||||
|
### 前端 (packages/app)
|
||||||
|
- 基于 Vue 3 + uni-app 框架,主攻微信小程序平台
|
||||||
|
- 页面目录:`src/pages/`(包含 home、booking、card、profile、admin 等模块)
|
||||||
|
- 组件目录:`src/components/`
|
||||||
|
- 状态管理:Pinia
|
||||||
|
- 样式:SCSS
|
||||||
|
|
||||||
|
### 后端 (packages/server)
|
||||||
|
- 框架:NestJS + Prisma ORM
|
||||||
|
- 核心模块:auth(认证)、user(用户)、booking(预约)、membership(会员卡)、payment(支付)、studio(场馆)、time-slot(时段)、scheduler(定时任务)、admin(管理)
|
||||||
|
- 认证:JWT + 微信登录
|
||||||
|
- 定时任务:@nestjs/schedule
|
||||||
|
- 数据库:SQLite(开发)/ MySQL(生产)
|
||||||
|
|
||||||
|
### 共享包 (packages/shared)
|
||||||
|
- TypeScript 接口和类型定义
|
||||||
|
- 枚举值定义
|
||||||
|
- 前后端共用的 DTO 类型
|
||||||
|
|
||||||
|
### API 结构
|
||||||
|
- 所有接口统一前缀:`/api`
|
||||||
|
- RESTful 风格接口
|
||||||
|
- 全局拦截器:日志记录、响应包装
|
||||||
|
- 全局过滤器:异常处理
|
||||||
|
|
||||||
### 数据库
|
### 数据库
|
||||||
```bash
|
- Prisma schema 位于 `packages/server/prisma/schema.prisma`
|
||||||
cd packages/server
|
- 核心数据模型:User、Studio、TimeSlot、Booking、Membership、CardType、Order
|
||||||
pnpm prisma:generate # schema 变更后重新生成 Prisma Client
|
- 注意:查询会员列表时,booking 统计通过 `groupBy` 批量获取,避免 N+1 查询
|
||||||
pnpm prisma:migrate # 运行迁移(交互式)
|
|
||||||
pnpm prisma:seed # 填充种子数据
|
|
||||||
```
|
|
||||||
|
|
||||||
### 代码检查
|
### 卡类型枚举
|
||||||
```bash
|
- `CardTypeCategory` (TIMES/DURATION/TRIAL) 定义在 `packages/shared/src/enums.ts`
|
||||||
pnpm lint # 所有包的 ESLint 检查
|
- 会员管理筛选使用特殊值 `NONE` 表示无卡/无有效会员(不在枚举中)
|
||||||
```
|
- 前端选项硬编码在 `src/pages/admin/members.vue` 的 `cardTypeOptions`,需与枚举保持同步
|
||||||
|
|
||||||
## 架构
|
### 管理后台 API 模式
|
||||||
|
- `/admin/members` 支持 `page`, `limit`, `search`, `cardType` 参数
|
||||||
|
- `cardType=NONE` → 无有效会员的用户;其他值对应 `CardTypeCategory`
|
||||||
|
- 预约统计(total/completed/cancelled)通过 `groupBy` 批量查询
|
||||||
|
|
||||||
### 数据流
|
### 筛选组件模式
|
||||||
```
|
- picker 筛选使用 300ms debounce 再触发加载,避免频繁请求
|
||||||
微信小程序 → Uni-app (Vue 3) → REST API (NestJS) → Prisma → PostgreSQL
|
- 列表分页使用 `onReachBottom` + `hasMore` 标志位实现无限滚动
|
||||||
↕ ↕
|
|
||||||
Pinia stores @nestjs/schedule (定时任务)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 后端模块结构
|
### Admin Store (`src/stores/admin.ts`)
|
||||||
每个功能是一个 NestJS 模块,遵循 controller → service → Prisma 模式。核心模块:
|
- 聚合所有管理端 API 调用:weekTemplates、cardTypes、studioConfig、members、bookings、orders、stats 等
|
||||||
- **auth** — 微信 OAuth 登录(code2Session)、JWT 令牌、手机号绑定
|
- 遵循不可变更新原则:`data` 赋值使用展开运算符 `[...newData]`
|
||||||
- **booking** — 创建/取消预约,含会员卡验证和容量检查
|
### 历史课程补录
|
||||||
- **time-slot** — 课程时段管理;`SlotGeneratorService` 根据 `WeekTemplate` 自动生成
|
- `LessonSupplement` 独立记录历史累计课时,归属 user 模块;DTO 放 `user/dto`,测试放 `user/__tests__`,前端入口为 `pages/admin/member-supplement.vue`。
|
||||||
- **membership** — 基于卡的会员制(TIMES 次卡、DURATION 时效卡、TRIAL 体验卡)
|
- 补录不创建预约或时段;只增加累计已完成节数,不推测上课日期、天数、时长,不参与月度统计、活跃网格或邀请奖励。
|
||||||
- **payment** — 微信支付集成,用于购卡
|
- 可选择从本人有限次会员卡扣次;补录与扣次必须事务提交,保存实际扣次快照,撤销只返还实际扣次。请求标识用于幂等重试。
|
||||||
- **scheduler** — 定时任务:02:00 自动生成时段,02:30 清理过期时段
|
- Prisma 迁移按 `YYYYMMDDHHmmss_description/migration.sql` 存放;补录表采用增量迁移,回退说明维护在 `docs/lesson-supplement.md`,不删除审计记录。
|
||||||
|
|
||||||
### 前端结构
|
|
||||||
- **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 兼容所需
|
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ echo_info "文件上传完成"
|
|||||||
# 6. 服务器端安装依赖并重启
|
# 6. 服务器端安装依赖并重启
|
||||||
echo_info "服务器部署操作..."
|
echo_info "服务器部署操作..."
|
||||||
ssh ${SERVER_USER}@${SERVER_HOST} "bash -l" << 'ENDSSH'
|
ssh ${SERVER_USER}@${SERVER_HOST} "bash -l" << 'ENDSSH'
|
||||||
|
set -e
|
||||||
export PATH="$HOME/.nvm/versions/node/v22.17.1/bin:$PATH"
|
export PATH="$HOME/.nvm/versions/node/v22.17.1/bin:$PATH"
|
||||||
SERVER_PATH="/usr/local/web/mp-pilates-server"
|
SERVER_PATH="/usr/local/web/mp-pilates-server"
|
||||||
PM2_APP_NAME="mp-pilates-server"
|
PM2_APP_NAME="mp-pilates-server"
|
||||||
|
|||||||
338
docs/STUDIO_COS_SETUP.md
Normal file
338
docs/STUDIO_COS_SETUP.md
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
# 工作室画廊 COS 接入配置说明
|
||||||
|
|
||||||
|
本文档对应当前仓库当前实现。
|
||||||
|
|
||||||
|
现在已经不再使用 STS `AssumeRole`。
|
||||||
|
当前方案改为:
|
||||||
|
|
||||||
|
- 服务端使用长期密钥直接签发 COS POST Policy
|
||||||
|
- 管理中心小程序拿到表单签名后直传 COS
|
||||||
|
- 工作室配置中的 `logo`、`bannerUrl`、`photos` 保存最终可访问 URL
|
||||||
|
|
||||||
|
当前实现代码入口:
|
||||||
|
|
||||||
|
- `packages/server/src/studio/studio-upload.service.ts`
|
||||||
|
- `packages/server/src/studio/studio.controller.ts`
|
||||||
|
- `packages/app/src/utils/studio-upload.ts`
|
||||||
|
- `packages/app/src/pages/admin/studio.vue`
|
||||||
|
|
||||||
|
## 一、整体链路
|
||||||
|
|
||||||
|
1. 管理中心点击上传图片。
|
||||||
|
2. 小程序请求服务端 `POST /api/admin/studio/upload-credentials`。
|
||||||
|
3. 服务端用 `COS_SECRET_ID`、`COS_SECRET_KEY` 直接生成一组 POST Policy 表单字段。
|
||||||
|
4. 服务端把 `uploadUrl`、`key`、`formData`、`fileUrl`、`expiresAt` 返回给小程序。
|
||||||
|
5. 小程序使用 `uni.uploadFile` 直接上传到 COS。
|
||||||
|
6. 上传成功后,把 URL 保存到工作室配置,再调用 `PUT /api/admin/studio/info` 落库。
|
||||||
|
|
||||||
|
这个方案没有临时密钥,也没有角色扮演。
|
||||||
|
安全边界来自两层:
|
||||||
|
|
||||||
|
- 服务端只为单个对象 key 签发一次表单策略
|
||||||
|
- 表单策略有明确过期时间,过期后自动失效
|
||||||
|
|
||||||
|
## 二、这个方案的本质
|
||||||
|
|
||||||
|
你现在选的是“服务端代签名”的直传方案。
|
||||||
|
它和 STS 的差别是:
|
||||||
|
|
||||||
|
- STS:给前端一段时间内可用的短期密钥
|
||||||
|
- 当前方案:不给前端密钥,只给前端一个短时有效的上传表单签名
|
||||||
|
|
||||||
|
所以结论很直接:
|
||||||
|
|
||||||
|
- 仍然有有效期
|
||||||
|
- 但有效期作用在 POST Policy 上,不是作用在临时密钥上
|
||||||
|
|
||||||
|
当前代码里默认有效期是 `1800` 秒。
|
||||||
|
环境变量:
|
||||||
|
|
||||||
|
- `COS_UPLOAD_DURATION_SECONDS`
|
||||||
|
|
||||||
|
当前实现限制范围:
|
||||||
|
|
||||||
|
- 最短 `300` 秒
|
||||||
|
- 最长 `7200` 秒
|
||||||
|
|
||||||
|
## 三、你现在真正需要准备的东西
|
||||||
|
|
||||||
|
先确认下面几个信息:
|
||||||
|
|
||||||
|
- COS Bucket 名称,例如 `plates-1251306435`
|
||||||
|
- COS 所在地域,例如 `ap-guangzhou`
|
||||||
|
- 服务端使用的 COS 长期密钥 `SecretId` / `SecretKey`
|
||||||
|
- 图片上传前缀,例如 `mp/studio`
|
||||||
|
- 图片访问域名
|
||||||
|
|
||||||
|
建议约定:
|
||||||
|
|
||||||
|
- Bucket:`plates-1251306435`
|
||||||
|
- Region:`ap-guangzhou`
|
||||||
|
- Prefix:`mp/studio`
|
||||||
|
|
||||||
|
## 四、COS 控制台配置
|
||||||
|
|
||||||
|
### 1. 创建或确认 Bucket
|
||||||
|
|
||||||
|
控制台路径:`对象存储 COS`
|
||||||
|
|
||||||
|
建议:
|
||||||
|
|
||||||
|
- 地域选 `广州` 或你当前实际地域
|
||||||
|
- 存储类型标准存储即可
|
||||||
|
- Bucket 名称和环境变量保持完全一致
|
||||||
|
|
||||||
|
### 2. 图片访问方式
|
||||||
|
|
||||||
|
当前实现保存的是直接图片 URL。
|
||||||
|
所以图片必须能被小程序和前台直接访问。
|
||||||
|
|
||||||
|
你有两种方式:
|
||||||
|
|
||||||
|
1. 直接使用 COS 源站并允许读
|
||||||
|
2. 配 CDN / 自定义域名并让这个域名可直接访问图片
|
||||||
|
|
||||||
|
如果你什么都不配,上传成功后图片可能打不开。
|
||||||
|
|
||||||
|
最直接做法:
|
||||||
|
|
||||||
|
- 让这个图片 Bucket 对外可读
|
||||||
|
|
||||||
|
更稳妥做法:
|
||||||
|
|
||||||
|
- 单独图片 Bucket
|
||||||
|
- 用 CDN 域名做 `COS_PUBLIC_BASE_URL`
|
||||||
|
|
||||||
|
### 3. 微信小程序合法域名
|
||||||
|
|
||||||
|
微信公众平台需要补白名单:
|
||||||
|
|
||||||
|
- `request 合法域名`:你的后端 API 域名
|
||||||
|
- `uploadFile 合法域名`:`https://<bucket>.cos.<region>.myqcloud.com`
|
||||||
|
- `downloadFile 合法域名`:图片访问域名
|
||||||
|
|
||||||
|
如果图片访问也走 COS 源站,那么 `downloadFile 合法域名` 同样加:
|
||||||
|
|
||||||
|
- `https://<bucket>.cos.<region>.myqcloud.com`
|
||||||
|
|
||||||
|
例如:
|
||||||
|
|
||||||
|
- `https://focus.richarjiang.com`
|
||||||
|
- `https://plates-1251306435.cos.ap-guangzhou.myqcloud.com`
|
||||||
|
|
||||||
|
## 五、服务端账号需要什么权限
|
||||||
|
|
||||||
|
现在已经不需要:
|
||||||
|
|
||||||
|
- STS
|
||||||
|
- CAM 角色
|
||||||
|
- `AssumeRole`
|
||||||
|
- 角色信任策略
|
||||||
|
- `COS_UPLOAD_ROLE_ARN`
|
||||||
|
|
||||||
|
现在服务端只需要一对可以给目标 Bucket 生成上传签名的长期密钥。
|
||||||
|
|
||||||
|
最简单的做法是:
|
||||||
|
|
||||||
|
- 用你的主账号密钥
|
||||||
|
|
||||||
|
但生产上更合理的是:
|
||||||
|
|
||||||
|
- 建一个专用 CAM 用户,只给这个 Bucket 上传相关权限
|
||||||
|
|
||||||
|
### 推荐 CAM 用户权限策略
|
||||||
|
|
||||||
|
如果你要建专用 CAM 用户,给它绑定下面这类策略即可。
|
||||||
|
|
||||||
|
把下面真实值替换成你的实际资源:
|
||||||
|
|
||||||
|
- 地域:`ap-guangzhou`
|
||||||
|
- AppId:`1251306435`
|
||||||
|
- Bucket:`plates-1251306435`
|
||||||
|
- Prefix:`mp/studio`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": "2.0",
|
||||||
|
"statement": [
|
||||||
|
{
|
||||||
|
"effect": "allow",
|
||||||
|
"action": [
|
||||||
|
"name/cos:PutObject",
|
||||||
|
"name/cos:PostObject"
|
||||||
|
],
|
||||||
|
"resource": [
|
||||||
|
"qcs::cos:ap-guangzhou:uid/1251306435:plates-1251306435/mp/studio/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
如果你后续还要服务端删除对象,再补:
|
||||||
|
|
||||||
|
- `name/cos:DeleteObject`
|
||||||
|
|
||||||
|
当前仓库实现不需要删除对象,所以先不要额外放大权限。
|
||||||
|
|
||||||
|
## 六、服务端环境变量
|
||||||
|
|
||||||
|
把下面变量配置到 `packages/server/.env` 或线上环境:
|
||||||
|
|
||||||
|
```env
|
||||||
|
COS_SECRET_ID=your-cos-secret-id
|
||||||
|
COS_SECRET_KEY=your-cos-secret-key
|
||||||
|
COS_BUCKET=plates-1251306435
|
||||||
|
COS_REGION=ap-guangzhou
|
||||||
|
COS_PUBLIC_BASE_URL=https://plates-1251306435.cos.ap-guangzhou.myqcloud.com
|
||||||
|
COS_UPLOAD_PREFIX=mp/studio
|
||||||
|
COS_UPLOAD_DURATION_SECONDS=1800
|
||||||
|
```
|
||||||
|
|
||||||
|
各字段含义:
|
||||||
|
|
||||||
|
- `COS_SECRET_ID`:用于签发 POST Policy 的长期密钥 ID
|
||||||
|
- `COS_SECRET_KEY`:用于签发 POST Policy 的长期密钥 Key
|
||||||
|
- `COS_BUCKET`:上传目标 Bucket
|
||||||
|
- `COS_REGION`:Bucket 地域
|
||||||
|
- `COS_PUBLIC_BASE_URL`:最终展示图片的访问域名
|
||||||
|
- `COS_UPLOAD_PREFIX`:统一对象前缀
|
||||||
|
- `COS_UPLOAD_DURATION_SECONDS`:Policy 有效期秒数
|
||||||
|
|
||||||
|
现在可以删除或忽略这些旧配置:
|
||||||
|
|
||||||
|
- `COS_UPLOAD_ROLE_ARN`
|
||||||
|
- `COS_APP_ID`
|
||||||
|
- `COS_UPLOAD_ROLE_SESSION_NAME`
|
||||||
|
|
||||||
|
它们对当前实现已经没用。
|
||||||
|
|
||||||
|
## 七、控制台操作清单
|
||||||
|
|
||||||
|
按这个顺序做:
|
||||||
|
|
||||||
|
1. 确认 COS Bucket 已存在。
|
||||||
|
2. 确认图片访问域名对外可读。
|
||||||
|
3. 在微信公众平台加好 `request` / `uploadFile` / `downloadFile` 合法域名。
|
||||||
|
4. 准备一对 COS 长期密钥。
|
||||||
|
5. 把 `COS_SECRET_ID`、`COS_SECRET_KEY`、`COS_BUCKET`、`COS_REGION`、`COS_PUBLIC_BASE_URL`、`COS_UPLOAD_PREFIX` 配到服务端。
|
||||||
|
6. 重启服务端。
|
||||||
|
7. 在管理中心上传一张图片测试。
|
||||||
|
|
||||||
|
## 八、接口返回内容说明
|
||||||
|
|
||||||
|
请求:
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/admin/studio/upload-credentials
|
||||||
|
Content-Type: application/json
|
||||||
|
Authorization: Bearer <admin-token>
|
||||||
|
|
||||||
|
{
|
||||||
|
"fileName": "demo.jpg",
|
||||||
|
"contentType": "image/jpeg",
|
||||||
|
"assetType": "gallery"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
正常返回会包含:
|
||||||
|
|
||||||
|
- `uploadUrl`
|
||||||
|
- `fileUrl`
|
||||||
|
- `key`
|
||||||
|
- `assetType`
|
||||||
|
- `expiresAt`
|
||||||
|
- `formData`
|
||||||
|
|
||||||
|
`formData` 里会有这些字段:
|
||||||
|
|
||||||
|
- `key`
|
||||||
|
- `policy`
|
||||||
|
- `success_action_status`
|
||||||
|
- `Content-Type`
|
||||||
|
- `q-sign-algorithm`
|
||||||
|
- `q-ak`
|
||||||
|
- `q-key-time`
|
||||||
|
- `q-sign-time`
|
||||||
|
- `q-signature`
|
||||||
|
|
||||||
|
这就是小程序直传需要的全部内容。
|
||||||
|
|
||||||
|
## 九、怎么验证是否配置正确
|
||||||
|
|
||||||
|
### 1. 接口层验证
|
||||||
|
|
||||||
|
调用 `POST /api/admin/studio/upload-credentials`。
|
||||||
|
|
||||||
|
如果成功,说明:
|
||||||
|
|
||||||
|
- 服务端长期密钥有效
|
||||||
|
- 服务端已经能正确签发 policy
|
||||||
|
|
||||||
|
### 2. 上传层验证
|
||||||
|
|
||||||
|
在管理中心上传一张图,检查:
|
||||||
|
|
||||||
|
1. COS Bucket 下是否出现对象
|
||||||
|
2. 返回的 `fileUrl` 浏览器是否能直接访问
|
||||||
|
3. 保存工作室设置后首页是否显示该图
|
||||||
|
|
||||||
|
### 3. 失败时怎么定位
|
||||||
|
|
||||||
|
如果 `upload-credentials` 接口失败,优先检查:
|
||||||
|
|
||||||
|
- `COS_SECRET_ID` / `COS_SECRET_KEY` 是否正确
|
||||||
|
- `COS_BUCKET` / `COS_REGION` 是否正确
|
||||||
|
- 服务端是否已经加载最新环境变量
|
||||||
|
|
||||||
|
如果接口成功但上传失败,优先检查:
|
||||||
|
|
||||||
|
- 小程序 `uploadFile 合法域名` 是否正确
|
||||||
|
- Bucket 权限策略是否允许当前长期密钥上传到该前缀
|
||||||
|
- `Content-Type` 是否被策略条件限制住
|
||||||
|
|
||||||
|
如果上传成功但图片打不开,优先检查:
|
||||||
|
|
||||||
|
- Bucket 或图片域名是否可公网访问
|
||||||
|
- `COS_PUBLIC_BASE_URL` 是否正确
|
||||||
|
- 小程序 `downloadFile 合法域名` 是否正确
|
||||||
|
|
||||||
|
## 十、当前实现的边界
|
||||||
|
|
||||||
|
当前仓库实现边界如下:
|
||||||
|
|
||||||
|
- 只支持 `jpg`、`jpeg`、`png`、`webp`、`heic`、`heif`
|
||||||
|
- 单次上传大小上限 `10MB`
|
||||||
|
- 只支持普通表单直传,不支持分片上传
|
||||||
|
- 删除工作室图片时,只会从数据库配置里移除 URL,不会删除 COS 历史对象
|
||||||
|
|
||||||
|
最后一条是故意保守设计。
|
||||||
|
原因很简单:
|
||||||
|
|
||||||
|
- 先保证配置删除安全
|
||||||
|
- 避免误删真实文件
|
||||||
|
|
||||||
|
如果以后要做“删配置时同步删对象”,那时再单独加 `DeleteObject` 权限。
|
||||||
|
|
||||||
|
## 十一、初始化工作室画廊
|
||||||
|
|
||||||
|
如果你要把现在手工写死的图片 URL 一次性写入数据库,执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm --filter @mp-pilates/server studio:seed-gallery
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本文件:
|
||||||
|
|
||||||
|
- `packages/server/prisma/update-studio-gallery.ts`
|
||||||
|
|
||||||
|
## 十二、建议的生产做法
|
||||||
|
|
||||||
|
如果你后面要长期维护,建议:
|
||||||
|
|
||||||
|
1. 图片单独放一个 Bucket。
|
||||||
|
2. 长期密钥不要直接用主账号,换成专用 CAM 用户。
|
||||||
|
3. 对专用 CAM 用户只给 `mp/studio/*` 前缀上传权限。
|
||||||
|
4. 用 CDN 域名作为 `COS_PUBLIC_BASE_URL`。
|
||||||
|
|
||||||
|
这样后面扩展、迁移、审计都会更稳。
|
||||||
606
docs/TIME_SLOT_DIAGRAMS.md
Normal file
606
docs/TIME_SLOT_DIAGRAMS.md
Normal file
@@ -0,0 +1,606 @@
|
|||||||
|
# Time-Slot & Scheduling System - Architecture Diagrams
|
||||||
|
|
||||||
|
## 1. Data Model Relationships
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ WEEK TEMPLATE │
|
||||||
|
│ │
|
||||||
|
│ dayOfWeek (1-7, ISO standard) │
|
||||||
|
│ startTime, endTime (e.g., "09:00", "10:00") │
|
||||||
|
│ capacity (default 1) │
|
||||||
|
│ isActive (can disable template) │
|
||||||
|
│ │
|
||||||
|
│ ↓ (auto-generates) │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ TIME SLOT │
|
||||||
|
│ │
|
||||||
|
│ date (calendar date, midnight UTC) │
|
||||||
|
│ startTime, endTime (from template) │
|
||||||
|
│ capacity (from template) │
|
||||||
|
│ bookedCount (# of current bookings) │
|
||||||
|
│ status (OPEN | FULL | CLOSED) │
|
||||||
|
│ source (TEMPLATE | MANUAL) │
|
||||||
|
│ templateId (reference to WeekTemplate) │
|
||||||
|
│ │
|
||||||
|
│ ↓ (has many) ↓ (belongs to) │
|
||||||
|
│ └─────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
↑ ↑
|
||||||
|
│ │
|
||||||
|
└────────────┬─────────────────────────
|
||||||
|
│
|
||||||
|
│ 1:1 booking
|
||||||
|
│
|
||||||
|
↓
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ BOOKING │
|
||||||
|
│ │
|
||||||
|
│ userId (FK to User) │
|
||||||
|
│ timeSlotId (FK to TimeSlot) │
|
||||||
|
│ membershipId (FK to Membership) │
|
||||||
|
│ status (CONFIRMED | CANCELLED | COMPLETED | NO_SHOW) │
|
||||||
|
│ cancelledAt (timestamp when cancelled) │
|
||||||
|
│ │
|
||||||
|
│ Constraints: │
|
||||||
|
│ - Unique [userId, timeSlotId] (one booking per user per slot) │
|
||||||
|
│ - ONE booking per TimeSlot per user │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
↑ ↑
|
||||||
|
│ │
|
||||||
|
└─────────────┬───────────┘
|
||||||
|
│
|
||||||
|
belongs to
|
||||||
|
│
|
||||||
|
↓
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ MEMBERSHIP │
|
||||||
|
│ │
|
||||||
|
│ userId (FK to User) │
|
||||||
|
│ cardTypeId (FK to CardType) │
|
||||||
|
│ remainingTimes (for TIMES/TRIAL card types) │
|
||||||
|
│ expireDate (for DURATION card types) │
|
||||||
|
│ status (ACTIVE | EXPIRED | USED_UP) │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Daily Scheduler Timeline
|
||||||
|
|
||||||
|
```
|
||||||
|
00:00 ─────────────────────────────────────────────────
|
||||||
|
│
|
||||||
|
├─ [Midnight] - Time passes
|
||||||
|
│
|
||||||
|
├─ System running in background
|
||||||
|
│
|
||||||
|
├─ ... (various other operations)
|
||||||
|
│
|
||||||
|
02:00 ─────────────────────────────────────────────────
|
||||||
|
│
|
||||||
|
├─► 🟢 SLOT GENERATION
|
||||||
|
│ SlotGeneratorService.generateSlots(14)
|
||||||
|
│
|
||||||
|
│ ├─ Query WeekTemplate (all isActive=true)
|
||||||
|
│ ├─ For each day in [tomorrow, tomorrow+14):
|
||||||
|
│ │ ├─ Get ISO weekday
|
||||||
|
│ │ ├─ Find matching templates
|
||||||
|
│ │ └─ Create TimeSlot entries
|
||||||
|
│ ├─ Batch insert with skipDuplicates: true
|
||||||
|
│ └─ Log: "Generated X new time slots"
|
||||||
|
│
|
||||||
|
02:30 ─────────────────────────────────────────────────
|
||||||
|
│
|
||||||
|
├─► 🟡 SLOT CLEANUP
|
||||||
|
│ SlotGeneratorService.cleanupExpiredSlots()
|
||||||
|
│
|
||||||
|
│ ├─ Find all OPEN slots with date < TODAY
|
||||||
|
│ ├─ Mark as CLOSED
|
||||||
|
│ └─ Log: "Closed X expired time slots"
|
||||||
|
│
|
||||||
|
03:00 ─────────────────────────────────────────────────
|
||||||
|
│
|
||||||
|
├─► 🟠 MEMBERSHIP CHECK
|
||||||
|
│ SlotGeneratorService.checkExpiredMemberships()
|
||||||
|
│
|
||||||
|
│ ├─ Update ACTIVE memberships with expireDate < NOW
|
||||||
|
│ │ └─ Set status = EXPIRED
|
||||||
|
│ ├─ Update ACTIVE memberships with remainingTimes = 0
|
||||||
|
│ │ └─ Set status = USED_UP
|
||||||
|
│ └─ Log: "Expired X by date, Y by sessions"
|
||||||
|
│
|
||||||
|
├─ ... (users awake, making bookings)
|
||||||
|
│
|
||||||
|
22:00 ─────────────────────────────────────────────────
|
||||||
|
│
|
||||||
|
├─► 🔴 BOOKING COMPLETION
|
||||||
|
│ SlotGeneratorService.completeBookings()
|
||||||
|
│
|
||||||
|
│ ├─ Find CONFIRMED bookings with timeSlot.date < TODAY
|
||||||
|
│ ├─ Mark as COMPLETED
|
||||||
|
│ └─ Log: "Completed X past bookings"
|
||||||
|
│
|
||||||
|
└─ (Day ends, repeat tomorrow)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Booking Lifecycle
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ BOOKING CREATION │
|
||||||
|
│ (POST /booking) │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
├─ Input: { timeSlotId, membershipId }
|
||||||
|
│
|
||||||
|
├─ TRANSACTION START ──────────────────
|
||||||
|
│ │
|
||||||
|
│ ├─► Fetch TimeSlot
|
||||||
|
│ │ └─ Check: status = OPEN? ✓
|
||||||
|
│ │
|
||||||
|
│ ├─► Check Duplicate
|
||||||
|
│ │ └─ Query: SELECT * FROM bookings WHERE userId=? AND timeSlotId=?
|
||||||
|
│ │ └─ Must not exist
|
||||||
|
│ │
|
||||||
|
│ ├─► Fetch Membership
|
||||||
|
│ │ └─ Check: belongs to user? ✓
|
||||||
|
│ │ └─ Check: status = ACTIVE? ✓
|
||||||
|
│ │ └─ Check: has capacity?
|
||||||
|
│ │ └─ IF TIMES/TRIAL: remainingTimes > 0? ✓
|
||||||
|
│ │ └─ IF DURATION: expireDate > NOW? ✓
|
||||||
|
│ │
|
||||||
|
│ ├─► CREATE Booking(CONFIRMED)
|
||||||
|
│ │ └─ INSERT: { userId, timeSlotId, membershipId, status: CONFIRMED }
|
||||||
|
│ │
|
||||||
|
│ ├─► UPDATE TimeSlot
|
||||||
|
│ │ ├─ bookedCount = bookedCount + 1
|
||||||
|
│ │ ├─ IF bookedCount >= capacity:
|
||||||
|
│ │ │ └─ status = FULL
|
||||||
|
│ │ └─ ELSE:
|
||||||
|
│ │ └─ status = OPEN (unchanged)
|
||||||
|
│ │
|
||||||
|
│ ├─► UPDATE Membership (if TIMES/TRIAL)
|
||||||
|
│ │ ├─ remainingTimes = remainingTimes - 1
|
||||||
|
│ │ ├─ IF remainingTimes <= 0:
|
||||||
|
│ │ │ └─ status = USED_UP
|
||||||
|
│ │ └─ ELSE:
|
||||||
|
│ │ └─ status = ACTIVE (unchanged)
|
||||||
|
│ │
|
||||||
|
│ └─ TRANSACTION COMMIT ──────────────
|
||||||
|
│
|
||||||
|
└─► Return: BookingWithRelations (includes timeSlot, membership)
|
||||||
|
|
||||||
|
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ BOOKING CANCELLATION │
|
||||||
|
│ (PUT /booking/:id/cancel) │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
├─ Fetch Booking + TimeSlot + Membership
|
||||||
|
│
|
||||||
|
├─ Check: booking.status = CONFIRMED? ✓
|
||||||
|
│
|
||||||
|
├─ Calculate Refund Eligibility
|
||||||
|
│ │
|
||||||
|
│ ├─ cancelHoursLimit = StudioConfig.cancelHoursLimit (default 2)
|
||||||
|
│ ├─ slotStartMs = Date(timeSlot.date) + timeSlot.startTime
|
||||||
|
│ ├─ deadlineMs = NOW + (cancelHoursLimit * 3600 * 1000)
|
||||||
|
│ │
|
||||||
|
│ ├─ IF slotStartMs >= deadlineMs:
|
||||||
|
│ │ └─ withinLimit = TRUE ✓ (User gets refund)
|
||||||
|
│ └─ ELSE:
|
||||||
|
│ └─ withinLimit = FALSE (No refund)
|
||||||
|
│
|
||||||
|
├─ TRANSACTION START ──────────────────
|
||||||
|
│ │
|
||||||
|
│ ├─► UPDATE Booking
|
||||||
|
│ │ ├─ status = CANCELLED
|
||||||
|
│ │ └─ cancelledAt = NOW
|
||||||
|
│ │
|
||||||
|
│ ├─► UPDATE TimeSlot
|
||||||
|
│ │ ├─ bookedCount = MAX(0, bookedCount - 1)
|
||||||
|
│ │ ├─ IF slot was FULL:
|
||||||
|
│ │ │ └─ status = OPEN
|
||||||
|
│ │ └─ ELSE:
|
||||||
|
│ │ └─ status = (unchanged)
|
||||||
|
│ │
|
||||||
|
│ ├─► IF withinLimit = TRUE:
|
||||||
|
│ │ └─ UPDATE Membership (if TIMES/TRIAL)
|
||||||
|
│ │ ├─ remainingTimes = remainingTimes + 1
|
||||||
|
│ │ ├─ IF was USED_UP:
|
||||||
|
│ │ │ └─ status = ACTIVE
|
||||||
|
│ │ └─ ELSE:
|
||||||
|
│ │ └─ status = (unchanged)
|
||||||
|
│ │
|
||||||
|
│ └─ TRANSACTION COMMIT ──────────────
|
||||||
|
│
|
||||||
|
└─► Return: { booking, refunded: boolean }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Slot Generation from Template
|
||||||
|
|
||||||
|
```
|
||||||
|
Template Setup:
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ PUT /admin/week-template │
|
||||||
|
│ │
|
||||||
|
│ { │
|
||||||
|
│ "templates": [ │
|
||||||
|
│ { │
|
||||||
|
│ "dayOfWeek": 1, // Monday (ISO standard)│
|
||||||
|
│ "startTime": "09:00", │
|
||||||
|
│ "endTime": "10:00", │
|
||||||
|
│ "capacity": 1, // Private lesson │
|
||||||
|
│ "isActive": true │
|
||||||
|
│ }, │
|
||||||
|
│ { │
|
||||||
|
│ "dayOfWeek": 5, // Friday (ISO standard)│
|
||||||
|
│ "startTime": "18:00", │
|
||||||
|
│ "endTime": "19:00", │
|
||||||
|
│ "capacity": 1, │
|
||||||
|
│ "isActive": true │
|
||||||
|
│ } │
|
||||||
|
│ ] │
|
||||||
|
│ } │
|
||||||
|
└─────────────────────────────────────────────────────┘
|
||||||
|
↓
|
||||||
|
Stored in database as WeekTemplates
|
||||||
|
↓
|
||||||
|
Each day at 02:00 UTC, generateSlots(14) runs:
|
||||||
|
|
||||||
|
|
||||||
|
Today: Monday, April 7, 2026
|
||||||
|
│
|
||||||
|
├─ Tomorrow = Tuesday, April 8
|
||||||
|
│
|
||||||
|
├─ For next 14 days:
|
||||||
|
│
|
||||||
|
│ Day 0: Tue (ISO 2) → no matching template → skip
|
||||||
|
│ Day 1: Wed (ISO 3) → no matching template → skip
|
||||||
|
│ Day 2: Thu (ISO 4) → no matching template → skip
|
||||||
|
│ Day 3: Fri (ISO 5) → MATCH! template (18:00-19:00)
|
||||||
|
│ └─ CREATE TimeSlot(date=Apr12, time=18:00-19:00, capacity=1)
|
||||||
|
│
|
||||||
|
│ Day 4: Sat (ISO 6) → no matching template → skip
|
||||||
|
│ Day 5: Sun (ISO 7) → no matching template → skip
|
||||||
|
│ Day 6: Mon (ISO 1) → MATCH! template (09:00-10:00)
|
||||||
|
│ └─ CREATE TimeSlot(date=Apr14, time=09:00-10:00, capacity=1)
|
||||||
|
│
|
||||||
|
│ Day 7: Tue (ISO 2) → no matching template → skip
|
||||||
|
│ ... (repeats pattern)
|
||||||
|
│
|
||||||
|
└─ All created with:
|
||||||
|
├─ status = OPEN
|
||||||
|
├─ bookedCount = 0
|
||||||
|
├─ source = TEMPLATE
|
||||||
|
├─ templateId = (reference to template)
|
||||||
|
└─ skipDuplicates = true (safe to re-run)
|
||||||
|
|
||||||
|
|
||||||
|
Result:
|
||||||
|
14 Friday 18:00-19:00 slots generated
|
||||||
|
14 Monday 09:00-10:00 slots generated
|
||||||
|
Total: 28 new slots
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. User Booking Flow (Frontend → Backend)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────┐
|
||||||
|
│ MEMBER CLIENT │
|
||||||
|
└──────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
│ 1. Click "View Available Slots"
|
||||||
|
│
|
||||||
|
├─► GET /time-slot/available?date=2026-04-10
|
||||||
|
│
|
||||||
|
│ Response: [{
|
||||||
|
│ id: "slot-123",
|
||||||
|
│ date: "2026-04-10",
|
||||||
|
│ startTime: "09:00",
|
||||||
|
│ endTime: "10:00",
|
||||||
|
│ status: "OPEN",
|
||||||
|
│ bookedCount: 0,
|
||||||
|
│ capacity: 1,
|
||||||
|
│ isBookedByMe: false, ← User's booking status
|
||||||
|
│ myBookingId: null
|
||||||
|
│ }, ...]
|
||||||
|
│
|
||||||
|
├─ Display available slots in UI
|
||||||
|
│
|
||||||
|
│ 2. User selects slot and membership
|
||||||
|
│
|
||||||
|
├─► POST /booking
|
||||||
|
│ Body: {
|
||||||
|
│ "timeSlotId": "slot-123",
|
||||||
|
│ "membershipId": "mem-456"
|
||||||
|
│ }
|
||||||
|
│
|
||||||
|
│ Response: {
|
||||||
|
│ id: "booking-789",
|
||||||
|
│ userId: "user-001",
|
||||||
|
│ timeSlotId: "slot-123",
|
||||||
|
│ status: "CONFIRMED",
|
||||||
|
│ createdAt: "2026-04-05T10:30:00Z",
|
||||||
|
│ timeSlot: { ... }, ← Full slot details
|
||||||
|
│ membership: { ... } ← Full membership details
|
||||||
|
│ }
|
||||||
|
│
|
||||||
|
├─ Display confirmation
|
||||||
|
│
|
||||||
|
│ 3. [Later] User cancels booking
|
||||||
|
│
|
||||||
|
└─► PUT /booking/booking-789/cancel
|
||||||
|
|
||||||
|
Response: {
|
||||||
|
booking: { ... },
|
||||||
|
refunded: true ← Was refund issued?
|
||||||
|
}
|
||||||
|
|
||||||
|
Display: "Booking cancelled. You've been refunded."
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. State Transitions
|
||||||
|
|
||||||
|
### TimeSlot Status
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────┐
|
||||||
|
│ AUTO-GENERATED │
|
||||||
|
│ by generateSlots() │
|
||||||
|
└─────────────┬───────────────────┘
|
||||||
|
│
|
||||||
|
↓
|
||||||
|
┌─────────────────┐
|
||||||
|
│ OPEN │ ← Can accept bookings
|
||||||
|
│ (bookedCount < │ bookedCount starts at 0
|
||||||
|
│ capacity) │
|
||||||
|
└────────┬────────┘
|
||||||
|
│
|
||||||
|
┌──────────┼──────────┐
|
||||||
|
│ │ │
|
||||||
|
│ │ │
|
||||||
|
[booking │ [cleanup
|
||||||
|
creates] │ or manual
|
||||||
|
│ close]
|
||||||
|
│ │ │
|
||||||
|
↓ ↓ ↓
|
||||||
|
FULL CLOSED
|
||||||
|
(bookedCount >= capacity)
|
||||||
|
│
|
||||||
|
│ [booking cancelled]
|
||||||
|
↓
|
||||||
|
OPEN (back to)
|
||||||
|
|
||||||
|
|
||||||
|
Once slot date passes:
|
||||||
|
├─ OPEN → CLOSED (by cleanup job at 02:30 UTC)
|
||||||
|
├─ FULL → CLOSED (when cleanup runs)
|
||||||
|
└─ CANCELLED bookings don't affect slot status
|
||||||
|
```
|
||||||
|
|
||||||
|
### Booking Status
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────┐
|
||||||
|
│ CONFIRMED │ ← Default when created
|
||||||
|
│ │ User has active reservation
|
||||||
|
└────────┬─────────┘
|
||||||
|
│
|
||||||
|
┌─────┼─────┐
|
||||||
|
│ │ │
|
||||||
|
[user │ [auto-mark
|
||||||
|
cancels] │ when date
|
||||||
|
│ │ passes]
|
||||||
|
│ │ │
|
||||||
|
↓ ↓ ↓
|
||||||
|
CANCELLED COMPLETED
|
||||||
|
(free (slot time
|
||||||
|
cancellation has passed)
|
||||||
|
until deadline)
|
||||||
|
|
||||||
|
|
||||||
|
CANCELLED bookings stay in history
|
||||||
|
COMPLETED bookings show in past bookings
|
||||||
|
CONFIRMED bookings show in upcoming bookings
|
||||||
|
```
|
||||||
|
|
||||||
|
### Membership Status
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────┐
|
||||||
|
│ ACTIVE │ ← Can book classes
|
||||||
|
│ │ Has remaining capacity
|
||||||
|
└────────┬────────┘
|
||||||
|
│
|
||||||
|
┌─────┼─────┐
|
||||||
|
│ │ │
|
||||||
|
[booking │ [auto-check
|
||||||
|
depletes │ by scheduler
|
||||||
|
sessions] │ at 03:00 UTC]
|
||||||
|
│ │ │
|
||||||
|
↓ ↓ ↓
|
||||||
|
USED_UP EXPIRED
|
||||||
|
(for (for
|
||||||
|
TIMES) DURATION)
|
||||||
|
|
||||||
|
|
||||||
|
USED_UP: remainingTimes = 0 (for TIMES/TRIAL only)
|
||||||
|
EXPIRED: expireDate < NOW (for DURATION) OR date-based expiry
|
||||||
|
|
||||||
|
All non-ACTIVE statuses prevent new bookings
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Timezone & Date Handling
|
||||||
|
|
||||||
|
```
|
||||||
|
User Timezone: Local (browser/app)
|
||||||
|
API Timezone: UTC (backend)
|
||||||
|
Database: UTC
|
||||||
|
|
||||||
|
┌──────────────────────────────────────────────┐
|
||||||
|
│ User in Shanghai (UTC+8) │
|
||||||
|
│ Local time: 2026-04-10 15:00:00 CST │
|
||||||
|
│ UTC time: 2026-04-10 07:00:00 UTC │
|
||||||
|
└──────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
├─ Query: GET /time-slot/available?date=2026-04-10
|
||||||
|
│ (User sends local date, frontend converts to ISO)
|
||||||
|
│
|
||||||
|
├─ Backend receives:
|
||||||
|
│ ├─ Parse "2026-04-10"
|
||||||
|
│ ├─ Build start of day: 2026-04-10T00:00:00 UTC
|
||||||
|
│ ├─ Build end of day: 2026-04-10T23:59:59.999 UTC
|
||||||
|
│ ├─ Query TimeSlots WHERE date BETWEEN [00:00, 23:59]
|
||||||
|
│
|
||||||
|
└─ Return slots for that calendar day in UTC
|
||||||
|
|
||||||
|
|
||||||
|
┌──────────────────────────────────────────────┐
|
||||||
|
│ TimeSlot Storage (Database) │
|
||||||
|
│ │
|
||||||
|
│ date: 2026-04-10 (DATE type, midnight UTC) │
|
||||||
|
│ startTime: "09:00" (string, no timezone) │
|
||||||
|
│ endTime: "10:00" (string, no timezone) │
|
||||||
|
│ │
|
||||||
|
│ When combined: │
|
||||||
|
│ Slot datetime = 2026-04-10T09:00:00 UTC │
|
||||||
|
└──────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
├─ For Shanghai user (UTC+8):
|
||||||
|
│ └─ 09:00 UTC = 17:00 CST (5 PM)
|
||||||
|
│
|
||||||
|
└─ For New York user (UTC-4):
|
||||||
|
└─ 09:00 UTC = 05:00 EDT (5 AM)
|
||||||
|
|
||||||
|
|
||||||
|
Scheduler (UTC times):
|
||||||
|
┌─────────────────────────────────────────────┐
|
||||||
|
│ 02:00 UTC = Generate slots │
|
||||||
|
│ 02:30 UTC = Cleanup │
|
||||||
|
│ 03:00 UTC = Check memberships │
|
||||||
|
│ 22:00 UTC = Complete bookings │
|
||||||
|
│ │
|
||||||
|
│ When scheduler checks "is date < today?": │
|
||||||
|
│ ├─ Create midnight UTC boundary │
|
||||||
|
│ ├─ Compare slot.date < today's midnight │
|
||||||
|
│ └─ Mark as CLOSED/COMPLETED if older │
|
||||||
|
└─────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Error Handling Tree
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /booking
|
||||||
|
│
|
||||||
|
├─ TimeSlot not found
|
||||||
|
│ └─ Return: NotFoundException
|
||||||
|
│
|
||||||
|
├─ TimeSlot.status ≠ OPEN
|
||||||
|
│ └─ Return: BadRequestException("TimeSlot is not available")
|
||||||
|
│
|
||||||
|
├─ Duplicate booking exists
|
||||||
|
│ └─ Return: ConflictException("Already booked this slot")
|
||||||
|
│
|
||||||
|
├─ Membership not found
|
||||||
|
│ └─ Return: NotFoundException
|
||||||
|
│
|
||||||
|
├─ Membership.userId ≠ current user
|
||||||
|
│ └─ Return: ForbiddenException("Not your membership")
|
||||||
|
│
|
||||||
|
├─ Membership.status ≠ ACTIVE
|
||||||
|
│ └─ Return: BadRequestException("Membership inactive")
|
||||||
|
│
|
||||||
|
├─ Card type is TIMES/TRIAL:
|
||||||
|
│ │
|
||||||
|
│ └─ remainingTimes ≤ 0
|
||||||
|
│ └─ Return: BadRequestException("No remaining times")
|
||||||
|
│
|
||||||
|
└─ Card type is DURATION:
|
||||||
|
│
|
||||||
|
└─ expireDate < NOW
|
||||||
|
└─ Return: BadRequestException("Membership expired")
|
||||||
|
|
||||||
|
|
||||||
|
PUT /booking/:id/cancel
|
||||||
|
│
|
||||||
|
├─ Booking not found
|
||||||
|
│ └─ Return: NotFoundException
|
||||||
|
│
|
||||||
|
├─ Booking.userId ≠ current user
|
||||||
|
│ └─ Return: ForbiddenException("Not your booking")
|
||||||
|
│
|
||||||
|
├─ Booking.status ≠ CONFIRMED
|
||||||
|
│ └─ Return: BadRequestException("Can't cancel this status")
|
||||||
|
│
|
||||||
|
└─ ✓ Cancel successful
|
||||||
|
├─ Check refund eligibility
|
||||||
|
├─ Update booking status
|
||||||
|
├─ Update timeSlot bookedCount
|
||||||
|
└─ Conditionally refund membership
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Integration Points
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ APP MODULE │
|
||||||
|
│ (packages/server/src/app.module.ts) │
|
||||||
|
└─────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
├─ imports: [
|
||||||
|
│ AuthModule,
|
||||||
|
│ TimeSlotModule, ← Time-Slot logic
|
||||||
|
│ SchedulerModule, ← Auto jobs (cron)
|
||||||
|
│ BookingModule, ← Booking logic
|
||||||
|
│ MembershipModule, ← Membership checks
|
||||||
|
│ StudioModule, ← Config (cancelHoursLimit)
|
||||||
|
│ ...
|
||||||
|
│ ]
|
||||||
|
│
|
||||||
|
└─ Controllers route to:
|
||||||
|
│
|
||||||
|
├─ TimeSlotController (public slots viewing)
|
||||||
|
├─ AdminTimeSlotController (templates, admin actions)
|
||||||
|
├─ BookingController (create, cancel bookings)
|
||||||
|
└─ ... (other endpoints)
|
||||||
|
|
||||||
|
|
||||||
|
SchedulerModule dependencies:
|
||||||
|
├─ ScheduleModule.forRoot() ← Enable @Cron decorators
|
||||||
|
└─ TimeSlotModule ← Access to SlotGeneratorService
|
||||||
|
|
||||||
|
|
||||||
|
BookingModule dependencies:
|
||||||
|
├─ MembershipModule ← Check membership status
|
||||||
|
└─ StudioModule ← Read cancelHoursLimit config
|
||||||
|
|
||||||
|
|
||||||
|
Services call chain:
|
||||||
|
├─ Controller
|
||||||
|
│ ├─ TimeSlotService
|
||||||
|
│ │ └─ PrismaService
|
||||||
|
│ └─ BookingService
|
||||||
|
│ ├─ PrismaService
|
||||||
|
│ ├─ MembershipService
|
||||||
|
│ └─ StudioService
|
||||||
|
```
|
||||||
|
|
||||||
364
docs/TIME_SLOT_INDEX.md
Normal file
364
docs/TIME_SLOT_INDEX.md
Normal file
@@ -0,0 +1,364 @@
|
|||||||
|
# Time-Slot & Scheduling System - Documentation Index
|
||||||
|
|
||||||
|
This directory contains comprehensive documentation of the NestJS backend time-slot and scheduling system for the pilates studio booking platform.
|
||||||
|
|
||||||
|
## 📚 Documentation Files
|
||||||
|
|
||||||
|
### 1. **TIME_SLOT_SCHEDULING_SYSTEM.md** (966 lines, 24KB)
|
||||||
|
**Most comprehensive reference** - Full system analysis with all details
|
||||||
|
|
||||||
|
**Contents:**
|
||||||
|
- Executive Summary
|
||||||
|
- Data Models (WeekTemplate, TimeSlot, Booking) with Prisma schema
|
||||||
|
- SlotGeneratorService (4 key methods: generateSlots, cleanupExpiredSlots, checkExpiredMemberships, completeBookings)
|
||||||
|
- TimeSlotService (queries and management)
|
||||||
|
- TimeSlotController & AdminTimeSlotController (all endpoints)
|
||||||
|
- SchedulerService (4 daily cron jobs at 02:00, 02:30, 03:00, 22:00 UTC)
|
||||||
|
- BookingService (integration with time slots)
|
||||||
|
- Data Flow Diagrams
|
||||||
|
- DTOs & Request/Response examples
|
||||||
|
- Shared Constants & Enums
|
||||||
|
- File Structure Summary
|
||||||
|
- Key Architectural Patterns
|
||||||
|
- Example Scenarios
|
||||||
|
- Testing Guide
|
||||||
|
- Configuration & Environment
|
||||||
|
- Performance Considerations
|
||||||
|
- Security Notes
|
||||||
|
- Future Enhancement Ideas
|
||||||
|
|
||||||
|
**When to use:** Deep dive into how the system works, understanding all components
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. **TIME_SLOT_QUICK_REFERENCE.md** (355 lines, 9KB)
|
||||||
|
**Quick lookup guide** - Essential information at a glance
|
||||||
|
|
||||||
|
**Contents:**
|
||||||
|
- File Locations (all key files in one table)
|
||||||
|
- Key Concepts (WeekTemplate, TimeSlot, Booking)
|
||||||
|
- Daily Scheduler Jobs (quick table with times and purposes)
|
||||||
|
- Important Methods (TypeScript signatures for all key methods)
|
||||||
|
- API Endpoints (member and admin endpoints with request/response)
|
||||||
|
- Status Values (all enum values explained)
|
||||||
|
- Key Logic (booking creation & cancellation flows in pseudocode)
|
||||||
|
- Weekday Mapping (ISO standard vs JavaScript)
|
||||||
|
- Database Constraints
|
||||||
|
- Configuration
|
||||||
|
- Common Errors (troubleshooting table)
|
||||||
|
- Testing
|
||||||
|
- Development Workflow
|
||||||
|
- Architecture Highlights
|
||||||
|
|
||||||
|
**When to use:** Quick lookup while coding, API reference, debugging errors
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. **TIME_SLOT_DIAGRAMS.md** (606 lines, 25KB)
|
||||||
|
**Visual references** - ASCII diagrams and flowcharts
|
||||||
|
|
||||||
|
**Contents:**
|
||||||
|
1. Data Model Relationships (entity diagram)
|
||||||
|
2. Daily Scheduler Timeline (24-hour cron schedule visualization)
|
||||||
|
3. Booking Lifecycle (detailed creation and cancellation flows)
|
||||||
|
4. Slot Generation from Template (step-by-step with example)
|
||||||
|
5. User Booking Flow (frontend → backend interaction)
|
||||||
|
6. State Transitions (TimeSlot, Booking, Membership status flows)
|
||||||
|
7. Timezone & Date Handling (UTC, local time conversion)
|
||||||
|
8. Error Handling Tree (decision tree for POST /booking and cancellation)
|
||||||
|
9. Integration Points (module dependencies)
|
||||||
|
|
||||||
|
**When to use:** Understanding the big picture, presenting to team, tracing flow execution
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 Key Information at a Glance
|
||||||
|
|
||||||
|
### Source Code Locations
|
||||||
|
|
||||||
|
```
|
||||||
|
Backend Time-Slot System:
|
||||||
|
├── packages/server/src/time-slot/
|
||||||
|
│ ├── slot-generator.service.ts (172 lines)
|
||||||
|
│ ├── time-slot.service.ts (142 lines)
|
||||||
|
│ ├── time-slot.controller.ts (93 lines)
|
||||||
|
│ ├── time-slot.module.ts
|
||||||
|
│ └── dto/
|
||||||
|
│ ├── query-slots.dto.ts
|
||||||
|
│ ├── create-manual-slot.dto.ts
|
||||||
|
│ └── week-template.dto.ts
|
||||||
|
│
|
||||||
|
├── packages/server/src/scheduler/
|
||||||
|
│ ├── scheduler.service.ts (55 lines)
|
||||||
|
│ └── scheduler.module.ts
|
||||||
|
│
|
||||||
|
├── packages/server/src/booking/
|
||||||
|
│ ├── booking.service.ts (367 lines)
|
||||||
|
│ ├── booking.controller.ts (82 lines)
|
||||||
|
│ ├── booking.module.ts
|
||||||
|
│ └── dto/
|
||||||
|
│ └── create-booking.dto.ts
|
||||||
|
│
|
||||||
|
├── packages/server/prisma/
|
||||||
|
│ └── schema.prisma (Models: WeekTemplate, TimeSlot, Booking)
|
||||||
|
│
|
||||||
|
└── packages/shared/src/
|
||||||
|
├── constants.ts (Slot generation, capacity defaults)
|
||||||
|
├── enums.ts (TimeSlotStatus, BookingStatus, etc.)
|
||||||
|
└── types/
|
||||||
|
└── time-slot.ts (Type definitions)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Daily Scheduler (UTC)
|
||||||
|
|
||||||
|
| Time | Job | Method |
|
||||||
|
|------|-----|--------|
|
||||||
|
| 02:00 | Generate 14 days of slots | `SlotGeneratorService.generateSlots(14)` |
|
||||||
|
| 02:30 | Close expired OPEN slots | `SlotGeneratorService.cleanupExpiredSlots()` |
|
||||||
|
| 03:00 | Expire memberships | `SlotGeneratorService.checkExpiredMemberships()` |
|
||||||
|
| 22:00 | Complete past bookings | `SlotGeneratorService.completeBookings()` |
|
||||||
|
|
||||||
|
### Important Constants
|
||||||
|
|
||||||
|
```
|
||||||
|
DEFAULT_SLOT_CAPACITY = 1 (private lessons)
|
||||||
|
SLOT_GENERATION_DAYS = 14 (days ahead to auto-generate)
|
||||||
|
DEFAULT_CANCEL_HOURS_LIMIT = 2 (hours before slot to allow refund)
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Endpoints
|
||||||
|
|
||||||
|
**Member:**
|
||||||
|
```
|
||||||
|
GET /time-slot/available?date=YYYY-MM-DD
|
||||||
|
GET /time-slot/:id
|
||||||
|
POST /booking
|
||||||
|
PUT /booking/:id/cancel
|
||||||
|
GET /booking/my
|
||||||
|
GET /booking/my/upcoming
|
||||||
|
```
|
||||||
|
|
||||||
|
**Admin:**
|
||||||
|
```
|
||||||
|
GET /admin/week-template
|
||||||
|
PUT /admin/week-template
|
||||||
|
POST /admin/time-slot/manual
|
||||||
|
PUT /admin/time-slot/:id/close
|
||||||
|
POST /admin/generate-slots
|
||||||
|
GET /admin/bookings
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Common Tasks & Where to Find Info
|
||||||
|
|
||||||
|
| Task | Reference |
|
||||||
|
|------|-----------|
|
||||||
|
| **Understand slot generation algorithm** | TIME_SLOT_SCHEDULING_SYSTEM.md § 2.2 or DIAGRAMS § 4 |
|
||||||
|
| **See all API endpoints** | QUICK_REFERENCE § "API Endpoints" or TIME_SLOT_SCHEDULING_SYSTEM.md § 4 |
|
||||||
|
| **Booking creation logic** | TIME_SLOT_DIAGRAMS.md § 3 or QUICK_REFERENCE § "Key Logic" |
|
||||||
|
| **Weekday mapping (ISO vs JS)** | QUICK_REFERENCE § "Weekday Mapping" or DIAGRAMS § 7 |
|
||||||
|
| **Cancellation refund policy** | TIME_SLOT_SCHEDULING_SYSTEM.md § 6.1 or DIAGRAMS § 3 |
|
||||||
|
| **Scheduler jobs timeline** | QUICK_REFERENCE § "Daily Scheduler Jobs" or DIAGRAMS § 2 |
|
||||||
|
| **Error handling** | QUICK_REFERENCE § "Common Errors" or DIAGRAMS § 8 |
|
||||||
|
| **Data model relationships** | DIAGRAMS § 1 or TIME_SLOT_SCHEDULING_SYSTEM.md § 1 |
|
||||||
|
| **Configuration & setup** | QUICK_REFERENCE § "Configuration" or TIME_SLOT_SCHEDULING_SYSTEM.md § 14 |
|
||||||
|
| **Performance tips** | TIME_SLOT_SCHEDULING_SYSTEM.md § 15 or QUICK_REFERENCE § "Performance Tips" |
|
||||||
|
| **Module dependencies** | DIAGRAMS § 9 or TIME_SLOT_SCHEDULING_SYSTEM.md § 11.2 |
|
||||||
|
| **Testing** | TIME_SLOT_SCHEDULING_SYSTEM.md § 13 or QUICK_REFERENCE § "Testing" |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 System Overview
|
||||||
|
|
||||||
|
### What It Does
|
||||||
|
|
||||||
|
This system manages the complete lifecycle of time slots and bookings for a pilates studio:
|
||||||
|
|
||||||
|
1. **Automated Slot Generation**: Every day at 02:00 UTC, generates 14 days of time slots from reusable weekly templates
|
||||||
|
2. **Capacity Management**: Tracks slot capacity and prevents overbooking
|
||||||
|
3. **Booking Management**: Allows members to book slots with their memberships
|
||||||
|
4. **Cancellation & Refunds**: Members can cancel with conditional refunds (within 2-hour window)
|
||||||
|
5. **Membership Expiration**: Automatically expires memberships by date or used sessions
|
||||||
|
6. **Cleanup**: Marks past slots as closed and completed bookings as finished
|
||||||
|
|
||||||
|
### Key Concepts
|
||||||
|
|
||||||
|
- **WeekTemplate**: Defines recurring schedule (e.g., "Monday 09:00-10:00")
|
||||||
|
- **TimeSlot**: Individual class instance (e.g., "April 10, 2026 09:00-10:00")
|
||||||
|
- **Booking**: User's reservation (links user + slot + membership)
|
||||||
|
- **Status Tracking**: OPEN → FULL → CLOSED (slots) and CONFIRMED → COMPLETED (bookings)
|
||||||
|
|
||||||
|
### Architecture Highlights
|
||||||
|
|
||||||
|
✅ **Idempotent** - Safe to re-run slot generation
|
||||||
|
✅ **Transactional** - ACID compliance for bookings
|
||||||
|
✅ **Automated** - 4 daily cron jobs maintain state
|
||||||
|
✅ **Flexible** - Supports TIMES, DURATION, and TRIAL memberships
|
||||||
|
✅ **Scalable** - Batch operations, proper database indexes
|
||||||
|
✅ **Secure** - Role-based access, comprehensive validation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Getting Started
|
||||||
|
|
||||||
|
### For New Developers
|
||||||
|
|
||||||
|
1. **Start with**: TIME_SLOT_QUICK_REFERENCE.md
|
||||||
|
- Get oriented with file locations and key methods
|
||||||
|
|
||||||
|
2. **Then read**: TIME_SLOT_DIAGRAMS.md § 1 (Data Model)
|
||||||
|
- Understand how entities relate
|
||||||
|
|
||||||
|
3. **Deep dive**: TIME_SLOT_SCHEDULING_SYSTEM.md § 2
|
||||||
|
- Study the SlotGeneratorService algorithm
|
||||||
|
|
||||||
|
4. **Explore the code**: Read actual source files for implementation details
|
||||||
|
|
||||||
|
### For System Integration
|
||||||
|
|
||||||
|
1. Review TIME_SLOT_DIAGRAMS.md § 9 (Integration Points)
|
||||||
|
2. Check the module imports in `app.module.ts`
|
||||||
|
3. Understand dependencies in QUICK_REFERENCE.md § "Configuration"
|
||||||
|
|
||||||
|
### For API Integration
|
||||||
|
|
||||||
|
1. Start with TIME_SLOT_QUICK_REFERENCE.md § "API Endpoints"
|
||||||
|
2. See examples in TIME_SLOT_SCHEDULING_SYSTEM.md § 12
|
||||||
|
3. Check DTOs in TIME_SLOT_SCHEDULING_SYSTEM.md § 8
|
||||||
|
|
||||||
|
### For Debugging
|
||||||
|
|
||||||
|
1. Check common errors in QUICK_REFERENCE.md § "Common Errors"
|
||||||
|
2. Trace error handling in DIAGRAMS.md § 8
|
||||||
|
3. Review actual error handling in source code
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📖 Reading Recommendations by Role
|
||||||
|
|
||||||
|
### Backend Developer
|
||||||
|
1. TIME_SLOT_SCHEDULING_SYSTEM.md (all)
|
||||||
|
2. TIME_SLOT_DIAGRAMS.md (all)
|
||||||
|
3. Source code in `packages/server/src/time-slot/`
|
||||||
|
|
||||||
|
### Frontend Developer
|
||||||
|
1. TIME_SLOT_QUICK_REFERENCE.md (API Endpoints section)
|
||||||
|
2. TIME_SLOT_SCHEDULING_SYSTEM.md § 12 (Example Scenarios)
|
||||||
|
3. TIME_SLOT_DIAGRAMS.md § 5 (User Booking Flow)
|
||||||
|
|
||||||
|
### DevOps / Sysadmin
|
||||||
|
1. TIME_SLOT_SCHEDULING_SYSTEM.md § 14 (Configuration)
|
||||||
|
2. TIME_SLOT_QUICK_REFERENCE.md § "Daily Scheduler Jobs"
|
||||||
|
3. TIME_SLOT_DIAGRAMS.md § 2 (Scheduler Timeline)
|
||||||
|
|
||||||
|
### Product Manager
|
||||||
|
1. TIME_SLOT_SCHEDULING_SYSTEM.md § "Executive Summary"
|
||||||
|
2. TIME_SLOT_DIAGRAMS.md § 3 & 5 (Booking flows)
|
||||||
|
3. TIME_SLOT_QUICK_REFERENCE.md § "Architecture Highlights"
|
||||||
|
|
||||||
|
### QA / Tester
|
||||||
|
1. TIME_SLOT_QUICK_REFERENCE.md (all)
|
||||||
|
2. TIME_SLOT_SCHEDULING_SYSTEM.md § 13 (Testing Guide)
|
||||||
|
3. TIME_SLOT_SCHEDULING_SYSTEM.md § 12 (Example Scenarios)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔗 Related Documentation
|
||||||
|
|
||||||
|
- **Database Schema**: See `packages/server/prisma/schema.prisma` (lines 113-168)
|
||||||
|
- **Shared Types**: See `packages/shared/src/types/` and `enums.ts`
|
||||||
|
- **Authentication**: See booking endpoints require JwtAuthGuard
|
||||||
|
- **Membership System**: See `BookingService` integration with `MembershipService`
|
||||||
|
- **Studio Config**: See `StudioService` for `cancelHoursLimit`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Document Statistics
|
||||||
|
|
||||||
|
| File | Lines | Size | Topics |
|
||||||
|
|------|-------|------|--------|
|
||||||
|
| TIME_SLOT_SCHEDULING_SYSTEM.md | 966 | 24KB | 17 comprehensive sections |
|
||||||
|
| TIME_SLOT_QUICK_REFERENCE.md | 355 | 9KB | 15 quick-lookup sections |
|
||||||
|
| TIME_SLOT_DIAGRAMS.md | 606 | 25KB | 9 visual flowcharts |
|
||||||
|
| **Total** | **1,927** | **58KB** | **Complete system coverage** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎓 Learning Path
|
||||||
|
|
||||||
|
```
|
||||||
|
Entry Level
|
||||||
|
├─ README.md (this file)
|
||||||
|
├─ TIME_SLOT_QUICK_REFERENCE.md (20 min read)
|
||||||
|
└─ TIME_SLOT_DIAGRAMS.md § 1 (5 min)
|
||||||
|
|
||||||
|
Intermediate
|
||||||
|
├─ TIME_SLOT_DIAGRAMS.md (all, 15 min)
|
||||||
|
├─ TIME_SLOT_QUICK_REFERENCE.md (re-read, 15 min)
|
||||||
|
└─ TIME_SLOT_SCHEDULING_SYSTEM.md § 1-6 (30 min)
|
||||||
|
|
||||||
|
Advanced
|
||||||
|
├─ TIME_SLOT_SCHEDULING_SYSTEM.md (full, 60 min)
|
||||||
|
├─ Source code reading (packages/server/src/time-slot/)
|
||||||
|
└─ Prisma schema study
|
||||||
|
|
||||||
|
Expert
|
||||||
|
└─ Code review + contributions
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🤝 Contributing
|
||||||
|
|
||||||
|
When adding features or making changes:
|
||||||
|
|
||||||
|
1. **Update the code** in `packages/server/src/time-slot/` and related modules
|
||||||
|
2. **Update tests** in `__tests__/` directories
|
||||||
|
3. **Update documentation** in this docs folder if behavior changes
|
||||||
|
4. Use the **Quick Reference** as checklist for all affected pieces
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ❓ FAQ
|
||||||
|
|
||||||
|
**Q: Where do time slots come from?**
|
||||||
|
A: Auto-generated from WeekTemplates every day at 02:00 UTC by `generateSlots(14)`.
|
||||||
|
|
||||||
|
**Q: Can I disable slot generation?**
|
||||||
|
A: Yes, make templates `isActive: false` or disable the cron job in `scheduler.service.ts`.
|
||||||
|
|
||||||
|
**Q: How is capacity managed?**
|
||||||
|
A: `bookedCount` increments on booking, slot status becomes FULL when `bookedCount >= capacity`.
|
||||||
|
|
||||||
|
**Q: What if I cancel a booking?**
|
||||||
|
A: `bookedCount` decrements; if within 2-hour window, membership refunded; slot status restored if was FULL.
|
||||||
|
|
||||||
|
**Q: Timezone support?**
|
||||||
|
A: All times stored in UTC. Scheduler uses UTC times (02:00, 02:30, etc.). See DIAGRAMS § 7.
|
||||||
|
|
||||||
|
**Q: How are memberships expired?**
|
||||||
|
A: Automatically by scheduler job at 03:00 UTC daily; marks EXPIRED if date passed or USED_UP if sessions depleted.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📞 Quick Reference Card
|
||||||
|
|
||||||
|
### Status Values
|
||||||
|
- **TimeSlot**: OPEN | FULL | CLOSED
|
||||||
|
- **Booking**: CONFIRMED | CANCELLED | COMPLETED | NO_SHOW
|
||||||
|
- **Membership**: ACTIVE | EXPIRED | USED_UP
|
||||||
|
|
||||||
|
### Key Dates & Times
|
||||||
|
- **Slot generation**: Daily 02:00 UTC (14 days ahead)
|
||||||
|
- **Cleanup**: Daily 02:30 UTC
|
||||||
|
- **Membership check**: Daily 03:00 UTC
|
||||||
|
- **Booking completion**: Daily 22:00 UTC
|
||||||
|
- **Cancellation window**: 2 hours before slot (configurable)
|
||||||
|
|
||||||
|
### Key Files
|
||||||
|
- **Slot generation**: `slot-generator.service.ts`
|
||||||
|
- **Slot queries**: `time-slot.service.ts`
|
||||||
|
- **Booking logic**: `booking.service.ts`
|
||||||
|
- **Database**: `prisma/schema.prisma`
|
||||||
|
|
||||||
355
docs/TIME_SLOT_QUICK_REFERENCE.md
Normal file
355
docs/TIME_SLOT_QUICK_REFERENCE.md
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
# Time-Slot & Scheduling System - Quick Reference
|
||||||
|
|
||||||
|
## File Locations
|
||||||
|
|
||||||
|
| Component | Path |
|
||||||
|
|-----------|------|
|
||||||
|
| **Slot Generator** | `packages/server/src/time-slot/slot-generator.service.ts` |
|
||||||
|
| **TimeSlot Service** | `packages/server/src/time-slot/time-slot.service.ts` |
|
||||||
|
| **TimeSlot Controller** | `packages/server/src/time-slot/time-slot.controller.ts` |
|
||||||
|
| **Scheduler** | `packages/server/src/scheduler/scheduler.service.ts` |
|
||||||
|
| **Booking Service** | `packages/server/src/booking/booking.service.ts` |
|
||||||
|
| **Booking Controller** | `packages/server/src/booking/booking.controller.ts` |
|
||||||
|
| **Database Schema** | `packages/server/prisma/schema.prisma` |
|
||||||
|
| **Shared Constants** | `packages/shared/src/constants.ts` |
|
||||||
|
| **Shared Enums** | `packages/shared/src/enums.ts` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Concepts
|
||||||
|
|
||||||
|
### WeekTemplate
|
||||||
|
Defines **recurring class schedule** by day of week (1=Monday, 7=Sunday) and time.
|
||||||
|
- Used to auto-generate TimeSlots nightly
|
||||||
|
- Can be enabled/disabled
|
||||||
|
- Has capacity (default 1 for private lessons)
|
||||||
|
|
||||||
|
### TimeSlot
|
||||||
|
**Individual class instance** on a specific date with a specific time.
|
||||||
|
- Status: OPEN → FULL → CLOSED
|
||||||
|
- Source: TEMPLATE (auto-generated) or MANUAL (admin-created)
|
||||||
|
- Cannot have duplicates (unique constraint on date+startTime+endTime)
|
||||||
|
|
||||||
|
### Booking
|
||||||
|
**User's reservation** for a specific TimeSlot.
|
||||||
|
- Status: CONFIRMED → COMPLETED (or CANCELLED)
|
||||||
|
- Links user + timeSlot + membership
|
||||||
|
- Unique constraint: one booking per user per slot
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Daily Scheduler Jobs
|
||||||
|
|
||||||
|
All times in UTC:
|
||||||
|
|
||||||
|
| Time | Job | What It Does |
|
||||||
|
|------|-----|--------------|
|
||||||
|
| **02:00** | `handleSlotGeneration()` | Generate slots 14 days ahead from WeekTemplates |
|
||||||
|
| **02:30** | `handleCleanupSlots()` | Mark past OPEN slots as CLOSED |
|
||||||
|
| **03:00** | `handleCheckMemberships()` | Expire memberships by date or used-up sessions |
|
||||||
|
| **22:00** | `handleCompleteBookings()` | Mark past CONFIRMED bookings as COMPLETED |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Important Methods
|
||||||
|
|
||||||
|
### SlotGeneratorService
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Generate N days of slots from WeekTemplates
|
||||||
|
generateSlots(daysAhead = 14): Promise<number>
|
||||||
|
|
||||||
|
// Close all past OPEN slots
|
||||||
|
cleanupExpiredSlots(): Promise<number>
|
||||||
|
|
||||||
|
// Expire memberships by date or session count
|
||||||
|
checkExpiredMemberships(): Promise<number>
|
||||||
|
|
||||||
|
// Mark past bookings as COMPLETED
|
||||||
|
completeBookings(): Promise<number>
|
||||||
|
```
|
||||||
|
|
||||||
|
### TimeSlotService
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Get all slots for a date (with user's booking status if provided)
|
||||||
|
getAvailableSlots(date: string, userId?: string): Promise<TimeSlotWithBookingStatus[]>
|
||||||
|
|
||||||
|
// Manually create a one-off slot
|
||||||
|
createManualSlot(dto: CreateManualSlotDto): Promise<TimeSlot>
|
||||||
|
|
||||||
|
// Close a slot (prevent new bookings)
|
||||||
|
closeSlot(id: string): Promise<TimeSlot>
|
||||||
|
|
||||||
|
// Get/replace weekly templates
|
||||||
|
getWeekTemplates(): Promise<WeekTemplate[]>
|
||||||
|
replaceWeekTemplates(items: WeekTemplateItemDto[]): Promise<CreateBatchPayload>
|
||||||
|
```
|
||||||
|
|
||||||
|
### BookingService
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Create a booking (validates slot/membership, updates counts)
|
||||||
|
createBooking(userId: string, dto: CreateBookingDto): Promise<BookingWithRelations>
|
||||||
|
|
||||||
|
// Cancel a booking (conditionally refunds membership)
|
||||||
|
cancelBooking(userId: string, bookingId: string): Promise<CancelBookingResult>
|
||||||
|
|
||||||
|
// Get user's bookings (paginated, filterable by status)
|
||||||
|
getMyBookings(userId: string, status?, page, limit): Promise<PaginatedResult>
|
||||||
|
|
||||||
|
// Get all CONFIRMED bookings for dates >= today
|
||||||
|
getUpcomingBookings(userId: string): Promise<BookingWithRelations[]>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Member Endpoints
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /time-slot/available?date=2026-04-10
|
||||||
|
→ Returns slots for that date with user's booking status
|
||||||
|
|
||||||
|
GET /time-slot/:id
|
||||||
|
→ Returns full slot details with all bookings
|
||||||
|
|
||||||
|
POST /booking
|
||||||
|
Body: { "timeSlotId": "uuid", "membershipId": "uuid" }
|
||||||
|
→ Create a booking
|
||||||
|
|
||||||
|
PUT /booking/:id/cancel
|
||||||
|
→ Cancel a booking (refund if within window)
|
||||||
|
|
||||||
|
GET /booking/my?status=CONFIRMED&page=1&limit=10
|
||||||
|
→ Get user's bookings (paginated)
|
||||||
|
|
||||||
|
GET /booking/my/upcoming
|
||||||
|
→ Get all upcoming CONFIRMED bookings
|
||||||
|
```
|
||||||
|
|
||||||
|
### Admin Endpoints
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /admin/week-template
|
||||||
|
→ List all templates
|
||||||
|
|
||||||
|
PUT /admin/week-template
|
||||||
|
Body: { "templates": [ {...}, {...} ] }
|
||||||
|
→ Replace all templates (atomic)
|
||||||
|
|
||||||
|
POST /admin/time-slot/manual
|
||||||
|
Body: { "date", "startTime", "endTime", "capacity" }
|
||||||
|
→ Create a one-off slot
|
||||||
|
|
||||||
|
PUT /admin/time-slot/:id/close
|
||||||
|
→ Close a slot
|
||||||
|
|
||||||
|
POST /admin/generate-slots
|
||||||
|
→ Manually trigger slot generation
|
||||||
|
|
||||||
|
GET /admin/bookings?page=1&limit=10&status=CONFIRMED
|
||||||
|
→ View all bookings (admin)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Status Values
|
||||||
|
|
||||||
|
### TimeSlotStatus
|
||||||
|
- **OPEN**: Accepts bookings (bookedCount < capacity)
|
||||||
|
- **FULL**: At capacity (bookedCount >= capacity)
|
||||||
|
- **CLOSED**: Past date or manually closed
|
||||||
|
|
||||||
|
### BookingStatus
|
||||||
|
- **CONFIRMED**: Active reservation
|
||||||
|
- **CANCELLED**: User cancelled
|
||||||
|
- **COMPLETED**: Slot time has passed
|
||||||
|
- **NO_SHOW**: Marked manually
|
||||||
|
|
||||||
|
### MembershipStatus
|
||||||
|
- **ACTIVE**: Valid for booking
|
||||||
|
- **EXPIRED**: End date passed
|
||||||
|
- **USED_UP**: No remaining sessions (for TIMES/TRIAL)
|
||||||
|
|
||||||
|
### CardTypeCategory
|
||||||
|
- **TIMES**: N sessions (e.g., "5-pack")
|
||||||
|
- **DURATION**: Valid for X days (e.g., "1-month")
|
||||||
|
- **TRIAL**: Free trial sessions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Logic
|
||||||
|
|
||||||
|
### Booking Creation Transaction
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Validate TimeSlot exists and status = OPEN
|
||||||
|
2. Check user not already booked this slot
|
||||||
|
3. Validate Membership:
|
||||||
|
- Belongs to user
|
||||||
|
- Status = ACTIVE
|
||||||
|
- Has capacity:
|
||||||
|
* TIMES/TRIAL: remainingTimes > 0
|
||||||
|
* DURATION: expireDate > NOW
|
||||||
|
4. CREATE Booking(CONFIRMED)
|
||||||
|
5. UPDATE TimeSlot:
|
||||||
|
- bookedCount++
|
||||||
|
- IF bookedCount >= capacity THEN status = FULL
|
||||||
|
6. UPDATE Membership (if time-based):
|
||||||
|
- remainingTimes--
|
||||||
|
- IF remainingTimes = 0 THEN status = USED_UP
|
||||||
|
7. Return with relations
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cancellation Refund Logic
|
||||||
|
|
||||||
|
```
|
||||||
|
cancelHoursLimit = 2 (configurable in StudioConfig)
|
||||||
|
slotStartTime = TimeSlot.date + TimeSlot.startTime
|
||||||
|
deadline = NOW + (cancelHoursLimit * hours)
|
||||||
|
|
||||||
|
IF slotStartTime >= deadline:
|
||||||
|
Refund = TRUE
|
||||||
|
Increment membership.remainingTimes
|
||||||
|
ELSE:
|
||||||
|
Refund = FALSE
|
||||||
|
No membership change
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Weekday Mapping
|
||||||
|
|
||||||
|
**ISO Standard** (what WeekTemplate uses):
|
||||||
|
```
|
||||||
|
1 = Monday
|
||||||
|
2 = Tuesday
|
||||||
|
3 = Wednesday
|
||||||
|
4 = Thursday
|
||||||
|
5 = Friday
|
||||||
|
6 = Saturday
|
||||||
|
7 = Sunday
|
||||||
|
```
|
||||||
|
|
||||||
|
**JavaScript getDay()** (what Date does):
|
||||||
|
```
|
||||||
|
0 = Sunday
|
||||||
|
1 = Monday
|
||||||
|
2 = Tuesday
|
||||||
|
...
|
||||||
|
6 = Saturday
|
||||||
|
```
|
||||||
|
|
||||||
|
**Conversion function:**
|
||||||
|
```typescript
|
||||||
|
function toIsoWeekday(jsDay: number): number {
|
||||||
|
return jsDay === 0 ? 7 : jsDay
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Constraints
|
||||||
|
|
||||||
|
### TimeSlot
|
||||||
|
- Unique: `[date, startTime, endTime]` - prevents duplicate slots
|
||||||
|
- Index: `date` - for date range queries
|
||||||
|
- Index: `status` - for filtering
|
||||||
|
|
||||||
|
### Booking
|
||||||
|
- Unique: `[userId, timeSlotId]` - one booking per user per slot
|
||||||
|
- Index: `userId` - for user's bookings
|
||||||
|
- Index: `status` - for status filtering
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
```
|
||||||
|
DATABASE_URL=mysql://... (required)
|
||||||
|
```
|
||||||
|
|
||||||
|
### From StudioConfig Table
|
||||||
|
```
|
||||||
|
cancelHoursLimit = 2 (hours before slot to allow free cancellation)
|
||||||
|
```
|
||||||
|
|
||||||
|
### From Shared Constants
|
||||||
|
```
|
||||||
|
DEFAULT_SLOT_CAPACITY = 1
|
||||||
|
SLOT_GENERATION_DAYS = 14
|
||||||
|
DEFAULT_CANCEL_HOURS_LIMIT = 2
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Errors
|
||||||
|
|
||||||
|
| Error | Cause | Solution |
|
||||||
|
|-------|-------|----------|
|
||||||
|
| TimeSlot not found | Invalid slot ID | Check slot exists |
|
||||||
|
| TimeSlot is not available | Status ≠ OPEN | Slot is FULL or CLOSED |
|
||||||
|
| You have already booked this slot | Duplicate booking | Check user's bookings |
|
||||||
|
| This membership does not belong to you | Membership not user's | Verify membership |
|
||||||
|
| Membership is not active | Status ≠ ACTIVE | Renew or purchase membership |
|
||||||
|
| No remaining times on this membership | remainingTimes ≤ 0 | Purchase more sessions |
|
||||||
|
| Membership has expired | expireDate < NOW | Renew membership |
|
||||||
|
| Cannot cancel booking with status | Status ≠ CONFIRMED | Can only cancel CONFIRMED bookings |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Run tests with:
|
||||||
|
```bash
|
||||||
|
npm test -- slot-generator.service.spec.ts
|
||||||
|
npm test -- booking.service.spec.ts
|
||||||
|
npm test -- time-slot.service.spec.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Key test areas:
|
||||||
|
- Slot generation from templates
|
||||||
|
- Weekday mapping (JS vs ISO)
|
||||||
|
- Booking creation with all validations
|
||||||
|
- Cancellation with/without refund
|
||||||
|
- Membership expiration
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Performance Tips
|
||||||
|
|
||||||
|
1. **Avoid N+1 queries** - Always include relations in findMany
|
||||||
|
2. **Batch operations** - Use createMany/updateMany for large operations
|
||||||
|
3. **Transactions** - Wrap multi-step operations to prevent race conditions
|
||||||
|
4. **Indexes** - Queries filter by date and status (both indexed)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Development Workflow
|
||||||
|
|
||||||
|
1. **Setup templates** → `PUT /admin/week-template`
|
||||||
|
2. **Manually trigger generation** → `POST /admin/generate-slots`
|
||||||
|
3. **View available slots** → `GET /time-slot/available?date=...`
|
||||||
|
4. **Create booking** → `POST /booking`
|
||||||
|
5. **Cancel booking** → `PUT /booking/:id/cancel`
|
||||||
|
|
||||||
|
For testing without scheduler:
|
||||||
|
```typescript
|
||||||
|
// Inject SlotGeneratorService and call directly
|
||||||
|
const count = await slotGenerator.generateSlots(7)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Highlights
|
||||||
|
|
||||||
|
✅ **Idempotent** - Safe to re-run slot generation
|
||||||
|
✅ **Transactional** - Bookings are atomic
|
||||||
|
✅ **Automated** - 4 daily cron jobs maintain state
|
||||||
|
✅ **Flexible** - Supports multiple membership types
|
||||||
|
✅ **Scalable** - Batch operations, proper indexes
|
||||||
|
✅ **Validating** - DTO decorators + business logic checks
|
||||||
|
|
||||||
966
docs/TIME_SLOT_SCHEDULING_SYSTEM.md
Normal file
966
docs/TIME_SLOT_SCHEDULING_SYSTEM.md
Normal file
@@ -0,0 +1,966 @@
|
|||||||
|
# NestJS Time-Slot & Scheduling System Analysis
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
This is a comprehensive analysis of the pilates studio booking system's time-slot generation and scheduling backend. The system automatically generates time slots from reusable weekly templates, maintains their lifecycle, and integrates tightly with the booking and membership management systems.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Data Models (Prisma Schema)
|
||||||
|
|
||||||
|
### 1.1 WeekTemplate Model
|
||||||
|
**Location:** `packages/server/prisma/schema.prisma` (lines 113-126)
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
model WeekTemplate {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
dayOfWeek Int @map("day_of_week") // 1=Mon, 7=Sun (ISO standard)
|
||||||
|
startTime String @map("start_time") // e.g., "09:00"
|
||||||
|
endTime String @map("end_time") // e.g., "10:00"
|
||||||
|
capacity Int @default(1) // Max participants
|
||||||
|
isActive Boolean @default(true) // Enable/disable template
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
timeSlots TimeSlot[] // Generated slots from this template
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Purpose:**
|
||||||
|
- Defines recurring time slots by day of week and time
|
||||||
|
- Used as blueprint for automatic slot generation
|
||||||
|
- Capacity defines how many people can book each slot
|
||||||
|
|
||||||
|
**Key Constraints:**
|
||||||
|
- `dayOfWeek` uses **ISO 8601 standard** (1=Monday through 7=Sunday)
|
||||||
|
- NOT JavaScript getDay() (0=Sunday)
|
||||||
|
- Conversion happens in SlotGeneratorService.toIsoWeekday()
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.2 TimeSlot Model
|
||||||
|
**Location:** `packages/server/prisma/schema.prisma` (lines 128-148)
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
model TimeSlot {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
date DateTime @db.Date // Calendar date (midnight UTC)
|
||||||
|
startTime String @map("start_time") // "HH:mm" format
|
||||||
|
endTime String @map("end_time") // "HH:mm" format
|
||||||
|
capacity Int @default(1) // Max participants
|
||||||
|
bookedCount Int @default(0) // Current bookings
|
||||||
|
status TimeSlotStatus @default(OPEN) // OPEN | FULL | CLOSED
|
||||||
|
source TimeSlotSource @default(TEMPLATE) // TEMPLATE | MANUAL
|
||||||
|
templateId String? @map("template_id") // Reference to WeekTemplate
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
template WeekTemplate? @relation(fields: [templateId], references: [id])
|
||||||
|
bookings Booking[]
|
||||||
|
|
||||||
|
@@unique([date, startTime, endTime]) // Prevent duplicate slots
|
||||||
|
@@index([date])
|
||||||
|
@@index([status])
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Status Lifecycle:**
|
||||||
|
- **OPEN**: Accepts bookings, bookedCount < capacity
|
||||||
|
- **FULL**: No more bookings, bookedCount >= capacity
|
||||||
|
- **CLOSED**: Past date or manually closed, no bookings allowed
|
||||||
|
|
||||||
|
**Source Types:**
|
||||||
|
- **TEMPLATE**: Auto-generated from WeekTemplate
|
||||||
|
- **MANUAL**: Created directly by admin
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.3 Booking Model
|
||||||
|
**Location:** `packages/server/prisma/schema.prisma` (lines 150-168)
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
model Booking {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String @map("user_id")
|
||||||
|
timeSlotId String @map("time_slot_id")
|
||||||
|
membershipId String @map("membership_id")
|
||||||
|
status BookingStatus @default(CONFIRMED)
|
||||||
|
cancelledAt DateTime? @map("cancelled_at")
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
timeSlot TimeSlot @relation(fields: [timeSlotId], references: [id])
|
||||||
|
membership Membership @relation(fields: [membershipId], references: [id])
|
||||||
|
|
||||||
|
@@unique([userId, timeSlotId]) // One booking per user per slot
|
||||||
|
@@index([userId])
|
||||||
|
@@index([status])
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Booking Status Values:**
|
||||||
|
- **CONFIRMED**: Active reservation
|
||||||
|
- **CANCELLED**: User cancelled
|
||||||
|
- **COMPLETED**: Slot time has passed
|
||||||
|
- **NO_SHOW**: Marked manually if user didn't attend
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. SlotGeneratorService
|
||||||
|
|
||||||
|
**Location:** `packages/server/src/time-slot/slot-generator.service.ts`
|
||||||
|
|
||||||
|
### 2.1 Service Overview
|
||||||
|
|
||||||
|
Core service responsible for:
|
||||||
|
1. **Generating** time slots from WeekTemplate
|
||||||
|
2. **Cleaning up** expired slots
|
||||||
|
3. **Managing** membership expiration
|
||||||
|
4. **Marking** past bookings as completed
|
||||||
|
|
||||||
|
### 2.2 Key Methods
|
||||||
|
|
||||||
|
#### `generateSlots(daysAhead: number = 14): Promise<number>`
|
||||||
|
|
||||||
|
**Purpose:** Creates time slots for the next N days based on active WeekTemplates.
|
||||||
|
|
||||||
|
**Algorithm:**
|
||||||
|
```
|
||||||
|
1. Fetch all active WeekTemplates (isActive = true)
|
||||||
|
2. Calculate tomorrow at midnight UTC as start date
|
||||||
|
3. For each day in [tomorrow, tomorrow + daysAhead):
|
||||||
|
a. Get ISO weekday (1-7) from JavaScript date
|
||||||
|
b. Find matching templates for this weekday
|
||||||
|
c. For each matching template, create slot data:
|
||||||
|
- date: UTC midnight
|
||||||
|
- startTime/endTime: from template
|
||||||
|
- capacity: from template
|
||||||
|
- source: TimeSlotSource.TEMPLATE
|
||||||
|
- templateId: template.id
|
||||||
|
4. Batch create all slots using createMany() with skipDuplicates: true
|
||||||
|
5. Return count of newly created slots
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Features:**
|
||||||
|
- **Idempotent:** Re-running is safe; duplicate date+startTime+endTime combos are skipped
|
||||||
|
- **Timezone Aware:** Uses UTC midnight for dates
|
||||||
|
- **Weekday Mapping:** Converts JS getDay() → ISO weekday
|
||||||
|
- **Batch Insert:** Creates all slots in single database operation
|
||||||
|
|
||||||
|
**Example Execution:**
|
||||||
|
- Today: Monday, April 7, 2026
|
||||||
|
- Daylight: 14 days
|
||||||
|
- Template: Monday 09:00-10:00, Friday 18:00-19:00
|
||||||
|
- Result: 2 slots tomorrow (Monday), 0 Wed-Thu, 1 Friday, repeat pattern
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `cleanupExpiredSlots(): Promise<number>`
|
||||||
|
|
||||||
|
**Purpose:** Marks all OPEN slots with dates before today as CLOSED.
|
||||||
|
|
||||||
|
**Logic:**
|
||||||
|
```sql
|
||||||
|
UPDATE time_slots
|
||||||
|
SET status = 'CLOSED'
|
||||||
|
WHERE status = 'OPEN' AND date < TODAY_MIDNIGHT_UTC
|
||||||
|
```
|
||||||
|
|
||||||
|
**Returns:** Count of slots closed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `checkExpiredMemberships(): Promise<number>`
|
||||||
|
|
||||||
|
**Purpose:** Manages membership expiration in two ways:
|
||||||
|
|
||||||
|
1. **By Expiration Date:**
|
||||||
|
```
|
||||||
|
WHERE status = ACTIVE AND expireDate < NOW
|
||||||
|
SET status = EXPIRED
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **By Used-Up Sessions:**
|
||||||
|
```
|
||||||
|
WHERE status = ACTIVE AND remainingTimes = 0
|
||||||
|
SET status = USED_UP
|
||||||
|
```
|
||||||
|
|
||||||
|
**Returns:** Total count of memberships updated.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `completeBookings(): Promise<number>`
|
||||||
|
|
||||||
|
**Purpose:** Marks CONFIRMED bookings for past time slots as COMPLETED.
|
||||||
|
|
||||||
|
**Logic:**
|
||||||
|
```sql
|
||||||
|
UPDATE bookings
|
||||||
|
SET status = 'COMPLETED'
|
||||||
|
WHERE status = 'CONFIRMED'
|
||||||
|
AND timeSlot.date < TODAY_MIDNIGHT_UTC
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. TimeSlotService
|
||||||
|
|
||||||
|
**Location:** `packages/server/src/time-slot/time-slot.service.ts`
|
||||||
|
|
||||||
|
### 3.1 Service Overview
|
||||||
|
|
||||||
|
Handles time slot queries and management for both members and admins.
|
||||||
|
|
||||||
|
### 3.2 Key Methods
|
||||||
|
|
||||||
|
#### `getAvailableSlots(date: string, userId?: string): Promise<TimeSlotWithBookingStatus[]>`
|
||||||
|
|
||||||
|
**Purpose:** Retrieve all non-closed slots for a specific date, optionally including user's booking status.
|
||||||
|
|
||||||
|
**Query Logic:**
|
||||||
|
```
|
||||||
|
1. Parse date string to Date object
|
||||||
|
2. Find all slots for that calendar day:
|
||||||
|
- WHERE status != CLOSED
|
||||||
|
- ORDER BY startTime ASC
|
||||||
|
3. If userId provided:
|
||||||
|
- Include bookings where userId=X AND status=CONFIRMED
|
||||||
|
- Map to "isBookedByMe" and "myBookingId" fields
|
||||||
|
4. Return TimeSlotWithBookingStatus[]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response Type:**
|
||||||
|
```typescript
|
||||||
|
interface TimeSlotWithBookingStatus {
|
||||||
|
id: string
|
||||||
|
date: string // ISO date "YYYY-MM-DD"
|
||||||
|
startTime: string // "HH:mm"
|
||||||
|
endTime: string
|
||||||
|
capacity: number
|
||||||
|
bookedCount: number
|
||||||
|
status: TimeSlotStatus // OPEN | FULL | CLOSED
|
||||||
|
source: TimeSlotSource // TEMPLATE | MANUAL
|
||||||
|
templateId: string | null
|
||||||
|
createdAt: string // ISO datetime
|
||||||
|
updatedAt: string
|
||||||
|
isBookedByMe: boolean // Current user's booking?
|
||||||
|
myBookingId: string | null // For cancellation
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `getSlotById(id: string): Promise<TimeSlot>`
|
||||||
|
|
||||||
|
Returns full slot details including all bookings. Throws NotFoundException if not found.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `createManualSlot(dto: CreateManualSlotDto): Promise<TimeSlot>`
|
||||||
|
|
||||||
|
**Purpose:** Allow admins to create one-off time slots outside templates.
|
||||||
|
|
||||||
|
**DTO:**
|
||||||
|
```typescript
|
||||||
|
class CreateManualSlotDto {
|
||||||
|
date: string // "YYYY-MM-DD"
|
||||||
|
startTime: string // "HH:mm"
|
||||||
|
endTime: string // "HH:mm"
|
||||||
|
capacity?: number // Defaults to DEFAULT_SLOT_CAPACITY (1)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Creates slot with:**
|
||||||
|
- `source: TimeSlotSource.MANUAL`
|
||||||
|
- `templateId: null`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `closeSlot(id: string): Promise<TimeSlot>`
|
||||||
|
|
||||||
|
Sets slot status to CLOSED. Prevents new bookings but keeps existing ones.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `getWeekTemplates(): Promise<WeekTemplate[]>`
|
||||||
|
|
||||||
|
Lists all templates ordered by dayOfWeek and startTime.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `replaceWeekTemplates(items: WeekTemplateItemDto[]): Promise<CreateBatchPayload>`
|
||||||
|
|
||||||
|
**Purpose:** Atomic replacement of all templates (used during admin config).
|
||||||
|
|
||||||
|
**Transaction:**
|
||||||
|
```
|
||||||
|
1. DELETE FROM week_templates (all rows)
|
||||||
|
2. CREATE week_templates with new items
|
||||||
|
3. Return count
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. TimeSlotController & AdminTimeSlotController
|
||||||
|
|
||||||
|
**Location:** `packages/server/src/time-slot/time-slot.controller.ts`
|
||||||
|
|
||||||
|
### 4.1 Member Endpoints
|
||||||
|
|
||||||
|
#### `GET /time-slot/available?date=YYYY-MM-DD`
|
||||||
|
- Returns available slots for the date
|
||||||
|
- Includes current user's booking status
|
||||||
|
- Requires JWT authentication
|
||||||
|
|
||||||
|
#### `GET /time-slot/:id`
|
||||||
|
- Returns full slot details with all bookings
|
||||||
|
- Requires JWT authentication
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.2 Admin Endpoints
|
||||||
|
|
||||||
|
All require `@Roles(UserRole.ADMIN)` and JWT auth.
|
||||||
|
|
||||||
|
#### `GET /admin/week-template`
|
||||||
|
Lists all WeekTemplate entries.
|
||||||
|
|
||||||
|
#### `PUT /admin/week-template`
|
||||||
|
Replaces all templates. Request body:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"templates": [
|
||||||
|
{
|
||||||
|
"dayOfWeek": 1,
|
||||||
|
"startTime": "09:00",
|
||||||
|
"endTime": "10:00",
|
||||||
|
"capacity": 1,
|
||||||
|
"isActive": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `POST /admin/time-slot/manual`
|
||||||
|
Creates a manual slot. Request body:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"date": "2026-04-10",
|
||||||
|
"startTime": "14:00",
|
||||||
|
"endTime": "15:00",
|
||||||
|
"capacity": 2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `PUT /admin/time-slot/:id/close`
|
||||||
|
Closes a specific slot.
|
||||||
|
|
||||||
|
#### `POST /admin/generate-slots`
|
||||||
|
Manually trigger slot generation (default 14 days ahead).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. SchedulerService - Automated Jobs
|
||||||
|
|
||||||
|
**Location:** `packages/server/src/scheduler/scheduler.service.ts`
|
||||||
|
|
||||||
|
### 5.1 Overview
|
||||||
|
|
||||||
|
Uses `@nestjs/schedule` to run daily maintenance tasks. All times in UTC.
|
||||||
|
|
||||||
|
### 5.2 Cron Jobs
|
||||||
|
|
||||||
|
#### Job 1: Slot Generation
|
||||||
|
```
|
||||||
|
@Cron('0 2 * * *') // 02:00 UTC daily
|
||||||
|
async handleSlotGeneration()
|
||||||
|
```
|
||||||
|
- Calls: `slotGenerator.generateSlots(14)`
|
||||||
|
- Generates slots 14 days ahead
|
||||||
|
- Purpose: Keep pipeline filled
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Job 2: Slot Cleanup
|
||||||
|
```
|
||||||
|
@Cron('30 2 * * *') // 02:30 UTC daily
|
||||||
|
async handleCleanupSlots()
|
||||||
|
```
|
||||||
|
- Calls: `slotGenerator.cleanupExpiredSlots()`
|
||||||
|
- Marks past OPEN slots as CLOSED
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Job 3: Membership Check
|
||||||
|
```
|
||||||
|
@Cron('0 3 * * *') // 03:00 UTC daily
|
||||||
|
async handleCheckMemberships()
|
||||||
|
```
|
||||||
|
- Calls: `slotGenerator.checkExpiredMemberships()`
|
||||||
|
- Expires memberships by date or used-up sessions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Job 4: Booking Completion
|
||||||
|
```
|
||||||
|
@Cron('0 22 * * *') // 22:00 UTC daily
|
||||||
|
async handleCompleteBookings()
|
||||||
|
```
|
||||||
|
- Calls: `slotGenerator.completeBookings()`
|
||||||
|
- Marks past CONFIRMED bookings as COMPLETED
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. BookingService - Integration with TimeSlots
|
||||||
|
|
||||||
|
**Location:** `packages/server/src/booking/booking.service.ts`
|
||||||
|
|
||||||
|
### 6.1 Key Integration Points
|
||||||
|
|
||||||
|
#### `createBooking(userId: string, dto: CreateBookingDto): Promise<BookingWithRelations>`
|
||||||
|
|
||||||
|
**DTO:**
|
||||||
|
```typescript
|
||||||
|
class CreateBookingDto {
|
||||||
|
timeSlotId: string // UUID of TimeSlot
|
||||||
|
membershipId: string // UUID of Membership
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Transaction Flow:**
|
||||||
|
```
|
||||||
|
1. Fetch TimeSlot - validate status = OPEN
|
||||||
|
2. Check unique constraint - user not already booked this slot
|
||||||
|
3. Fetch Membership - validate:
|
||||||
|
- Belongs to user
|
||||||
|
- Status = ACTIVE
|
||||||
|
- Has remaining capacity:
|
||||||
|
* TIMES/TRIAL: remainingTimes > 0
|
||||||
|
* DURATION: not expired
|
||||||
|
4. Create Booking(userId, timeSlotId, membershipId) → CONFIRMED
|
||||||
|
5. Update TimeSlot:
|
||||||
|
- bookedCount++
|
||||||
|
- If bookedCount >= capacity, set status = FULL
|
||||||
|
6. Update Membership (if time-based):
|
||||||
|
- remainingTimes--
|
||||||
|
- If remainingTimes = 0, set status = USED_UP
|
||||||
|
7. Return booking with relations
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error Handling:**
|
||||||
|
- TimeSlot not OPEN → BadRequestException
|
||||||
|
- Duplicate booking → ConflictException
|
||||||
|
- Invalid membership → ForbiddenException
|
||||||
|
- No remaining sessions → BadRequestException
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `cancelBooking(userId: string, bookingId: string): Promise<CancelBookingResult>`
|
||||||
|
|
||||||
|
**Refund Logic:**
|
||||||
|
```
|
||||||
|
cancelHoursLimit = StudioConfig.cancelHoursLimit (default 2 hours)
|
||||||
|
slotStartMs = Date(date).setUTC Hours + startTime
|
||||||
|
deadlineMs = NOW + (cancelHoursLimit * 3600 * 1000)
|
||||||
|
withinLimit = slotStartMs >= deadlineMs
|
||||||
|
|
||||||
|
IF withinLimit:
|
||||||
|
Restore membership.remainingTimes++
|
||||||
|
ELSE:
|
||||||
|
No refund
|
||||||
|
```
|
||||||
|
|
||||||
|
**Transaction Flow:**
|
||||||
|
```
|
||||||
|
1. Mark Booking → CANCELLED, set cancelledAt
|
||||||
|
2. Decrement TimeSlot.bookedCount
|
||||||
|
3. If slot was FULL, restore to OPEN
|
||||||
|
4. If within cancel window:
|
||||||
|
- For TIMES/TRIAL: increment remainingTimes
|
||||||
|
- Restore membership status if was USED_UP
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `getMyBookings(userId: string, status?, page, limit): Promise<PaginatedResult>`
|
||||||
|
|
||||||
|
Lists user's bookings with pagination, optionally filtered by status.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `getUpcomingBookings(userId: string): Promise<BookingWithRelations[]>`
|
||||||
|
|
||||||
|
Returns all CONFIRMED bookings for dates >= today, ordered by date.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Data Flow Diagrams
|
||||||
|
|
||||||
|
### 7.1 Slot Generation Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Daily 02:00 UTC
|
||||||
|
↓
|
||||||
|
SchedulerService.handleSlotGeneration()
|
||||||
|
↓
|
||||||
|
SlotGeneratorService.generateSlots(14)
|
||||||
|
↓
|
||||||
|
1. Query WeekTemplate (isActive=true)
|
||||||
|
2. For next 14 days:
|
||||||
|
- Match templates by ISO weekday
|
||||||
|
- Create TimeSlot entries
|
||||||
|
3. Use createMany(skipDuplicates: true)
|
||||||
|
↓
|
||||||
|
Database: Insert new TimeSlot records
|
||||||
|
↓
|
||||||
|
Return: count of new slots
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7.2 Booking Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
User Action
|
||||||
|
↓
|
||||||
|
POST /booking
|
||||||
|
timeSlotId: UUID
|
||||||
|
membershipId: UUID
|
||||||
|
↓
|
||||||
|
BookingService.createBooking()
|
||||||
|
↓
|
||||||
|
START TRANSACTION
|
||||||
|
├─ Validate TimeSlot (status=OPEN)
|
||||||
|
├─ Check unique(userId, timeSlotId)
|
||||||
|
├─ Validate Membership (ACTIVE, not expired)
|
||||||
|
├─ CREATE Booking(CONFIRMED)
|
||||||
|
├─ UPDATE TimeSlot(bookedCount++, status=?)
|
||||||
|
└─ UPDATE Membership(remainingTimes--)
|
||||||
|
COMMIT
|
||||||
|
↓
|
||||||
|
Return: BookingWithRelations
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7.3 Cancellation Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
User Action
|
||||||
|
↓
|
||||||
|
PUT /booking/:id/cancel
|
||||||
|
↓
|
||||||
|
BookingService.cancelBooking()
|
||||||
|
↓
|
||||||
|
Check: Now vs Slot Time + cancelHoursLimit
|
||||||
|
↓
|
||||||
|
START TRANSACTION
|
||||||
|
├─ UPDATE Booking(CANCELLED, cancelledAt=NOW)
|
||||||
|
├─ UPDATE TimeSlot(bookedCount--, status=?)
|
||||||
|
└─ IF within cancel window:
|
||||||
|
└─ UPDATE Membership(remainingTimes++)
|
||||||
|
COMMIT
|
||||||
|
↓
|
||||||
|
Return: { booking, refunded: boolean }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. DTOs & Request/Response
|
||||||
|
|
||||||
|
### 8.1 Time Slot DTOs
|
||||||
|
|
||||||
|
**Location:** `packages/server/src/time-slot/dto/`
|
||||||
|
|
||||||
|
#### `QuerySlotsDto`
|
||||||
|
```typescript
|
||||||
|
class QuerySlotsDto {
|
||||||
|
@IsDateString()
|
||||||
|
date!: string // Format: YYYY-MM-DD
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `CreateManualSlotDto`
|
||||||
|
```typescript
|
||||||
|
class CreateManualSlotDto {
|
||||||
|
@IsDateString()
|
||||||
|
date!: string
|
||||||
|
@IsString()
|
||||||
|
startTime!: string
|
||||||
|
@IsString()
|
||||||
|
endTime!: string
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
capacity?: number
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `WeekTemplateItemDto` & `UpdateWeekTemplateDto`
|
||||||
|
```typescript
|
||||||
|
class WeekTemplateItemDto {
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(7)
|
||||||
|
dayOfWeek!: number // ISO: 1=Mon, 7=Sun
|
||||||
|
@IsString()
|
||||||
|
startTime!: string
|
||||||
|
@IsString()
|
||||||
|
endTime!: string
|
||||||
|
@IsOptional()
|
||||||
|
capacity?: number
|
||||||
|
@IsOptional()
|
||||||
|
isActive?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
class UpdateWeekTemplateDto {
|
||||||
|
@ArrayNotEmpty()
|
||||||
|
templates!: WeekTemplateItemDto[]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Shared Constants & Enums
|
||||||
|
|
||||||
|
**Location:** `packages/shared/src/`
|
||||||
|
|
||||||
|
### 9.1 Constants
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// constants.ts
|
||||||
|
export const DEFAULT_CANCEL_HOURS_LIMIT = 2
|
||||||
|
export const DEFAULT_SLOT_CAPACITY = 1
|
||||||
|
export const SLOT_GENERATION_DAYS = 14
|
||||||
|
export const TIME_PERIODS = {
|
||||||
|
MORNING: { label: '上午', start: '06:00', end: '12:00' },
|
||||||
|
AFTERNOON: { label: '下午', start: '12:00', end: '18:00' },
|
||||||
|
EVENING: { label: '晚上', start: '18:00', end: '22:00' },
|
||||||
|
}
|
||||||
|
export const DATE_SELECTOR_DAYS = 7
|
||||||
|
export const WEEKDAY_LABELS = ['', '周一', '周二', '周三', '周四', '周五', '周六', '周日']
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.2 Enums
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// enums.ts
|
||||||
|
enum TimeSlotStatus {
|
||||||
|
OPEN = 'OPEN',
|
||||||
|
FULL = 'FULL',
|
||||||
|
CLOSED = 'CLOSED',
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TimeSlotSource {
|
||||||
|
TEMPLATE = 'TEMPLATE',
|
||||||
|
MANUAL = 'MANUAL',
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BookingStatus {
|
||||||
|
CONFIRMED = 'CONFIRMED',
|
||||||
|
CANCELLED = 'CANCELLED',
|
||||||
|
COMPLETED = 'COMPLETED',
|
||||||
|
NO_SHOW = 'NO_SHOW',
|
||||||
|
}
|
||||||
|
|
||||||
|
enum MembershipStatus {
|
||||||
|
ACTIVE = 'ACTIVE',
|
||||||
|
EXPIRED = 'EXPIRED',
|
||||||
|
USED_UP = 'USED_UP',
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. File Structure Summary
|
||||||
|
|
||||||
|
```
|
||||||
|
packages/server/src/
|
||||||
|
├── time-slot/
|
||||||
|
│ ├── __tests__/
|
||||||
|
│ │ ├── slot-generator.service.spec.ts (170 lines, comprehensive tests)
|
||||||
|
│ │ └── time-slot.service.spec.ts
|
||||||
|
│ ├── dto/
|
||||||
|
│ │ ├── query-slots.dto.ts
|
||||||
|
│ │ ├── create-manual-slot.dto.ts
|
||||||
|
│ │ └── week-template.dto.ts
|
||||||
|
│ ├── slot-generator.service.ts (172 lines, 4 key methods)
|
||||||
|
│ ├── time-slot.service.ts (142 lines)
|
||||||
|
│ ├── time-slot.controller.ts (93 lines, 2 controllers)
|
||||||
|
│ └── time-slot.module.ts
|
||||||
|
│
|
||||||
|
├── scheduler/
|
||||||
|
│ ├── __tests__/
|
||||||
|
│ │ └── scheduler.service.spec.ts
|
||||||
|
│ ├── scheduler.service.ts (55 lines, 4 cron jobs)
|
||||||
|
│ └── scheduler.module.ts
|
||||||
|
│
|
||||||
|
├── booking/
|
||||||
|
│ ├── __tests__/
|
||||||
|
│ │ └── booking.service.spec.ts
|
||||||
|
│ ├── dto/
|
||||||
|
│ │ └── create-booking.dto.ts
|
||||||
|
│ ├── booking.service.ts (367 lines)
|
||||||
|
│ ├── booking.controller.ts (82 lines)
|
||||||
|
│ └── booking.module.ts
|
||||||
|
│
|
||||||
|
├── prisma/
|
||||||
|
│ └── schema.prisma (205 lines, includes models)
|
||||||
|
│
|
||||||
|
└── app.module.ts (imports TimeSlotModule, SchedulerModule)
|
||||||
|
|
||||||
|
packages/shared/src/
|
||||||
|
├── types/
|
||||||
|
│ ├── time-slot.ts
|
||||||
|
│ └── (others)
|
||||||
|
├── constants.ts (22 lines)
|
||||||
|
├── enums.ts (47 lines)
|
||||||
|
└── index.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Key Architectural Patterns
|
||||||
|
|
||||||
|
### 11.1 Idempotent Slot Generation
|
||||||
|
|
||||||
|
**Problem:** If scheduler crashes or delays, slots might not be generated.
|
||||||
|
**Solution:**
|
||||||
|
- Use `createMany(skipDuplicates: true)` with unique constraint on `[date, startTime, endTime]`
|
||||||
|
- Safe to re-run multiple times
|
||||||
|
- Only inserts new combinations
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 11.2 Atomic Transactions
|
||||||
|
|
||||||
|
**For Booking Creation:**
|
||||||
|
- Create booking, update slot, update membership in single transaction
|
||||||
|
- All-or-nothing: ensures consistency if any step fails
|
||||||
|
|
||||||
|
**For Cancellation:**
|
||||||
|
- Cancel booking, restore slot, conditionally restore membership
|
||||||
|
- Prevents race conditions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 11.3 ISO Weekday Mapping
|
||||||
|
|
||||||
|
**Problem:** JavaScript `Date.getDay()` uses 0=Sunday, but WeekTemplate uses ISO 8601 (1=Monday).
|
||||||
|
|
||||||
|
**Solution:** Helper function `toIsoWeekday()`:
|
||||||
|
```typescript
|
||||||
|
function toIsoWeekday(jsDay: number): number {
|
||||||
|
return jsDay === 0 ? 7 : jsDay
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 11.4 Membership Type Handling
|
||||||
|
|
||||||
|
**TIMES/TRIAL cardType:**
|
||||||
|
- Deduct `remainingTimes--` on booking
|
||||||
|
- Mark USED_UP when remainingTimes = 0
|
||||||
|
- Refund if cancelled within window
|
||||||
|
|
||||||
|
**DURATION cardType:**
|
||||||
|
- Check `expireDate` not passed
|
||||||
|
- No deduction; just check validity
|
||||||
|
- No refund on cancellation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Example Scenarios
|
||||||
|
|
||||||
|
### Scenario 1: Setup Studio with Mon-Fri Classes
|
||||||
|
|
||||||
|
**Admin Actions:**
|
||||||
|
```json
|
||||||
|
PUT /admin/week-template
|
||||||
|
{
|
||||||
|
"templates": [
|
||||||
|
{ "dayOfWeek": 1, "startTime": "09:00", "endTime": "10:00", "capacity": 1 },
|
||||||
|
{ "dayOfWeek": 1, "startTime": "10:30", "endTime": "11:30", "capacity": 1 },
|
||||||
|
{ "dayOfWeek": 2, "startTime": "09:00", "endTime": "10:00", "capacity": 1 },
|
||||||
|
{ "dayOfWeek": 3, "startTime": "09:00", "endTime": "10:00", "capacity": 1 },
|
||||||
|
{ "dayOfWeek": 4, "startTime": "09:00", "endTime": "10:00", "capacity": 1 },
|
||||||
|
{ "dayOfWeek": 5, "startTime": "09:00", "endTime": "10:00", "capacity": 1 },
|
||||||
|
{ "dayOfWeek": 5, "startTime": "18:00", "endTime": "19:00", "capacity": 1 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Next Day (02:00 UTC):**
|
||||||
|
- Scheduler auto-generates 14 days of slots
|
||||||
|
- Result: 14 Mon morning + 14 Mon mid-morning + 14 Tue morning + ... + 14 Fri evening
|
||||||
|
|
||||||
|
**Member Action (View Availability):**
|
||||||
|
```
|
||||||
|
GET /time-slot/available?date=2026-04-10
|
||||||
|
→ Returns all slots for April 10 (Friday)
|
||||||
|
→ Includes bookings for current user
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Scenario 2: Member Books, Then Cancels
|
||||||
|
|
||||||
|
**Member Books:**
|
||||||
|
```
|
||||||
|
POST /booking
|
||||||
|
{
|
||||||
|
"timeSlotId": "slot-123",
|
||||||
|
"membershipId": "mem-456"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**System:**
|
||||||
|
1. Validates slot is OPEN, membership is ACTIVE with remaining sessions
|
||||||
|
2. Creates Booking(CONFIRMED)
|
||||||
|
3. Increments slot.bookedCount (1 → 2)
|
||||||
|
4. If now at capacity, sets slot.status = FULL
|
||||||
|
5. Decrements membership.remainingTimes (5 → 4)
|
||||||
|
|
||||||
|
**Member Cancels (within 2-hour window):**
|
||||||
|
```
|
||||||
|
PUT /booking/booking-789/cancel
|
||||||
|
```
|
||||||
|
|
||||||
|
**System:**
|
||||||
|
1. Checks if NOW + 2 hours ≤ slot start time ✓
|
||||||
|
2. Sets booking.status = CANCELLED
|
||||||
|
3. Decrements slot.bookedCount (2 → 1)
|
||||||
|
4. If slot was FULL, restores to OPEN
|
||||||
|
5. Increments membership.remainingTimes (4 → 5) ✓ refunded
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Scenario 3: Membership Expires
|
||||||
|
|
||||||
|
**Overnight at 03:00 UTC:**
|
||||||
|
- Scheduler runs `handleCheckMemberships()`
|
||||||
|
- Updates all ACTIVE memberships where `expireDate < NOW` to EXPIRED
|
||||||
|
- User tries to book → BadRequestException "Membership is not active (status: EXPIRED)"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Testing Guide
|
||||||
|
|
||||||
|
### Key Test Files
|
||||||
|
|
||||||
|
1. **`slot-generator.service.spec.ts`** (310 lines)
|
||||||
|
- Tests slot generation from templates
|
||||||
|
- Tests weekday mapping (JS vs ISO)
|
||||||
|
- Tests cleanup and expiration logic
|
||||||
|
- Tests membership and booking expiration
|
||||||
|
|
||||||
|
2. **`time-slot.service.spec.ts`** (existing)
|
||||||
|
- Tests getAvailableSlots with user booking status
|
||||||
|
- Tests manual slot creation
|
||||||
|
|
||||||
|
3. **`booking.service.spec.ts`** (existing)
|
||||||
|
- Tests booking creation with all validations
|
||||||
|
- Tests cancellation with refund logic
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Configuration & Environment
|
||||||
|
|
||||||
|
### Required Env Variables
|
||||||
|
```
|
||||||
|
DATABASE_URL=mysql://...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Studio Config (StudioConfig table)
|
||||||
|
- `cancelHoursLimit`: Hours before slot to allow free cancellation (default 2)
|
||||||
|
|
||||||
|
### Constants (shared package)
|
||||||
|
- `SLOT_GENERATION_DAYS`: 14 (days ahead to generate)
|
||||||
|
- `DEFAULT_SLOT_CAPACITY`: 1 (private lessons)
|
||||||
|
- `DEFAULT_CANCEL_HOURS_LIMIT`: 2
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Performance Considerations
|
||||||
|
|
||||||
|
### Database Indexes
|
||||||
|
- `TimeSlot(date)` - for date range queries
|
||||||
|
- `TimeSlot(status)` - for status filtering
|
||||||
|
- `Booking(userId)` - for user's bookings
|
||||||
|
- `Booking(status)` - for status filtering
|
||||||
|
|
||||||
|
### Batch Operations
|
||||||
|
- Slot generation uses `createMany()` for efficiency
|
||||||
|
- Expiration checks use `updateMany()` instead of loops
|
||||||
|
|
||||||
|
### Transaction Isolation
|
||||||
|
- All booking/cancellation operations wrapped in transactions
|
||||||
|
- Prevents race conditions on bookedCount and remainingTimes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. Security Notes
|
||||||
|
|
||||||
|
### Authorization
|
||||||
|
- JWT guard on all endpoints
|
||||||
|
- RolesGuard for admin endpoints (only ADMIN role)
|
||||||
|
- Users can only modify their own bookings/memberships
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
- All DTOs have class-validator decorators
|
||||||
|
- UUID validation on foreign keys
|
||||||
|
- Date string validation (YYYY-MM-DD format)
|
||||||
|
|
||||||
|
### Data Integrity
|
||||||
|
- Unique constraint on `[userId, timeSlotId]` prevents duplicate bookings
|
||||||
|
- Unique constraint on `[date, startTime, endTime]` prevents duplicate slots
|
||||||
|
- Foreign key constraints on relations
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. Future Enhancement Ideas
|
||||||
|
|
||||||
|
1. **Overbooking Buffer:**
|
||||||
|
- Allow configurable overbooking ratio (e.g., 110% capacity)
|
||||||
|
|
||||||
|
2. **Waitlist Support:**
|
||||||
|
- Add BookingStatus.WAITLISTED
|
||||||
|
- Auto-promote when slot opens
|
||||||
|
|
||||||
|
3. **Recurring Cancellation:**
|
||||||
|
- Cancel all future bookings of a series
|
||||||
|
- Batch refunds
|
||||||
|
|
||||||
|
4. **Slot Availability Notifications:**
|
||||||
|
- Alert users when slots available
|
||||||
|
- Implement notification queue
|
||||||
|
|
||||||
|
5. **Dynamic Pricing:**
|
||||||
|
- Peak vs off-peak pricing
|
||||||
|
- Last-minute discounts
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
This time-slot and scheduling system is well-architected with:
|
||||||
|
|
||||||
|
✅ **Idempotent slot generation** - Safe to re-run
|
||||||
|
✅ **Atomic transactions** - ACID compliance for bookings
|
||||||
|
✅ **Automatic maintenance** - 4 daily cron jobs
|
||||||
|
✅ **Flexible membership types** - TIMES, DURATION, TRIAL
|
||||||
|
✅ **Refund policy** - Configurable cancellation window
|
||||||
|
✅ **ISO weekday standard** - Proper international support
|
||||||
|
✅ **Comprehensive validation** - DTOs with decorators
|
||||||
|
✅ **Role-based access** - Admin vs member endpoints
|
||||||
|
|
||||||
|
The system handles:
|
||||||
|
- Auto-generating 14 days of slots nightly
|
||||||
|
- Accepting bookings with capacity management
|
||||||
|
- Canceling with conditional refunds
|
||||||
|
- Expiring memberships and marking past bookings
|
||||||
|
- All with transactional integrity and concurrent safety.
|
||||||
|
|
||||||
29
docs/lesson-supplement.md
Normal file
29
docs/lesson-supplement.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# 历史课程补录
|
||||||
|
|
||||||
|
入口:会员档案 → 上课情况 → 补录上课。一次填写 1–999 节,可备注,默认仅补记录,也可选择本人有限次会员卡扣次(含已过期卡)。不限次卡无需扣次;余额不足阻止提交。
|
||||||
|
|
||||||
|
一批补录展示为「系统补录 · N 节」,不是 N 个虚拟预约。累计已上课次数(个人中心、会员档案、会员列表已完成)增加 N;累计预约、月度节数/天数/时长、最近 30 天网格、邀请奖励保持正常预约口径,因为补录没有真实上课日期。记录时间明确为补录时间。
|
||||||
|
|
||||||
|
老师与管理员共用 ADMIN 权限;成员只能读取自己的生效记录。事务保存记录与扣次快照,请求标识唯一,网络重试不重复扣次。撤销软删除,保留操作人与时间,只返还当时实际扣次,过期卡不重新激活。若原卡已转为不限次,撤销阻止并要求先核对卡状态。
|
||||||
|
|
||||||
|
## 迁移与发布
|
||||||
|
|
||||||
|
新增 `lesson_supplements` 表及关联索引,不修改既有预约数据。按仓库发布流程先执行 `prisma migrate deploy` 并重新生成 Prisma Client、发布后端,再发布小程序。开发检查不自动触及生产数据库。
|
||||||
|
|
||||||
|
回退:先回退应用代码,保留新增表即可,旧代码不读取该表。若一定要移除表,须先导出审计数据并核对已扣会员卡余额;仅在未写入任何补录的环境可执行 `DROP TABLE lesson_supplements;`。MySQL DDL 不能靠事务回滚,不在生产自动执行删除。
|
||||||
|
|
||||||
|
## 本次验证
|
||||||
|
|
||||||
|
- 补录与用户统计相关 62 项测试通过,覆盖权限、数量校验、仅补记录、扣次、余额不足、过期卡、重复提交、并发冲突、撤销和统计口径。
|
||||||
|
- 共享包与后端构建、小程序构建、vue-tsc、Prisma schema 校验通过。
|
||||||
|
- 使用真实 Vue 页面与示例接口检查 375/320 宽度布局、十节补录、撤销、余额不足及学员历史记录;不替代微信真机与真实数据库联调。
|
||||||
|
- 全量测试存在原有 SchedulerService 测试依赖缺失:未提供 FlashSaleService,8 项失败;其余已执行用例通过。
|
||||||
|
- 2026-09-08 已经用户授权执行生产迁移及后端发布;小程序仍需单独发布。
|
||||||
|
|
||||||
|
## 生产发布记录(2026-09-08)
|
||||||
|
|
||||||
|
- 执行 `pnpm deploy:server`,迁移 `20260908090000_add_lesson_supplements` 成功,全部 4 个迁移处于最新状态。
|
||||||
|
- 发布前数据库及后端备份保存在服务器 `/usr/local/web/mp-pilates-backups/20260908-145852/`。
|
||||||
|
- 新表读取成功,验收时为 0 条记录;未创建测试补录或修改学员余额。
|
||||||
|
- 内网及公网 `https://focus.richarjiang.com/api/health` 均返回 `success: true`、`status: ok`;PM2 `mp-pilates-server` 为 online,重启计数为 0。
|
||||||
|
- 部署脚本增加远端 `set -e`,使依赖安装或迁移失败时停止发布。
|
||||||
946
docs/superpowers/plans/2026-04-07-member-card-edit.md
Normal file
946
docs/superpowers/plans/2026-04-07-member-card-edit.md
Normal file
@@ -0,0 +1,946 @@
|
|||||||
|
# 会员卡编辑功能实现计划
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** 在管理后台会员列表中,点击会员弹出详情/编辑弹窗,支持编辑卡种、有效期、次数,以及清空会员卡
|
||||||
|
|
||||||
|
**Architecture:** 后端在 `UserController` 新增三个 admin 接口(GET/PUT/DELETE `/admin/members/:userId/membership`),前端将现有详情弹窗改造为 Tab 模式,store 新增三个 action
|
||||||
|
|
||||||
|
**Tech Stack:** NestJS (后端) + Vue 3 + uni-app + Pinia (前端)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 文件结构
|
||||||
|
|
||||||
|
```
|
||||||
|
Backend:
|
||||||
|
- packages/server/src/user/user.controller.ts (新增3个接口)
|
||||||
|
- packages/server/src/user/user.service.ts (新增3个 service 方法)
|
||||||
|
- packages/server/src/user/dto/update-user-membership.dto.ts (新建)
|
||||||
|
|
||||||
|
Frontend:
|
||||||
|
- packages/app/src/stores/admin.ts (新增3个 action)
|
||||||
|
- packages/app/src/pages/admin/members.vue (改造详情弹窗为 Tab 模式)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: 后端接口
|
||||||
|
|
||||||
|
### Task 1: 创建 UpdateUserMembershipDto
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `packages/server/src/user/dto/update-user-membership.dto.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { IsDateString, IsInt, IsOptional, IsUUID, Min } from 'class-validator'
|
||||||
|
|
||||||
|
export class UpdateUserMembershipDto {
|
||||||
|
@IsUUID()
|
||||||
|
cardTypeId: string
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
remainingTimes?: number | null
|
||||||
|
|
||||||
|
@IsDateString()
|
||||||
|
startDate: string
|
||||||
|
|
||||||
|
@IsDateString()
|
||||||
|
expireDate: string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 1: 创建目录并写入文件**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p /Users/richard/Documents/code/pilates/mp-pilates/packages/server/src/user/dto
|
||||||
|
```
|
||||||
|
|
||||||
|
写入 `packages/server/src/user/dto/update-user-membership.dto.ts`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: UserService 新增会员卡管理方法
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `packages/server/src/user/user.service.ts` — 在文件末尾添加 3 个新方法
|
||||||
|
|
||||||
|
需要添加的方法:
|
||||||
|
1. `getUserMembership(userId: string)` — 返回 `Membership & { cardType: CardType } | null`
|
||||||
|
2. `updateUserMembership(userId: string, dto: UpdateUserMembershipDto)` — 创建或更新 membership,status 自动计算
|
||||||
|
3. `deleteUserMembership(userId: string)` — 软删除(status → EXPIRED)
|
||||||
|
|
||||||
|
关键业务逻辑:
|
||||||
|
- `updateUserMembership` 中 status 计算规则:
|
||||||
|
- `expireDate < now` → `EXPIRED`
|
||||||
|
- `remainingTimes === 0` → `USED_UP`
|
||||||
|
- 否则 → `ACTIVE`
|
||||||
|
- 如果用户已有 membership → 更新;无 → 创建新的
|
||||||
|
- 需要同时 import `CardTypeCategory` 和 `MembershipStatus`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 在 user.service.ts 顶部已有 import,追加方法
|
||||||
|
import { CardTypeCategory, MembershipStatus } from '@mp-pilates/shared'
|
||||||
|
import { UpdateUserMembershipDto } from './dto/update-user-membership.dto'
|
||||||
|
import { Membership, CardType, Prisma } from '@prisma/client'
|
||||||
|
import { PrismaService } from '../prisma/prisma.service'
|
||||||
|
|
||||||
|
// 在 class 末尾添加:
|
||||||
|
|
||||||
|
async getUserMembership(userId: string): Promise<(Membership & { cardType: CardType }) | null> {
|
||||||
|
// userId 无唯一约束,取第一条
|
||||||
|
const membership = await this.prisma.membership.findFirst({
|
||||||
|
where: { userId },
|
||||||
|
include: { cardType: true },
|
||||||
|
})
|
||||||
|
return membership ? { ...membership, cardType: { ...membership.cardType } } : null
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateUserMembership(
|
||||||
|
userId: string,
|
||||||
|
dto: UpdateUserMembershipDto,
|
||||||
|
): Promise<Membership & { cardType: CardType }> {
|
||||||
|
// 计算 status
|
||||||
|
const now = new Date()
|
||||||
|
const expireDate = new Date(dto.expireDate)
|
||||||
|
const remainingTimes = dto.remainingTimes ?? null
|
||||||
|
const isTimeBased = remainingTimes !== null
|
||||||
|
|
||||||
|
let status: MembershipStatus = MembershipStatus.ACTIVE
|
||||||
|
if (expireDate < now) {
|
||||||
|
status = MembershipStatus.EXPIRED
|
||||||
|
} else if (isTimeBased && remainingTimes <= 0) {
|
||||||
|
status = MembershipStatus.USED_UP
|
||||||
|
}
|
||||||
|
|
||||||
|
// 由于 userId 无唯一约束,先查是否存在,取第一条
|
||||||
|
const existing = await this.prisma.membership.findFirst({ where: { userId } })
|
||||||
|
|
||||||
|
let membership: Membership & { cardType: CardType }
|
||||||
|
if (existing) {
|
||||||
|
// 已有 membership → 更新
|
||||||
|
const updated = await this.prisma.membership.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: {
|
||||||
|
cardTypeId: dto.cardTypeId,
|
||||||
|
remainingTimes: dto.remainingTimes ?? null,
|
||||||
|
startDate: new Date(dto.startDate),
|
||||||
|
expireDate: new Date(dto.expireDate),
|
||||||
|
status,
|
||||||
|
},
|
||||||
|
include: { cardType: true },
|
||||||
|
})
|
||||||
|
membership = { ...updated, cardType: { ...updated.cardType } }
|
||||||
|
} else {
|
||||||
|
// 无 membership → 创建新的
|
||||||
|
const created = await this.prisma.membership.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
cardTypeId: dto.cardTypeId,
|
||||||
|
remainingTimes: dto.remainingTimes ?? null,
|
||||||
|
startDate: new Date(dto.startDate),
|
||||||
|
expireDate: new Date(dto.expireDate),
|
||||||
|
status,
|
||||||
|
},
|
||||||
|
include: { cardType: true },
|
||||||
|
})
|
||||||
|
membership = { ...created, cardType: { ...created.cardType } }
|
||||||
|
}
|
||||||
|
|
||||||
|
return membership
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteUserMembership(userId: string): Promise<void> {
|
||||||
|
await this.prisma.membership.updateMany({
|
||||||
|
where: { userId },
|
||||||
|
data: { status: MembershipStatus.EXPIRED },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
注意:`Membership` 表的 `userId` 暂无唯一约束,实现阶段如有需要可加。`findFirst` + `update` 的方案在只有一张卡时工作正常。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: UserController 新增 3 个 admin 接口
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `packages/server/src/user/user.controller.ts`
|
||||||
|
|
||||||
|
在文件顶部添加 `@Param` import,然后在 `Admin: Member Management` 区块后添加新接口:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Put,
|
||||||
|
Delete,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common'
|
||||||
|
// ... existing imports ...
|
||||||
|
import { UpdateUserMembershipDto } from './dto/update-user-membership.dto'
|
||||||
|
|
||||||
|
// 在 getMembers 之后添加:
|
||||||
|
|
||||||
|
@Get('admin/members/:userId/membership')
|
||||||
|
@UseGuards(RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
getUserMembership(@Param('userId') userId: string) {
|
||||||
|
return this.userService.getUserMembership(userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('admin/members/:userId/membership')
|
||||||
|
@UseGuards(RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
updateUserMembership(
|
||||||
|
@Param('userId') userId: string,
|
||||||
|
@Body() dto: UpdateUserMembershipDto,
|
||||||
|
) {
|
||||||
|
return this.userService.updateUserMembership(userId, dto)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('admin/members/:userId/membership')
|
||||||
|
@UseGuards(RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
deleteUserMembership(@Param('userId') userId: string) {
|
||||||
|
return this.userService.deleteUserMembership(userId)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: 前端 Store
|
||||||
|
|
||||||
|
### Task 4: Admin Store 新增 3 个 action
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `packages/app/src/stores/admin.ts`
|
||||||
|
|
||||||
|
在 `MemberSummary` 类型后添加 `UserMembership` 类型(放在 fetchMembers 附近):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface UserMembership {
|
||||||
|
userId: string
|
||||||
|
membership: {
|
||||||
|
id: string
|
||||||
|
cardTypeId: string
|
||||||
|
remainingTimes: number | null
|
||||||
|
startDate: string
|
||||||
|
expireDate: string
|
||||||
|
status: string
|
||||||
|
cardType: {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
type: string
|
||||||
|
totalTimes: number | null
|
||||||
|
durationDays: number
|
||||||
|
}
|
||||||
|
} | null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
在 `fetchMembers` action 后添加:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async function getUserMembership(userId: string): Promise<UserMembership> {
|
||||||
|
return get<UserMembership>(`/admin/members/${userId}/membership`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateUserMembership(
|
||||||
|
userId: string,
|
||||||
|
dto: {
|
||||||
|
cardTypeId: string
|
||||||
|
remainingTimes?: number | null
|
||||||
|
startDate: string
|
||||||
|
expireDate: string
|
||||||
|
},
|
||||||
|
): Promise<any> {
|
||||||
|
return put<any>(`/admin/members/${userId}/membership`, dto)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteUserMembership(userId: string): Promise<void> {
|
||||||
|
return del<void>(`/admin/members/${userId}/membership`)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
在 `return` 对象中导出这三个函数。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: 前端 UI(详情弹窗改造)
|
||||||
|
|
||||||
|
### Task 5: members.vue 弹窗改造 — 结构变更
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `packages/app/src/pages/admin/members.vue`
|
||||||
|
|
||||||
|
将现有的详情弹窗(`detail-modal`)从单一视图改造为 Tab 模式。
|
||||||
|
|
||||||
|
#### 5.1 模板改动
|
||||||
|
|
||||||
|
**原有弹窗代码:**
|
||||||
|
```vue
|
||||||
|
<!-- Detail modal -->
|
||||||
|
<view v-if="showDetail && detailMember" class="modal-mask" @tap.self="showDetail = false">
|
||||||
|
<view class="modal">
|
||||||
|
<!-- 详情内容 -->
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
```
|
||||||
|
|
||||||
|
**替换为:**
|
||||||
|
```vue
|
||||||
|
<!-- Detail / Edit modal -->
|
||||||
|
<view v-if="showDetail && detailMember" class="modal-mask" @tap.self="closeModal">
|
||||||
|
<view class="modal">
|
||||||
|
<!-- Header with close -->
|
||||||
|
<view class="modal-header">
|
||||||
|
<text class="modal-title">会员详情</text>
|
||||||
|
<text class="modal-close-btn" @tap="closeModal">×</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Tab bar -->
|
||||||
|
<view class="tab-bar">
|
||||||
|
<view
|
||||||
|
class="tab-item"
|
||||||
|
:class="{ 'tab-item--active': activeTab === 'detail' }"
|
||||||
|
@tap="switchTab('detail')"
|
||||||
|
>
|
||||||
|
<text class="tab-text">详情</text>
|
||||||
|
</view>
|
||||||
|
<view
|
||||||
|
class="tab-item"
|
||||||
|
:class="{ 'tab-item--active': activeTab === 'edit' }"
|
||||||
|
@tap="switchTab('edit')"
|
||||||
|
>
|
||||||
|
<text class="tab-text">编辑</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Tab: 详情 -->
|
||||||
|
<view v-if="activeTab === 'detail'" class="tab-content">
|
||||||
|
<!-- 头像区 -->
|
||||||
|
<view class="detail-header">
|
||||||
|
<view class="detail-avatar">
|
||||||
|
<image v-if="detailMember.avatarUrl" class="avatar-img" :src="detailMember.avatarUrl" mode="aspectFill" />
|
||||||
|
<view v-else class="avatar-placeholder avatar-placeholder--lg">
|
||||||
|
<text class="avatar-text avatar-text--lg">{{ (detailMember.nickname || '?').slice(0, 1) }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<text class="detail-name">{{ detailMember.nickname || '未知用户' }}</text>
|
||||||
|
<text class="detail-openid" @tap="copyOpenid(detailMember.openid)">{{ detailMember.openid }}</text>
|
||||||
|
<text class="detail-phone">{{ detailMember.phone || '未绑定手机' }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 会员卡信息(有卡时) -->
|
||||||
|
<view v-if="detailMembership" class="membership-card">
|
||||||
|
<view class="membership-card-header">
|
||||||
|
<text class="membership-card-name">{{ detailMembership.cardType.name }}</text>
|
||||||
|
<text class="membership-card-badge" :class="'badge--' + detailMembership.status">
|
||||||
|
{{ statusLabel(detailMembership.status) }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<view class="membership-card-info">
|
||||||
|
<text class="membership-info-item">有效期:{{ formatDate(detailMembership.startDate) }} - {{ formatDate(detailMembership.expireDate) }}</text>
|
||||||
|
<text v-if="detailMembership.remainingTimes != null" class="membership-info-item">
|
||||||
|
剩余:{{ detailMembership.remainingTimes }} 次
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 无卡时 -->
|
||||||
|
<view v-else class="no-membership">
|
||||||
|
<text class="no-membership-text">暂无会员卡</text>
|
||||||
|
<view class="no-membership-btn" @tap="goEdit">
|
||||||
|
<text class="no-membership-btn-text">去开卡</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 预约统计 -->
|
||||||
|
<view class="detail-stats">
|
||||||
|
<view class="detail-stat">
|
||||||
|
<text class="detail-stat-value">{{ detailMember.totalBookings }}</text>
|
||||||
|
<text class="detail-stat-label">总预约</text>
|
||||||
|
</view>
|
||||||
|
<view class="detail-stat">
|
||||||
|
<text class="detail-stat-value">{{ detailMember.completedBookings }}</text>
|
||||||
|
<text class="detail-stat-label">已完成</text>
|
||||||
|
</view>
|
||||||
|
<view class="detail-stat">
|
||||||
|
<text class="detail-stat-value">{{ detailMember.cancelledBookings }}</text>
|
||||||
|
<text class="detail-stat-label">已取消</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 清空卡按钮(有卡时) -->
|
||||||
|
<view v-if="detailMembership" class="danger-zone">
|
||||||
|
<view class="danger-btn" @tap="onClearMembership">
|
||||||
|
<text class="danger-btn-text">解除会员卡</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Tab: 编辑 -->
|
||||||
|
<view v-if="activeTab === 'edit'" class="tab-content">
|
||||||
|
<!-- 加载中 -->
|
||||||
|
<view v-if="editLoading" class="edit-loading">
|
||||||
|
<text class="edit-loading-text">加载中...</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 编辑表单 -->
|
||||||
|
<view v-else class="edit-form">
|
||||||
|
<!-- 卡种选择 -->
|
||||||
|
<view class="form-item">
|
||||||
|
<text class="form-label">卡种</text>
|
||||||
|
<picker
|
||||||
|
class="form-picker"
|
||||||
|
mode="selector"
|
||||||
|
:value="editForm.cardTypeIndex"
|
||||||
|
:range="editCardTypes"
|
||||||
|
range-key="name"
|
||||||
|
@change="onCardTypeChange"
|
||||||
|
>
|
||||||
|
<view class="form-picker-inner">
|
||||||
|
<text class="form-picker-text">{{ editCardTypes[editForm.cardTypeIndex]?.name || '请选择' }}</text>
|
||||||
|
<text class="form-picker-arrow">▾</text>
|
||||||
|
</view>
|
||||||
|
</picker>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 剩余次数(TIMES/TRIAL) -->
|
||||||
|
<view v-if="isTimeBasedCard" class="form-item">
|
||||||
|
<text class="form-label">剩余次数</text>
|
||||||
|
<input
|
||||||
|
class="form-input"
|
||||||
|
type="number"
|
||||||
|
v-model="editForm.remainingTimes"
|
||||||
|
placeholder="请输入剩余次数"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 生效日期 -->
|
||||||
|
<view class="form-item">
|
||||||
|
<text class="form-label">生效日期</text>
|
||||||
|
<picker
|
||||||
|
class="form-picker"
|
||||||
|
mode="date"
|
||||||
|
:value="editForm.startDate"
|
||||||
|
@change="(e) => onStartDateChange(e)"
|
||||||
|
>
|
||||||
|
<view class="form-picker-inner">
|
||||||
|
<text class="form-picker-text">{{ editForm.startDate }}</text>
|
||||||
|
</view>
|
||||||
|
</picker>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 有效期至 -->
|
||||||
|
<view class="form-item">
|
||||||
|
<text class="form-label">有效期至</text>
|
||||||
|
<picker
|
||||||
|
class="form-picker"
|
||||||
|
mode="date"
|
||||||
|
:value="editForm.expireDate"
|
||||||
|
@change="(e) => onExpireDateChange(e)"
|
||||||
|
>
|
||||||
|
<view class="form-picker-inner">
|
||||||
|
<text class="form-picker-text">{{ editForm.expireDate }}</text>
|
||||||
|
</view>
|
||||||
|
</picker>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 保存按钮 -->
|
||||||
|
<view class="edit-submit">
|
||||||
|
<view
|
||||||
|
class="submit-btn"
|
||||||
|
:class="{ 'submit-btn--disabled': editSubmitting }"
|
||||||
|
@tap="onSaveMembership"
|
||||||
|
>
|
||||||
|
<text class="submit-btn-text">{{ editSubmitting ? '保存中...' : '保存' }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5.2 Script 改动
|
||||||
|
|
||||||
|
新增以下状态:
|
||||||
|
|
||||||
|
> 注意:在 `import { ref, onMounted, onUnmounted }` 后添加 `computed`:
|
||||||
|
> `import { ref, computed, onMounted, onUnmounted } from 'vue'`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const activeTab = ref<'detail' | 'edit'>('detail')
|
||||||
|
const detailMembership = ref<any>(null) // 当前会员卡数据
|
||||||
|
const editLoading = ref(false)
|
||||||
|
const editSubmitting = ref(false)
|
||||||
|
const editCardTypes = ref<any[]>([]) // 卡种列表(去获取)
|
||||||
|
|
||||||
|
const editForm = ref({
|
||||||
|
cardTypeIndex: 0,
|
||||||
|
cardTypeId: '',
|
||||||
|
remainingTimes: null as number | null,
|
||||||
|
startDate: '',
|
||||||
|
expireDate: '',
|
||||||
|
manuallyEditedExpire: false, // 用户手动修改过 expireDate 时为 true
|
||||||
|
})
|
||||||
|
|
||||||
|
// 是否为次数卡(TIMES / TRIAL),依赖 cardTypeIndex
|
||||||
|
const isTimeBasedCard = computed(() => {
|
||||||
|
const card = editCardTypes.value[editForm.value.cardTypeIndex]
|
||||||
|
return card && (card.type === 'TIMES' || card.type === 'TRIAL')
|
||||||
|
})
|
||||||
|
|
||||||
|
// 工具函数
|
||||||
|
function formatDate(dateStr: string): string {
|
||||||
|
if (!dateStr) return '-'
|
||||||
|
return dateStr.slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(status: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
ACTIVE: '有效',
|
||||||
|
EXPIRED: '已过期',
|
||||||
|
USED_UP: '已用完',
|
||||||
|
}
|
||||||
|
return map[status] || status
|
||||||
|
}
|
||||||
|
|
||||||
|
// 懒加载会员卡数据
|
||||||
|
async function loadDetailMembership(userId: string) {
|
||||||
|
detailMembership.value = null
|
||||||
|
try {
|
||||||
|
const result = await adminStore.getUserMembership(userId)
|
||||||
|
detailMembership.value = result.membership
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchTab(tab: 'detail' | 'edit') {
|
||||||
|
activeTab.value = tab
|
||||||
|
if (tab === 'edit') {
|
||||||
|
// 切换到编辑 Tab 时加载会员卡数据和卡种列表
|
||||||
|
if (!editCardTypes.value.length) {
|
||||||
|
adminStore.fetchCardTypes().then((types) => {
|
||||||
|
editCardTypes.value = types
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// 如果还没加载 membership,先加载
|
||||||
|
if (!detailMembership.value) {
|
||||||
|
loadDetailMembership(detailMember.value!.userId).then(() => {
|
||||||
|
initEditForm()
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
initEditForm()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initEditForm() {
|
||||||
|
const m = detailMembership.value
|
||||||
|
const types = editCardTypes.value
|
||||||
|
if (m) {
|
||||||
|
// 有卡:预填充
|
||||||
|
const idx = types.findIndex((t) => t.id === m.cardTypeId)
|
||||||
|
editForm.value = {
|
||||||
|
cardTypeIndex: idx >= 0 ? idx : 0,
|
||||||
|
cardTypeId: m.cardTypeId,
|
||||||
|
remainingTimes: m.remainingTimes,
|
||||||
|
startDate: m.startDate.slice(0, 10),
|
||||||
|
expireDate: m.expireDate.slice(0, 10),
|
||||||
|
manuallyEditedExpire: false,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 无卡:默认选中第一个卡种
|
||||||
|
editForm.value = {
|
||||||
|
cardTypeIndex: 0,
|
||||||
|
cardTypeId: types[0]?.id || '',
|
||||||
|
remainingTimes: types[0]?.totalTimes ?? null,
|
||||||
|
startDate: formatDate2(new Date()),
|
||||||
|
expireDate: calculateExpireDate(formatDate2(new Date()), types[0]?.durationDays ?? 30),
|
||||||
|
manuallyEditedExpire: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate2(date: Date): string {
|
||||||
|
const y = date.getFullYear()
|
||||||
|
const m = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const d = String(date.getDate()).padStart(2, '0')
|
||||||
|
return `${y}-${m}-${d}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateExpireDate(startDate: string, durationDays: number): string {
|
||||||
|
const d = new Date(startDate)
|
||||||
|
d.setDate(d.getDate() + durationDays)
|
||||||
|
return formatDate2(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCardTypeChange(e: { detail: { value: number } }) {
|
||||||
|
const idx = e.detail.value
|
||||||
|
const cardType = editCardTypes.value[idx]
|
||||||
|
editForm.value.cardTypeIndex = idx
|
||||||
|
editForm.value.cardTypeId = cardType.id
|
||||||
|
// 自动填充次数和有效期(仅当用户未手动修改过有效期时)
|
||||||
|
if (cardType.totalTimes != null) {
|
||||||
|
editForm.value.remainingTimes = cardType.totalTimes
|
||||||
|
}
|
||||||
|
if (!editForm.value.manuallyEditedExpire) {
|
||||||
|
editForm.value.startDate = formatDate2(new Date())
|
||||||
|
editForm.value.expireDate = calculateExpireDate(
|
||||||
|
formatDate2(new Date()),
|
||||||
|
cardType.durationDays,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onStartDateChange(e: { detail: { value: string } }) {
|
||||||
|
editForm.value.startDate = e.detail.value
|
||||||
|
if (!editForm.value.manuallyEditedExpire) {
|
||||||
|
const cardType = editCardTypes.value[editForm.value.cardTypeIndex]
|
||||||
|
if (cardType) {
|
||||||
|
editForm.value.expireDate = calculateExpireDate(e.detail.value, cardType.durationDays)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onExpireDateChange(e: { detail: { value: string } }) {
|
||||||
|
editForm.value.expireDate = e.detail.value
|
||||||
|
editForm.value.manuallyEditedExpire = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function goEdit() {
|
||||||
|
switchTab('edit')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSaveMembership() {
|
||||||
|
if (editSubmitting.value) return
|
||||||
|
const userId = detailMember.value?.userId
|
||||||
|
if (!userId) return
|
||||||
|
editSubmitting.value = true
|
||||||
|
try {
|
||||||
|
await adminStore.updateUserMembership(userId, {
|
||||||
|
cardTypeId: editForm.value.cardTypeId,
|
||||||
|
remainingTimes: editForm.value.remainingTimes,
|
||||||
|
startDate: editForm.value.startDate,
|
||||||
|
expireDate: editForm.value.expireDate,
|
||||||
|
})
|
||||||
|
uni.showToast({ title: '保存成功', icon: 'success' })
|
||||||
|
activeTab.value = 'detail'
|
||||||
|
await loadDetailMembership(userId)
|
||||||
|
// 刷新列表
|
||||||
|
loadMembers(false)
|
||||||
|
} catch {
|
||||||
|
uni.showToast({ title: '保存失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
editSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onClearMembership() {
|
||||||
|
const userId = detailMember.value?.userId
|
||||||
|
if (!userId) return
|
||||||
|
uni.showModal({
|
||||||
|
title: '确认解除',
|
||||||
|
content: '确定要解除该用户的会员卡吗?',
|
||||||
|
confirmColor: '#e64329',
|
||||||
|
success: async (res) => {
|
||||||
|
if (!res.confirm) return
|
||||||
|
try {
|
||||||
|
await adminStore.deleteUserMembership(userId)
|
||||||
|
uni.showToast({ title: '已解除', icon: 'success' })
|
||||||
|
showDetail.value = false
|
||||||
|
loadMembers(false)
|
||||||
|
} catch {
|
||||||
|
uni.showToast({ title: '操作失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
showDetail.value = false
|
||||||
|
activeTab.value = 'detail'
|
||||||
|
detailMembership.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 修改 openDetail
|
||||||
|
function openDetail(m: MemberSummary) {
|
||||||
|
detailMember.value = m
|
||||||
|
showDetail.value = true
|
||||||
|
detailMembership.value = null // 重置,懒加载
|
||||||
|
activeTab.value = 'detail'
|
||||||
|
// 预加载 membership(详情 Tab 需要显示)
|
||||||
|
loadDetailMembership(m.userId)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5.3 样式改动
|
||||||
|
|
||||||
|
在 `<style>` 末尾添加以下样式:
|
||||||
|
|
||||||
|
```scss
|
||||||
|
/* ── Modal header ─────────────────────────── */
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 0 32rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $brand-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close-btn {
|
||||||
|
font-size: 48rpx;
|
||||||
|
color: $text-hint;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tab bar ───────────────────────────────── */
|
||||||
|
.tab-bar {
|
||||||
|
display: flex;
|
||||||
|
border-bottom: 1rpx solid $border-color;
|
||||||
|
margin-bottom: 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding-bottom: 16rpx;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item--active::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 48rpx;
|
||||||
|
height: 4rpx;
|
||||||
|
background: $accent-color;
|
||||||
|
border-radius: 2rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-text {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: $text-hint;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item--active .tab-text {
|
||||||
|
color: $accent-color;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
min-height: 400rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Membership card ────────────────────────── */
|
||||||
|
.membership-card {
|
||||||
|
background: linear-gradient(135deg, rgba($brand-color, 0.08), rgba($accent-color, 0.08));
|
||||||
|
border: 1rpx solid rgba($brand-color, 0.15);
|
||||||
|
border-radius: $radius-md;
|
||||||
|
padding: 24rpx;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.membership-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.membership-card-name {
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $brand-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.membership-card-badge {
|
||||||
|
font-size: 20rpx;
|
||||||
|
padding: 4rpx 12rpx;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge--ACTIVE { background: rgba(#52c41a, 0.12); color: #52c41a; }
|
||||||
|
.badge--EXPIRED { background: rgba($text-hint, 0.12); color: $text-hint; }
|
||||||
|
.badge--USED_UP { background: rgba(#faad14, 0.12); color: #faad14; }
|
||||||
|
|
||||||
|
.membership-card-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.membership-info-item {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: $text-secondary;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── No membership ──────────────────────────── */
|
||||||
|
.no-membership {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 48rpx 0;
|
||||||
|
gap: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-membership-text {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: $text-hint;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-membership-btn {
|
||||||
|
background: $brand-color;
|
||||||
|
border-radius: 36rpx;
|
||||||
|
padding: 16rpx 48rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-membership-btn-text {
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $accent-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Danger zone ───────────────────────────── */
|
||||||
|
.danger-zone {
|
||||||
|
margin-top: 24rpx;
|
||||||
|
padding-top: 24rpx;
|
||||||
|
border-top: 1rpx solid $border-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-btn {
|
||||||
|
width: 100%;
|
||||||
|
height: 80rpx;
|
||||||
|
background: rgba(230, 67, 41, 0.08);
|
||||||
|
border-radius: 40rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-btn-text {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #e64329;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Edit form ─────────────────────────────── */
|
||||||
|
.edit-loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 80rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-loading-text {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: $text-hint;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: $text-secondary;
|
||||||
|
width: 140rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input {
|
||||||
|
flex: 1;
|
||||||
|
height: 72rpx;
|
||||||
|
background: $bg-page;
|
||||||
|
border-radius: $radius-sm;
|
||||||
|
padding: 0 20rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: $text-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-picker {
|
||||||
|
flex: 1;
|
||||||
|
height: 72rpx;
|
||||||
|
background: $bg-page;
|
||||||
|
border-radius: $radius-sm;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-picker-inner {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-picker-text {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: $text-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-picker-arrow {
|
||||||
|
font-size: 20rpx;
|
||||||
|
color: $text-hint;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-submit {
|
||||||
|
margin-top: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn {
|
||||||
|
width: 100%;
|
||||||
|
height: 88rpx;
|
||||||
|
background: $brand-color;
|
||||||
|
border-radius: 44rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn--disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn-text {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $accent-color;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 自检清单
|
||||||
|
|
||||||
|
- [ ] 后端接口路径正确:`/admin/members/:userId/membership`(不是 `/admin/membership/...`)
|
||||||
|
- [ ] `UpdateUserMembershipDto` 字段与前端 `editForm` 一致
|
||||||
|
- [ ] `isTimeBasedCard` 计算逻辑:`cardType.type === 'TIMES' || cardType.type === 'TRIAL'`
|
||||||
|
- [ ] `detailMembership` 在 `openDetail` 时懒加载,不阻塞弹窗打开
|
||||||
|
- [ ] `deleteUserMembership` 是软删除(status → EXPIRED),不是物理删除
|
||||||
|
- [ ] `switchTab('edit')` 时 initEditForm 使用 `detailMembership.value`(可能为 null)
|
||||||
|
- [ ] 卡种 picker 变化时,`remainingTimes` 只对 TIMES/TRIAL 类型生效
|
||||||
186
docs/superpowers/specs/2026-04-07-member-card-edit-design.md
Normal file
186
docs/superpowers/specs/2026-04-07-member-card-edit-design.md
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
# 会员卡编辑功能设计
|
||||||
|
|
||||||
|
**日期:** 2026-04-07
|
||||||
|
**状态:** 已批准
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 概述
|
||||||
|
|
||||||
|
在管理后台会员列表中,点击会员 item 时弹出的详情弹窗增加"编辑"能力。管理员可编辑指定用户的卡种、卡有效期、剩余次数,也可清空/解除会员卡。
|
||||||
|
|
||||||
|
**前置约束:**
|
||||||
|
- 一个用户只能拥有一张会员卡
|
||||||
|
- 支持清空卡(解除会员资格)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 交互设计
|
||||||
|
|
||||||
|
### 2.1 弹窗结构
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────┐
|
||||||
|
│ 会员详情 [×] │
|
||||||
|
├──────────┬──────────────────────┤
|
||||||
|
│ 详情 │ 编辑 │ ← Tab 切换
|
||||||
|
├──────────┴──────────────────────┤
|
||||||
|
│ 头像 / 昵称 / OpenID / 手机号 │
|
||||||
|
│ ───────────────────────────── │
|
||||||
|
│ 会员卡信息区(根据 Tab 显示) │
|
||||||
|
│ 预约统计(总/完成/取消) │
|
||||||
|
│ ───────────────────────────── │
|
||||||
|
│ [关闭] │
|
||||||
|
└─────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 无卡用户交互
|
||||||
|
|
||||||
|
- 详情 Tab 显示"暂无会员卡"提示
|
||||||
|
- 显示"去开卡"按钮,点击切换到编辑 Tab
|
||||||
|
- 编辑 Tab 中卡种 picker 默认选中列表第一项,有效期自动计算
|
||||||
|
|
||||||
|
### 2.3 状态说明
|
||||||
|
|
||||||
|
| 场景 | Tab 初始显示 | 编辑表单状态 |
|
||||||
|
|------|-------------|-------------|
|
||||||
|
| 有卡用户 | 详情 Tab | 预填充当前数据 |
|
||||||
|
| 无卡用户 | 详情 Tab(提示无卡) | 空白表单,卡种默认选中第一项 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 表单字段
|
||||||
|
|
||||||
|
| 字段 | 组件 | 条件 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| 卡种 | picker | 必填 | 从 `CardType` 表读取,按 sortOrder 排序 |
|
||||||
|
| 剩余次数 | input (number) | TIMES / TRIAL 类型 | 默认填充卡种的 totalTimes |
|
||||||
|
| 生效日期 | date picker | 必填 | 默认当天 |
|
||||||
|
| 有效期至 | date picker | 必填 | 自动计算,支持手动调整 |
|
||||||
|
|
||||||
|
### 3.1 卡种切换逻辑
|
||||||
|
|
||||||
|
切换卡种时:
|
||||||
|
1. `remainingTimes` 自动填充新卡种的 `totalTimes`(TIMES/TRIAL 类型)
|
||||||
|
2. `expireDate` 按新卡种 `durationDays` 重新计算起始日期
|
||||||
|
3. 编辑器内手动修改过 `expireDate` 时,不再自动覆盖
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. API 设计
|
||||||
|
|
||||||
|
### 4.1 获取用户会员卡
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /admin/members/:userId/membership
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"userId": "uuid",
|
||||||
|
"membership": {
|
||||||
|
"id": "uuid",
|
||||||
|
"cardTypeId": "uuid",
|
||||||
|
"remainingTimes": 10,
|
||||||
|
"startDate": "2026-01-01",
|
||||||
|
"expireDate": "2026-04-01",
|
||||||
|
"status": "ACTIVE",
|
||||||
|
"cardType": { ... }
|
||||||
|
} | null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 创建或更新会员卡
|
||||||
|
|
||||||
|
```
|
||||||
|
PUT /admin/members/:userId/membership
|
||||||
|
```
|
||||||
|
|
||||||
|
**请求体:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"cardTypeId": "uuid",
|
||||||
|
"remainingTimes": 10,
|
||||||
|
"startDate": "2026-04-07",
|
||||||
|
"expireDate": "2026-07-07"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**业务逻辑:**
|
||||||
|
- 若该用户已有 membership → 更新
|
||||||
|
- 若该用户无 membership → 创建新的
|
||||||
|
- `status` 由后端根据 expireDate 和 remainingTimes 自动计算
|
||||||
|
|
||||||
|
### 4.3 解除会员卡
|
||||||
|
|
||||||
|
```
|
||||||
|
DELETE /admin/members/:userId/membership
|
||||||
|
```
|
||||||
|
|
||||||
|
**业务逻辑:**
|
||||||
|
- 将 membership 的 `status` 标记为 `EXPIRED`(软删除,便于审计)
|
||||||
|
- 不物理删除记录
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 数据模型
|
||||||
|
|
||||||
|
### 5.1 会员卡状态自动计算规则
|
||||||
|
|
||||||
|
```
|
||||||
|
if (expireDate < now) → EXPIRED
|
||||||
|
else if (remainingTimes === 0) → USED_UP
|
||||||
|
else → ACTIVE
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 变更日志
|
||||||
|
|
||||||
|
通过现有的 `BookingStatusHistory` 表记录操作(tbd: 是否需要独立 `MembershipChangeLog` 表,待实现时确认)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 错误处理
|
||||||
|
|
||||||
|
| 场景 | 处理 |
|
||||||
|
|------|------|
|
||||||
|
| 卡种不存在 | 后端返回 404,前端提示"卡种不存在" |
|
||||||
|
| 有效期早于生效日期 | 前端表单校验失败,提示"有效期不能早于生效日期" |
|
||||||
|
| 次数为负数 | 前端表单校验失败,提示"次数不能为负数" |
|
||||||
|
| 网络错误 | Toast 提示"网络错误,请重试" |
|
||||||
|
| 清空卡确认 | 弹出确认对话框,提示"确定要解除该用户的会员卡吗?" |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 涉及改动
|
||||||
|
|
||||||
|
### 后端
|
||||||
|
|
||||||
|
- `packages/server/src/membership/membership.controller.ts` — 新增三个 admin 接口
|
||||||
|
- `packages/server/src/membership/membership.service.ts` — 新增 `getUserMembership` / `updateUserMembership` / `deleteUserMembership`
|
||||||
|
- `packages/server/src/user/user.controller.ts` — 无改动(列表接口保持不变)
|
||||||
|
|
||||||
|
### 前端
|
||||||
|
|
||||||
|
- `packages/app/src/stores/admin.ts` — 新增三个 store action
|
||||||
|
- `packages/app/src/pages/admin/members.vue` — 将详情弹窗改造为 Tab 模式,新增编辑表单
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. UI 细节
|
||||||
|
|
||||||
|
### 8.1 Tab 切换
|
||||||
|
|
||||||
|
- 详情/编辑 Tab 横向排列,激活态有下划线指示
|
||||||
|
- 无卡用户点击"去开卡"后,自动切到编辑 Tab
|
||||||
|
|
||||||
|
### 8.2 编辑表单提交
|
||||||
|
|
||||||
|
- 保存成功后 Toast 提示"保存成功"
|
||||||
|
- 自动切回详情 Tab 并刷新数据
|
||||||
|
- 保存按钮点击后置灰,防止重复提交
|
||||||
|
|
||||||
|
### 8.3 清空卡
|
||||||
|
|
||||||
|
- 需二次确认
|
||||||
|
- 成功后弹窗关闭,列表自动刷新
|
||||||
@@ -22,5 +22,6 @@
|
|||||||
"@prisma/engines",
|
"@prisma/engines",
|
||||||
"prisma"
|
"prisma"
|
||||||
]
|
]
|
||||||
}
|
},
|
||||||
|
"version": "0.0.1"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,25 +8,25 @@
|
|||||||
"type-check": "vue-tsc --noEmit"
|
"type-check": "vue-tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dcloudio/uni-app": "3.0.0-4060620250520001",
|
"@dcloudio/uni-app": "3.0.0-5000620260331001",
|
||||||
"@dcloudio/uni-app-plus": "3.0.0-4060620250520001",
|
"@dcloudio/uni-app-plus": "3.0.0-5000620260331001",
|
||||||
"@dcloudio/uni-components": "3.0.0-4060620250520001",
|
"@dcloudio/uni-components": "3.0.0-5000620260331001",
|
||||||
"@dcloudio/uni-h5": "3.0.0-4060620250520001",
|
"@dcloudio/uni-h5": "3.0.0-5000620260331001",
|
||||||
"@dcloudio/uni-mp-weixin": "3.0.0-4060620250520001",
|
"@dcloudio/uni-mp-weixin": "3.0.0-5000620260331001",
|
||||||
"@mp-pilates/shared": "workspace:*",
|
"@mp-pilates/shared": "workspace:*",
|
||||||
"pinia": "^2.1.7",
|
"pinia": "^2.1.7",
|
||||||
"vue": "^3.4.0"
|
"vue": "^3.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@dcloudio/types": "^3.4.0",
|
"@dcloudio/types": "^3.4.0",
|
||||||
"@dcloudio/uni-automator": "3.0.0-4060620250520001",
|
"@dcloudio/uni-automator": "3.0.0-5000620260331001",
|
||||||
"@dcloudio/uni-cli-shared": "3.0.0-4060620250520001",
|
"@dcloudio/uni-cli-shared": "3.0.0-5000620260331001",
|
||||||
"@dcloudio/uni-stacktracey": "3.0.0-4060620250520001",
|
"@dcloudio/uni-stacktracey": "3.0.0-5000620260331001",
|
||||||
"@dcloudio/vite-plugin-uni": "3.0.0-4060620250520001",
|
"@dcloudio/vite-plugin-uni": "3.0.0-5000620260331001",
|
||||||
"@types/node": "^20.0.0",
|
"@types/node": "^20.0.0",
|
||||||
|
"sass": "^1.77.0",
|
||||||
"typescript": "^5.4.0",
|
"typescript": "^5.4.0",
|
||||||
"vite": "^5.4.0",
|
"vite": "^5.4.0",
|
||||||
"vue-tsc": "^2.0.0",
|
"vue-tsc": "^2.0.0"
|
||||||
"sass": "^1.77.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
84
packages/app/src/components/AboutSection.vue
Normal file
84
packages/app/src/components/AboutSection.vue
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
<template>
|
||||||
|
<view class="about-section">
|
||||||
|
<view class="section-header">
|
||||||
|
<view>
|
||||||
|
<text class="section-title">老师介绍</text>
|
||||||
|
</view>
|
||||||
|
<text class="section-more" @tap="goToDetail">查看详情</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="teacher-card" @tap="goToDetail">
|
||||||
|
<view class="teacher-main">
|
||||||
|
<view class="cover-wrap">
|
||||||
|
<image class="teacher-cover" :src="teacher.avatar" mode="aspectFill" />
|
||||||
|
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="teacher-content">
|
||||||
|
<view class="teacher-heading">
|
||||||
|
<view>
|
||||||
|
<view class="name-row">
|
||||||
|
<text class="teacher-name">{{ teacher.name }}</text>
|
||||||
|
|
||||||
|
</view>
|
||||||
|
<text class="teacher-title">{{ teacher.title }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="specialty-row">
|
||||||
|
<text class="specialty-text">{{ teacher.specialties.join(' · ') }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<text class="teacher-intro">{{ teacher.intro }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="action-row">
|
||||||
|
<view class="secondary-btn" @tap.stop="goToDetail">
|
||||||
|
<text class="secondary-btn-text">查看详情</text>
|
||||||
|
</view>
|
||||||
|
<view class="primary-btn" @tap.stop="goToBooking">
|
||||||
|
<text class="primary-btn-text">立即预约</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { irisProfile } from '../utils/teacher'
|
||||||
|
|
||||||
|
const teacher = irisProfile
|
||||||
|
|
||||||
|
function goToDetail() {
|
||||||
|
uni.navigateTo({ url: `/pages/teacher/detail?id=${teacher.id}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToBooking() {
|
||||||
|
uni.switchTab({ url: '/pages/booking/index' })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.about-section { margin: 36rpx 32rpx 0; }
|
||||||
|
.section-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20rpx; }
|
||||||
|
.section-title { font-size: 30rpx; font-weight: 500; color: #514943; }
|
||||||
|
.section-more { font-size: 23rpx; color: #8b817b; padding: 8rpx 0; }
|
||||||
|
.teacher-card { padding: 26rpx; background: #f2ebe5; border-radius: 28rpx; }
|
||||||
|
.teacher-main { display: flex; align-items: flex-start; gap: 24rpx; }
|
||||||
|
.cover-wrap { width: 164rpx; height: 230rpx; border-radius: 18rpx; overflow: hidden; background: #e1d4c8; flex-shrink: 0; }
|
||||||
|
.teacher-cover { width: 100%; height: 100%; }
|
||||||
|
.teacher-content { flex: 1; min-width: 0; }
|
||||||
|
.name-row { margin-bottom: 8rpx; }
|
||||||
|
.teacher-name { font-size: 34rpx; font-weight: 500; color: #514943; }
|
||||||
|
.teacher-title { font-size: 22rpx; color: #8b817b; line-height: 1.5; }
|
||||||
|
.specialty-row { margin: 14rpx 0 10rpx; }
|
||||||
|
.specialty-text { font-size: 21rpx; color: #617d73; line-height: 1.6; }
|
||||||
|
.teacher-intro { font-size: 22rpx; line-height: 1.7; color: #786b61; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
|
||||||
|
.action-row { display: flex; gap: 16rpx; margin-top: 24rpx; }
|
||||||
|
.secondary-btn, .primary-btn { flex: 1; height: 68rpx; border-radius: 999rpx; display: flex; align-items: center; justify-content: center; }
|
||||||
|
.secondary-btn { background: #fbf7f3; }
|
||||||
|
.primary-btn { background: #6b8276; }
|
||||||
|
.secondary-btn-text { font-size: 24rpx; color: #78675c; }
|
||||||
|
.primary-btn-text { font-size: 24rpx; font-weight: 400; color: #fff; }
|
||||||
|
</style>
|
||||||
@@ -15,18 +15,18 @@
|
|||||||
<view class="info-section">
|
<view class="info-section">
|
||||||
<view class="info-row">
|
<view class="info-row">
|
||||||
<text class="info-label">日期</text>
|
<text class="info-label">日期</text>
|
||||||
<text class="info-value">{{ slot?.date }}</text>
|
<text class="info-value">{{ timeSlot?.date }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="info-row">
|
<view class="info-row">
|
||||||
<text class="info-label">时间</text>
|
<text class="info-label">时间</text>
|
||||||
<text class="info-value" v-if="slot">
|
<text class="info-value" v-if="timeSlot">
|
||||||
{{ slot.startTime.slice(0, 5) }} - {{ slot.endTime.slice(0, 5) }}
|
{{ timeSlot.startTime.slice(0, 5) }} - {{ timeSlot.endTime.slice(0, 5) }}
|
||||||
</text>
|
</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="info-row">
|
<view class="info-row">
|
||||||
<text class="info-label">剩余</text>
|
<text class="info-label">剩余</text>
|
||||||
<text class="info-value" v-if="slot">
|
<text class="info-value" v-if="timeSlot">
|
||||||
{{ slot.capacity - slot.bookedCount }} 个名额
|
{{ timeSlot.capacity - timeSlot.bookedCount }} 个名额
|
||||||
</text>
|
</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -36,15 +36,12 @@
|
|||||||
<!-- Membership card selection -->
|
<!-- Membership card selection -->
|
||||||
<view class="card-section">
|
<view class="card-section">
|
||||||
<view class="section-label-row">
|
<view class="section-label-row">
|
||||||
<text class="section-label">选择扣课会员卡</text>
|
<text class="section-label">选择会员卡</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Single membership -->
|
<!-- Single membership -->
|
||||||
<view v-if="memberships.length === 1" class="card-single">
|
<view v-if="memberships.length === 1" class="card-single">
|
||||||
<view class="card-item selected">
|
<view class="card-item selected">
|
||||||
<view class="card-icon-wrap">
|
|
||||||
<text class="card-icon">💳</text>
|
|
||||||
</view>
|
|
||||||
<view class="card-info">
|
<view class="card-info">
|
||||||
<text class="card-name">{{ memberships[0].cardType.name }}</text>
|
<text class="card-name">{{ memberships[0].cardType.name }}</text>
|
||||||
<text class="card-remain" v-if="memberships[0].remainingTimes !== null">
|
<text class="card-remain" v-if="memberships[0].remainingTimes !== null">
|
||||||
@@ -61,7 +58,7 @@
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Multiple memberships picker -->
|
<!-- Multiple memberships picker -->
|
||||||
<view v-else-if="memberships.length > 1" class="card-picker-wrap">
|
<scroll-view v-else-if="memberships.length > 1" class="card-picker-wrap" scroll-y>
|
||||||
<view
|
<view
|
||||||
v-for="m in memberships"
|
v-for="m in memberships"
|
||||||
:key="m.id"
|
:key="m.id"
|
||||||
@@ -69,9 +66,6 @@
|
|||||||
:class="{ selected: selectedMembershipId === m.id }"
|
:class="{ selected: selectedMembershipId === m.id }"
|
||||||
@tap="selectedMembershipId = m.id"
|
@tap="selectedMembershipId = m.id"
|
||||||
>
|
>
|
||||||
<view class="card-icon-wrap">
|
|
||||||
<text class="card-icon">💳</text>
|
|
||||||
</view>
|
|
||||||
<view class="card-info">
|
<view class="card-info">
|
||||||
<text class="card-name">{{ m.cardType.name }}</text>
|
<text class="card-name">{{ m.cardType.name }}</text>
|
||||||
<text class="card-remain" v-if="m.remainingTimes !== null">
|
<text class="card-remain" v-if="m.remainingTimes !== null">
|
||||||
@@ -85,7 +79,7 @@
|
|||||||
<text class="check-icon">✓</text>
|
<text class="check-icon">✓</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</scroll-view>
|
||||||
|
|
||||||
<!-- No memberships fallback (should not normally appear) -->
|
<!-- No memberships fallback (should not normally appear) -->
|
||||||
<view v-else class="no-card-tip">
|
<view v-else class="no-card-tip">
|
||||||
@@ -96,7 +90,9 @@
|
|||||||
<!-- Deduction tip -->
|
<!-- Deduction tip -->
|
||||||
<view class="deduction-tip" v-if="selectedMembership">
|
<view class="deduction-tip" v-if="selectedMembership">
|
||||||
<text class="deduction-text">
|
<text class="deduction-text">
|
||||||
确认后将从「{{ selectedMembership.cardType.name }}」扣除 1 次课时
|
{{ selectedMembership.remainingTimes === null
|
||||||
|
? `「${selectedMembership.cardType.name}」有效期内不限次`
|
||||||
|
: `确认后将从「${selectedMembership.cardType.name}」扣除 1 次课时` }}
|
||||||
</text>
|
</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -105,13 +101,15 @@
|
|||||||
<view class="btn-outline" @tap="handleCancel">
|
<view class="btn-outline" @tap="handleCancel">
|
||||||
<text class="btn-outline-text">取消</text>
|
<text class="btn-outline-text">取消</text>
|
||||||
</view>
|
</view>
|
||||||
<view
|
<button
|
||||||
class="btn-confirm"
|
class="btn-confirm"
|
||||||
:class="{ disabled: !selectedMembershipId }"
|
:class="{ disabled: !selectedMembershipId }"
|
||||||
|
:disabled="!selectedMembershipId || requestingSubscribe"
|
||||||
|
:loading="requestingSubscribe"
|
||||||
@tap="handleConfirm"
|
@tap="handleConfirm"
|
||||||
>
|
>
|
||||||
<text class="btn-confirm-text">确认预约</text>
|
<text class="btn-confirm-text">确认预约</text>
|
||||||
</view>
|
</button>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -120,10 +118,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import type { TimeSlotWithBookingStatus, MembershipWithCardType } from '@mp-pilates/shared'
|
import type { TimeSlotWithBookingStatus, MembershipWithCardType } from '@mp-pilates/shared'
|
||||||
|
import { requestBookingCreatedSubscriptionMessage } from '../utils/wechat-subscription'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
visible: boolean
|
visible: boolean
|
||||||
slot: TimeSlotWithBookingStatus | null
|
timeSlot: TimeSlotWithBookingStatus | null
|
||||||
memberships: MembershipWithCardType[]
|
memberships: MembershipWithCardType[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
@@ -134,13 +133,14 @@ const emit = defineEmits<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const selectedMembershipId = ref<string>('')
|
const selectedMembershipId = ref<string>('')
|
||||||
|
const requestingSubscribe = ref(false)
|
||||||
|
|
||||||
// Auto-select the first membership when popup opens or memberships list changes
|
// Auto-select the first membership when popup opens or memberships list changes
|
||||||
watch(
|
watch(
|
||||||
[() => props.visible, () => props.memberships],
|
[() => props.visible, () => props.memberships],
|
||||||
([visible, memberships]) => {
|
([visible, memberships]) => {
|
||||||
if (visible && memberships.length > 0) {
|
if (visible) {
|
||||||
selectedMembershipId.value = memberships[0].id
|
selectedMembershipId.value = memberships[0]?.id ?? ''
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
@@ -150,10 +150,24 @@ const selectedMembership = computed(() =>
|
|||||||
props.memberships.find((m) => m.id === selectedMembershipId.value) ?? null,
|
props.memberships.find((m) => m.id === selectedMembershipId.value) ?? null,
|
||||||
)
|
)
|
||||||
|
|
||||||
function handleConfirm() {
|
async function handleConfirm() {
|
||||||
if (!props.slot || !selectedMembershipId.value) return
|
if (!props.timeSlot || !selectedMembershipId.value) return
|
||||||
|
|
||||||
|
if (requestingSubscribe.value) return
|
||||||
|
requestingSubscribe.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
await requestBookingCreatedSubscriptionMessage()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : '订阅消息授权失败'
|
||||||
|
uni.showToast({ title: message, icon: 'none' })
|
||||||
|
return
|
||||||
|
} finally {
|
||||||
|
requestingSubscribe.value = false
|
||||||
|
}
|
||||||
|
|
||||||
emit('confirm', {
|
emit('confirm', {
|
||||||
timeSlotId: props.slot.id,
|
timeSlotId: props.timeSlot.id,
|
||||||
membershipId: selectedMembershipId.value,
|
membershipId: selectedMembershipId.value,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -169,261 +183,45 @@ function handleMaskTap() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.popup-mask {
|
.popup-mask { position: fixed; inset: 0; background: rgba(56, 48, 42, 0.32); z-index: 1000; display: flex; align-items: flex-end; justify-content: center; }
|
||||||
position: fixed;
|
.popup-panel { width: 100%; box-sizing: border-box; background: #fbf9f6; border-radius: 36rpx 36rpx 0 0; padding: 28rpx 32rpx calc(24rpx + env(safe-area-inset-bottom)); }
|
||||||
top: 0;
|
.popup-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 24rpx; }
|
||||||
left: 0;
|
.popup-title { font-size: 32rpx; font-weight: 500; color: #514943; }
|
||||||
right: 0;
|
.close-btn { width: 60rpx; height: 60rpx; display: flex; align-items: center; justify-content: center; }
|
||||||
bottom: 0;
|
.close-icon { font-size: 26rpx; color: #8b817b; }
|
||||||
background: rgba(0, 0, 0, 0.45);
|
.info-section { padding: 24rpx; background: #f1e8e1; border-radius: 24rpx; display: flex; flex-direction: column; gap: 14rpx; }
|
||||||
z-index: 1000;
|
.info-row { display: flex; align-items: center; gap: 20rpx; }
|
||||||
display: flex;
|
.info-label { font-size: 24rpx; color: #8b7b70; width: 64rpx; flex-shrink: 0; }
|
||||||
align-items: flex-end;
|
.info-value { font-size: 26rpx; color: #6f5c50; font-weight: 400; }
|
||||||
justify-content: center;
|
.divider { height: 24rpx; }
|
||||||
}
|
.card-section { display: flex; flex-direction: column; gap: 16rpx; }
|
||||||
|
.section-label { font-size: 26rpx; color: #514943; font-weight: 500; }
|
||||||
.popup-panel {
|
.card-picker-wrap { height: 250rpx; }
|
||||||
width: 100%;
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 32rpx 32rpx 0 0;
|
|
||||||
padding: 32rpx 32rpx calc(32rpx + env(safe-area-inset-bottom));
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.popup-header {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 32rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.popup-title {
|
|
||||||
font-size: 34rpx;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #1a1a1a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.close-btn {
|
|
||||||
width: 56rpx;
|
|
||||||
height: 56rpx;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: #f5f5f5;
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.close-icon {
|
|
||||||
font-size: 24rpx;
|
|
||||||
color: #999;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Info rows */
|
|
||||||
.info-section {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 20rpx;
|
|
||||||
margin-bottom: 28rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-row {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-label {
|
|
||||||
font-size: 28rpx;
|
|
||||||
color: #999;
|
|
||||||
width: 80rpx;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-value {
|
|
||||||
font-size: 28rpx;
|
|
||||||
color: #222;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.divider {
|
|
||||||
height: 1rpx;
|
|
||||||
background: #f0f0f0;
|
|
||||||
margin: 8rpx 0 28rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Card selection */
|
|
||||||
.card-section {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16rpx;
|
|
||||||
margin-bottom: 24rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-label-row {
|
|
||||||
margin-bottom: 8rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-label {
|
|
||||||
font-size: 28rpx;
|
|
||||||
color: #666;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-single,
|
|
||||||
.card-picker-wrap {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-item {
|
.card-item {
|
||||||
display: flex;
|
display: flex; align-items: center; padding: 22rpx 24rpx; border-radius: 24rpx;
|
||||||
flex-direction: row;
|
border: 2rpx solid #eee8e3; background: #fff; gap: 20rpx; box-sizing: border-box;
|
||||||
align-items: center;
|
&.selected { border-color: #a5b8ab; background: #f0f4ee; }
|
||||||
padding: 24rpx 20rpx;
|
|
||||||
border-radius: 16rpx;
|
|
||||||
border: 2rpx solid #f0f0f0;
|
|
||||||
background: #fafafa;
|
|
||||||
gap: 20rpx;
|
|
||||||
transition: border-color 0.15s, background 0.15s;
|
|
||||||
|
|
||||||
&.selected {
|
|
||||||
border-color: #c9a87c;
|
|
||||||
background: #fffbf5;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
.card-picker-wrap .card-item { margin-bottom: 12rpx; }
|
||||||
.card-icon-wrap {
|
.card-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8rpx; }
|
||||||
width: 60rpx;
|
.card-name { font-size: 26rpx; font-weight: 400; color: #514943; overflow-wrap: anywhere; }
|
||||||
height: 60rpx;
|
.card-remain { font-size: 22rpx; color: #8b817b; }
|
||||||
border-radius: 14rpx;
|
.check-mark { width: 36rpx; height: 36rpx; border-radius: 50%; background: #6b8276; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
||||||
background: linear-gradient(135deg, #d4b896, #c9a87c);
|
.check-icon { font-size: 22rpx; color: #fff; }
|
||||||
display: flex;
|
.no-card-tip { padding: 24rpx; text-align: center; }
|
||||||
align-items: center;
|
.no-card-text { font-size: 24rpx; color: #8b817b; }
|
||||||
justify-content: center;
|
.deduction-tip { padding: 18rpx 4rpx; }
|
||||||
flex-shrink: 0;
|
.deduction-text { font-size: 22rpx; color: #8b817b; line-height: 1.6; }
|
||||||
}
|
.action-row { display: flex; gap: 20rpx; margin-top: 12rpx; }
|
||||||
|
.btn-outline { flex: 1; height: 88rpx; border-radius: 999rpx; background: #f0eae4; display: flex; align-items: center; justify-content: center; }
|
||||||
.card-icon {
|
.btn-outline-text { font-size: 28rpx; color: #78675c; font-weight: 400; }
|
||||||
font-size: 32rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-info {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-name {
|
|
||||||
font-size: 28rpx;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #222;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-remain {
|
|
||||||
font-size: 22rpx;
|
|
||||||
color: #999;
|
|
||||||
}
|
|
||||||
|
|
||||||
.check-mark {
|
|
||||||
width: 44rpx;
|
|
||||||
height: 44rpx;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: #c9a87c;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.check-icon {
|
|
||||||
font-size: 24rpx;
|
|
||||||
color: #fff;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.no-card-tip {
|
|
||||||
padding: 24rpx;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.no-card-text {
|
|
||||||
font-size: 26rpx;
|
|
||||||
color: #bbb;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Deduction tip */
|
|
||||||
.deduction-tip {
|
|
||||||
background: #fffbf0;
|
|
||||||
border-radius: 12rpx;
|
|
||||||
padding: 16rpx 20rpx;
|
|
||||||
margin-bottom: 28rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.deduction-text {
|
|
||||||
font-size: 24rpx;
|
|
||||||
color: #c9a87c;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Action buttons */
|
|
||||||
.action-row {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
gap: 20rpx;
|
|
||||||
margin-top: 8rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-outline {
|
|
||||||
flex: 1;
|
|
||||||
height: 88rpx;
|
|
||||||
border-radius: 44rpx;
|
|
||||||
border: 2rpx solid #e0e0e0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
background: #f5f5f5;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-outline-text {
|
|
||||||
font-size: 30rpx;
|
|
||||||
color: #666;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-confirm {
|
.btn-confirm {
|
||||||
flex: 2;
|
flex: 2; height: 88rpx; padding: 0; margin: 0; border: none; line-height: 1;
|
||||||
height: 88rpx;
|
border-radius: 999rpx; background: #6b8276;
|
||||||
border-radius: 44rpx;
|
display: flex; align-items: center; justify-content: center;
|
||||||
background: #c9a87c;
|
&::after { border: none; }
|
||||||
display: flex;
|
&:active { background: #597264; }
|
||||||
align-items: center;
|
&.disabled { background: #d9dfd8; }
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
opacity: 0.85;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.disabled {
|
|
||||||
background: #e0e0e0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-confirm-text {
|
|
||||||
font-size: 30rpx;
|
|
||||||
color: #fff;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 1rpx;
|
|
||||||
|
|
||||||
.disabled & {
|
|
||||||
color: #bbb;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
.btn-confirm-text { font-size: 28rpx; color: #fff; font-weight: 500; .disabled & { color: #677467; } }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,51 +1,49 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="brand-banner">
|
<view class="brand-banner">
|
||||||
<!-- Background image layer -->
|
<!-- Background image layer with blur -->
|
||||||
<image
|
<image
|
||||||
v-if="studioInfo?.bannerUrl"
|
|
||||||
class="banner-bg"
|
class="banner-bg"
|
||||||
:src="studioInfo.bannerUrl"
|
:src="bannerImage"
|
||||||
mode="aspectFill"
|
mode="aspectFill"
|
||||||
/>
|
/>
|
||||||
<!-- Dark overlay for readability -->
|
<!-- Dark overlay for readability -->
|
||||||
<view class="banner-overlay" />
|
<view class="banner-overlay" />
|
||||||
|
|
||||||
<!-- Status bar spacer -->
|
|
||||||
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }" />
|
|
||||||
|
|
||||||
<!-- Centered content -->
|
<!-- Centered content -->
|
||||||
<view class="banner-content">
|
<view class="banner-content">
|
||||||
<!-- Circular logo -->
|
<!-- Circular logo -->
|
||||||
<view class="logo-circle">
|
<view class="logo-circle">
|
||||||
<image
|
<image
|
||||||
v-if="studioInfo?.logo"
|
v-if="logoImage"
|
||||||
class="logo-img"
|
class="logo-img"
|
||||||
:src="studioInfo.logo"
|
:src="logoImage"
|
||||||
mode="aspectFit"
|
mode="aspectFill"
|
||||||
/>
|
/>
|
||||||
<text v-else class="logo-placeholder">FC</text>
|
<view v-else class="logo-placeholder">
|
||||||
|
<text>{{ studioName.slice(0, 1) || 'F' }}</text>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Studio name -->
|
<!-- Studio name -->
|
||||||
<text class="studio-name">{{ studioInfo?.name || 'Focus Core' }}</text>
|
<text class="studio-name">{{ studioName }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue'
|
import { computed } from 'vue'
|
||||||
import type { StudioConfig } from '@mp-pilates/shared'
|
import type { StudioConfig } from '@mp-pilates/shared'
|
||||||
|
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
studioInfo: StudioConfig | null
|
studioInfo: StudioConfig | null
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const statusBarHeight = ref(0)
|
const fallbackBannerImage = 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/mp/images/bannerBg.jpg'
|
||||||
|
const fallbackLogoImage = 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/mp/images/logo.jpg'
|
||||||
|
|
||||||
onMounted(() => {
|
const bannerImage = computed(() => props.studioInfo?.bannerUrl || fallbackBannerImage)
|
||||||
const sysInfo = uni.getSystemInfoSync()
|
const logoImage = computed(() => props.studioInfo?.logo || fallbackLogoImage)
|
||||||
statusBarHeight.value = sysInfo.statusBarHeight ?? 20
|
const studioName = computed(() => props.studioInfo?.name || 'Focus Core')
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@@ -54,7 +52,7 @@ onMounted(() => {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 580rpx;
|
height: 580rpx;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #2a2a2a;
|
background: linear-gradient(160deg, #E1F4FA 0%, $primary-color 50%, $primary-dark 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.banner-bg {
|
.banner-bg {
|
||||||
@@ -63,6 +61,8 @@ onMounted(() => {
|
|||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
filter: blur(2px);
|
||||||
|
transform: scale(1.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.banner-overlay {
|
.banner-overlay {
|
||||||
@@ -71,12 +71,7 @@ onMounted(() => {
|
|||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: rgba(0, 0, 0, 0.3);
|
background: rgba($primary-dark, 0.25);
|
||||||
}
|
|
||||||
|
|
||||||
.status-bar {
|
|
||||||
position: relative;
|
|
||||||
z-index: 2;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.banner-content {
|
.banner-content {
|
||||||
@@ -86,7 +81,7 @@ onMounted(() => {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding-top: 40rpx;
|
padding-top: 120rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo-circle {
|
.logo-circle {
|
||||||
@@ -102,15 +97,22 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.logo-img {
|
.logo-img {
|
||||||
width: 160rpx;
|
width: 200rpx;
|
||||||
height: 160rpx;
|
height: 200rpx;
|
||||||
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo-placeholder {
|
.logo-placeholder {
|
||||||
|
width: 200rpx;
|
||||||
|
height: 200rpx;
|
||||||
|
border-radius: 50%;
|
||||||
font-size: 64rpx;
|
font-size: 64rpx;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
color: #333;
|
color: #333;
|
||||||
letter-spacing: 4rpx;
|
letter-spacing: 4rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.studio-name {
|
.studio-name {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<!-- Section header -->
|
<!-- Section header -->
|
||||||
<view class="section-header">
|
<view class="section-header">
|
||||||
<text class="section-title">会员卡</text>
|
<text class="section-title">会员卡</text>
|
||||||
<text class="section-action" @tap="goToAllCards">全部</text>
|
<text class="section-action" @tap="goToAllCards">全部 ›</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Loading skeleton -->
|
<!-- Loading skeleton -->
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
:key="i"
|
:key="i"
|
||||||
class="card-row skeleton-row"
|
class="card-row skeleton-row"
|
||||||
>
|
>
|
||||||
<view class="skeleton-thumb" />
|
<view class="skeleton-card-cover" />
|
||||||
<view class="skeleton-info">
|
<view class="skeleton-info">
|
||||||
<view class="skeleton-line skeleton-line--title" />
|
<view class="skeleton-line skeleton-line--title" />
|
||||||
<view class="skeleton-line skeleton-line--sub" />
|
<view class="skeleton-line skeleton-line--sub" />
|
||||||
@@ -30,31 +30,48 @@
|
|||||||
class="card-row"
|
class="card-row"
|
||||||
@tap="goToDetail(card.id)"
|
@tap="goToDetail(card.id)"
|
||||||
>
|
>
|
||||||
<!-- Thumbnail -->
|
<!-- Card Cover — image if available, gradient fallback -->
|
||||||
<view class="card-thumb" :class="thumbClass(card)">
|
<view class="card-cover" :class="card.coverUrl ? '' : getCardCoverClass(card.type)">
|
||||||
<view class="thumb-fallback">
|
<image
|
||||||
<text class="thumb-name">{{ truncate(card.name, 8) }}</text>
|
v-if="card.coverUrl"
|
||||||
<text class="thumb-price">¥{{ formatPrice(card.price) }}</text>
|
class="card-cover-img"
|
||||||
</view>
|
:src="card.coverUrl"
|
||||||
|
mode="aspectFill"
|
||||||
|
/>
|
||||||
|
<template v-else>
|
||||||
|
<text class="cover-label">{{ card.type === CardTypeCategory.TRIAL ? '体验' : card.type === CardTypeCategory.DURATION ? '期限卡' : '次卡' }}</text>
|
||||||
|
</template>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Card info -->
|
<!-- Card info — aligns with card-cover height -->
|
||||||
<view class="card-info">
|
<view class="card-info">
|
||||||
|
<view class="info-top">
|
||||||
|
<view class="card-name-row">
|
||||||
<text class="card-name">{{ card.name }}</text>
|
<text class="card-name">{{ card.name }}</text>
|
||||||
<text class="card-validity">有效期:{{ card.durationDays }} 天</text>
|
<text v-if="isRenewable(card)" class="renew-tag">续</text>
|
||||||
|
</view>
|
||||||
|
<text class="card-validity">有效期 {{ card.durationDays }} 天</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-bottom">
|
||||||
|
<view v-if="card.totalTimes" class="card-times">
|
||||||
|
<text class="card-times-value">{{ card.totalTimes }}</text>
|
||||||
|
<text class="card-times-unit">课时</text>
|
||||||
|
</view>
|
||||||
<view class="price-row">
|
<view class="price-row">
|
||||||
<text class="price-label">价格:</text>
|
<text class="price-current">¥{{ formatPrice(card.price) }}</text>
|
||||||
<text class="price-symbol">¥</text>
|
|
||||||
<text class="price-current">{{ formatPrice(card.price) }}</text>
|
|
||||||
<text
|
<text
|
||||||
v-if="card.originalPrice && card.originalPrice > card.price"
|
v-if="card.originalPrice && card.originalPrice > card.price"
|
||||||
class="price-original"
|
class="price-original"
|
||||||
>
|
>
|
||||||
原价:¥{{ formatPrice(card.originalPrice) }}
|
¥{{ formatPrice(card.originalPrice) }}
|
||||||
</text>
|
</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<!-- Arrow -->
|
||||||
|
<text class="card-arrow">›</text>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Empty -->
|
<!-- Empty -->
|
||||||
@@ -65,24 +82,49 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { computed, ref, onMounted } from 'vue'
|
||||||
import type { CardType } from '@mp-pilates/shared'
|
import type { CardType } from '@mp-pilates/shared'
|
||||||
import { CardTypeCategory } from '@mp-pilates/shared'
|
import { CardTypeCategory } from '@mp-pilates/shared'
|
||||||
import { get } from '../utils/request'
|
import { get } from '../utils/request'
|
||||||
import { formatPrice } from '../utils/format'
|
import { formatPrice, getCardCoverClass } from '../utils/format'
|
||||||
|
import { useUserStore } from '../stores/user'
|
||||||
|
|
||||||
|
const userStore = useUserStore()
|
||||||
const cardTypes = ref<CardType[]>([])
|
const cardTypes = ref<CardType[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
const hasLoaded = ref(false)
|
||||||
|
|
||||||
|
const renewableCardTypeIds = computed(() => {
|
||||||
|
const ids = new Set<string>()
|
||||||
|
for (const membership of userStore.memberships) {
|
||||||
|
if (membership.cardType.type !== CardTypeCategory.TRIAL) {
|
||||||
|
ids.add(membership.cardTypeId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
})
|
||||||
|
|
||||||
|
function isRenewable(card: CardType): boolean {
|
||||||
|
return card.type !== CardTypeCategory.TRIAL && renewableCardTypeIds.value.has(card.id)
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchCardTypes() {
|
async function fetchCardTypes() {
|
||||||
|
// Stale-While-Revalidate: only show skeleton on first load
|
||||||
|
// Subsequent refreshes silently update data in background
|
||||||
|
if (!hasLoaded.value) {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const result = await get<CardType[]>('/membership/card-types')
|
const result = await get<CardType[]>('/membership/card-types')
|
||||||
cardTypes.value = result
|
cardTypes.value = result
|
||||||
.filter((c) => c.isActive)
|
.filter((c) => c.isActive)
|
||||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||||
|
hasLoaded.value = true
|
||||||
} catch {
|
} catch {
|
||||||
|
// Only show error toast on first load; silent fail on background refresh
|
||||||
|
if (!hasLoaded.value) {
|
||||||
uni.showToast({ title: '加载会员卡失败', icon: 'none' })
|
uni.showToast({ title: '加载会员卡失败', icon: 'none' })
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@@ -98,232 +140,41 @@ function goToDetail(id: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function goToAllCards() {
|
function goToAllCards() {
|
||||||
// Navigate to all cards page or scroll behavior
|
|
||||||
uni.navigateTo({ url: '/pages/card/detail?showAll=1' })
|
uni.navigateTo({ url: '/pages/card/detail?showAll=1' })
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
|
||||||
return str.length > maxLen ? str.slice(0, maxLen) + '…' : str
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.card-shop {
|
.card-shop { margin: 36rpx 32rpx 0; }
|
||||||
background: #ffffff;
|
.section-header { display: flex; align-items: center; justify-content: space-between; gap: 16rpx; margin-bottom: 20rpx; }
|
||||||
margin-top: 16rpx;
|
.section-title { font-size: 30rpx; font-weight: 500; color: #514943; }
|
||||||
padding-bottom: 20rpx;
|
.section-action { font-size: 23rpx; color: #8b817b; padding: 8rpx 0; }
|
||||||
}
|
.card-list { padding: 0 24rpx; border-radius: 28rpx; background: #fff; border: 1rpx solid #eee8e3; }
|
||||||
|
.card-row { display: flex; align-items: center; gap: 20rpx; padding: 26rpx 0; border-bottom: 1rpx solid #f0ebe6; &:last-child { border: none; } }
|
||||||
/* ── Section header ── */
|
.card-cover { width: 132rpx; height: 148rpx; border-radius: 16rpx; overflow: hidden; flex-shrink: 0; display: flex; align-items: center; justify-content: center; background: #f0e6de; }
|
||||||
.section-header {
|
.card-cover-img { width: 100%; height: 100%; }
|
||||||
display: flex;
|
.cover--times { background: #f0e6de; }
|
||||||
align-items: center;
|
.cover--duration { background: #e9ede5; }
|
||||||
justify-content: space-between;
|
.cover--trial { background: #f2e4e0; }
|
||||||
padding: 32rpx 32rpx 20rpx;
|
.cover-label { font-family: 'Songti SC', 'STSong', serif; font-size: 28rpx; color: #857062; letter-spacing: 3rpx; }
|
||||||
}
|
.card-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 14rpx; }
|
||||||
|
.info-top { display: flex; flex-direction: column; gap: 8rpx; }
|
||||||
.section-title {
|
.card-name-row { display: flex; align-items: baseline; gap: 8rpx; }
|
||||||
font-size: 36rpx;
|
.card-name { min-width: 0; font-size: 27rpx; font-weight: 400; color: #514943; line-height: 1.5; overflow-wrap: anywhere; }
|
||||||
font-weight: 700;
|
.renew-tag { flex-shrink: 0; font-size: 18rpx; color: #617d73; background: #edf2ec; padding: 3rpx 8rpx; border-radius: 6rpx; }
|
||||||
color: #222;
|
.card-validity { font-size: 21rpx; color: #8b817b; }
|
||||||
}
|
.info-bottom { display: flex; align-items: baseline; flex-wrap: wrap; gap: 8rpx 16rpx; }
|
||||||
|
.card-times { display: flex; align-items: baseline; gap: 4rpx; }
|
||||||
.section-action {
|
.card-times-value { font-size: 24rpx; color: #6f655e; }
|
||||||
font-size: 26rpx;
|
.card-times-unit { font-size: 20rpx; color: #8b817b; }
|
||||||
color: #999;
|
.price-row { display: flex; align-items: baseline; flex-wrap: wrap; gap: 8rpx; }
|
||||||
padding: 8rpx 24rpx;
|
.price-current { font-size: 30rpx; font-weight: 500; color: #8b6c5b; font-variant-numeric: tabular-nums; }
|
||||||
border: 1rpx solid #ddd;
|
.price-original { font-size: 20rpx; color: #a59b93; text-decoration: line-through; }
|
||||||
border-radius: 24rpx;
|
.card-arrow { font-size: 32rpx; color: #b2a79e; flex-shrink: 0; }
|
||||||
}
|
.skeleton-card-cover { width: 132rpx; height: 148rpx; border-radius: 16rpx; flex-shrink: 0; }
|
||||||
|
.skeleton-info { flex: 1; display: flex; flex-direction: column; gap: 16rpx; }
|
||||||
/* ── Card list ── */
|
.skeleton-line { height: 24rpx; width: 70%; border-radius: 8rpx; &--sub { width: 50%; } }
|
||||||
.card-list {
|
.skeleton-line, .skeleton-card-cover { background: linear-gradient(90deg, #f0eae5 25%, #faf7f3 50%, #f0eae5 75%); background-size: 400% 100%; animation: shimmer 1.4s infinite; }
|
||||||
padding: 0 32rpx;
|
.empty-state { padding: 48rpx 24rpx; border-radius: 24rpx; background: #f3efea; text-align: center; }
|
||||||
}
|
.empty-text { font-size: 24rpx; color: #8b817b; }
|
||||||
|
|
||||||
.card-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 24rpx;
|
|
||||||
padding: 24rpx 0;
|
|
||||||
border-bottom: 1rpx solid #f0f0f0;
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── 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;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 8rpx;
|
|
||||||
padding: 12rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Empty state ── */
|
|
||||||
.empty-state {
|
|
||||||
padding: 80rpx;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-text {
|
|
||||||
font-size: 28rpx;
|
|
||||||
color: #bbb;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
119
packages/app/src/components/CustomNavBar.vue
Normal file
119
packages/app/src/components/CustomNavBar.vue
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
<template>
|
||||||
|
<view
|
||||||
|
class="nav-bar"
|
||||||
|
:class="{ 'nav-bar--transparent': transparent }"
|
||||||
|
:style="{ paddingTop: statusBarHeight + 'px' }"
|
||||||
|
>
|
||||||
|
<view class="nav-bar__inner">
|
||||||
|
<!-- Back button -->
|
||||||
|
<view v-if="showBack" class="nav-bar__left" @tap="handleBack">
|
||||||
|
<text class="nav-bar__back-icon">‹</text>
|
||||||
|
</view>
|
||||||
|
<view v-else class="nav-bar__left" />
|
||||||
|
|
||||||
|
<!-- Title -->
|
||||||
|
<text class="nav-bar__title">{{ title }}</text>
|
||||||
|
|
||||||
|
<!-- Right placeholder (balances the back button) -->
|
||||||
|
<view class="nav-bar__right" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
title: string
|
||||||
|
/** Transparent bg with white text — for pages with colored header */
|
||||||
|
transparent?: boolean
|
||||||
|
/** Show back arrow (for sub-pages navigated via navigateTo) */
|
||||||
|
showBack?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const statusBarHeight = ref(0)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const windowInfo = uni.getWindowInfo()
|
||||||
|
statusBarHeight.value = windowInfo.statusBarHeight ?? 20
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleBack() {
|
||||||
|
const pages = getCurrentPages()
|
||||||
|
|
||||||
|
if (pages.length > 1) {
|
||||||
|
uni.navigateBack({ delta: 1 })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
uni.switchTab({ url: '/pages/home/index' })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.nav-bar {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 101;
|
||||||
|
background: #ffffff;
|
||||||
|
|
||||||
|
&--transparent {
|
||||||
|
background: transparent;
|
||||||
|
|
||||||
|
.nav-bar__title {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-bar__back-icon {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__inner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: 88rpx;
|
||||||
|
padding: 0 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__left,
|
||||||
|
&__right {
|
||||||
|
width: 72rpx;
|
||||||
|
height: 88rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__left {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__right {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__back-icon {
|
||||||
|
font-size: 52rpx;
|
||||||
|
font-weight: 300;
|
||||||
|
color: #1a1a2e;
|
||||||
|
line-height: 1;
|
||||||
|
margin-top: -4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a2e;
|
||||||
|
letter-spacing: 2rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,17 +1,20 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="date-selector">
|
<view class="date-selector" :class="`date-selector--${variant}`">
|
||||||
<scroll-view class="scroll" scroll-x enhanced :show-scrollbar="false">
|
<scroll-view class="scroll" scroll-x enhanced :show-scrollbar="false">
|
||||||
<view class="track">
|
<view class="track">
|
||||||
<view
|
<view
|
||||||
v-for="item in dateRange"
|
v-for="item in dateRange"
|
||||||
:key="item.date"
|
:key="item.date"
|
||||||
class="date-item"
|
class="date-item"
|
||||||
:class="{ active: item.date === modelValue, today: item.isToday }"
|
:class="[
|
||||||
|
`date-item--${variant}`,
|
||||||
|
{ active: item.date === modelValue, today: item.isToday },
|
||||||
|
]"
|
||||||
@tap="handleSelect(item.date)"
|
@tap="handleSelect(item.date)"
|
||||||
>
|
>
|
||||||
<text class="weekday">{{ item.isToday ? '今天' : item.weekday }}</text>
|
<text class="weekday">{{ item.isToday ? '今天' : item.weekday }}</text>
|
||||||
<text class="day">{{ getDayNumber(item.date) }}</text>
|
<text class="day">{{ getDayNumber(item.date) }}</text>
|
||||||
<text class="month">{{ getMonthNumber(item.date) }}月</text>
|
<text v-if="variant !== 'soft' || getDayNumber(item.date) === '1'" class="month">{{ getMonthNumber(item.date) }}月</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</scroll-view>
|
</scroll-view>
|
||||||
@@ -25,6 +28,7 @@ import { getDateRange } from '../utils/format'
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
modelValue: string
|
modelValue: string
|
||||||
|
variant?: 'default' | 'booking' | 'soft'
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
@@ -47,13 +51,20 @@ function handleSelect(date: string) {
|
|||||||
emit('update:modelValue', date)
|
emit('update:modelValue', date)
|
||||||
emit('select', date)
|
emit('select', date)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const variant = computed(() => props.variant ?? 'default')
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.date-selector {
|
.date-selector {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
padding: 16rpx 0 20rpx;
|
padding: 16rpx 0 20rpx;
|
||||||
border-bottom: 1rpx solid #f0ece8;
|
border-bottom: 1rpx solid $primary-border;
|
||||||
|
|
||||||
|
&.date-selector--booking {
|
||||||
|
background: rgba(252, 250, 248, 0.96);
|
||||||
|
border-bottom-color: rgba(192, 154, 137, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
.scroll {
|
.scroll {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -75,7 +86,7 @@ function handleSelect(date: string) {
|
|||||||
min-width: 88rpx;
|
min-width: 88rpx;
|
||||||
padding: 16rpx 12rpx;
|
padding: 16rpx 12rpx;
|
||||||
border-radius: 16rpx;
|
border-radius: 16rpx;
|
||||||
background: #f7f4f0;
|
background: $primary-bg;
|
||||||
gap: 4rpx;
|
gap: 4rpx;
|
||||||
transition: background 0.2s;
|
transition: background 0.2s;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -100,7 +111,7 @@ function handleSelect(date: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
background: #c9a87c;
|
background: $primary-color;
|
||||||
|
|
||||||
.weekday {
|
.weekday {
|
||||||
color: rgba(255, 255, 255, 0.85);
|
color: rgba(255, 255, 255, 0.85);
|
||||||
@@ -117,10 +128,64 @@ function handleSelect(date: string) {
|
|||||||
|
|
||||||
&.today:not(.active) {
|
&.today:not(.active) {
|
||||||
.weekday {
|
.weekday {
|
||||||
color: #c9a87c;
|
color: $primary-dark;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.date-item--booking {
|
||||||
|
background: rgba(247, 242, 238, 0.88);
|
||||||
|
border: 1rpx solid rgba(192, 154, 137, 0.08);
|
||||||
|
|
||||||
|
.weekday {
|
||||||
|
color: #9d8b83;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day {
|
||||||
|
color: #3a2e2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.month {
|
||||||
|
color: #b7a79f;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: linear-gradient(135deg, #d7beb1, #b98f7d);
|
||||||
|
box-shadow: 0 12rpx 28rpx rgba(143, 103, 89, 0.16);
|
||||||
|
|
||||||
|
.weekday,
|
||||||
|
.day,
|
||||||
|
.month {
|
||||||
|
color: #fffaf7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.today:not(.active) {
|
||||||
|
.weekday {
|
||||||
|
color: #8f6759;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-selector.date-selector--soft {
|
||||||
|
padding: 8rpx 0 24rpx;
|
||||||
|
background: #fbf9f6;
|
||||||
|
border-bottom: none;
|
||||||
|
.track { padding: 0 32rpx; gap: 12rpx; }
|
||||||
|
.date-item {
|
||||||
|
min-width: 0; width: 86rpx; height: 116rpx; padding: 0;
|
||||||
|
box-sizing: border-box; border-radius: 24rpx;
|
||||||
|
background: transparent; gap: 12rpx;
|
||||||
|
.weekday { font-size: 22rpx; color: #8b817b; }
|
||||||
|
.day { font-size: 34rpx; font-weight: 400; color: #514943; }
|
||||||
|
&.active {
|
||||||
|
background: #eee3dc;
|
||||||
|
.weekday, .day { color: #705b4f; }
|
||||||
|
.day { font-weight: 500; }
|
||||||
|
}
|
||||||
|
&.today:not(.active) .weekday { color: #617d73; font-weight: 400; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
182
packages/app/src/components/FlashSaleSection.vue
Normal file
182
packages/app/src/components/FlashSaleSection.vue
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
<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(sale.flashPrice) }}</text>
|
||||||
|
</view>
|
||||||
|
<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 { 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>
|
||||||
40
packages/app/src/components/LessonSupplementList.vue
Normal file
40
packages/app/src/components/LessonSupplementList.vue
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<template>
|
||||||
|
<view class="supplements">
|
||||||
|
<view v-for="item in records" :key="item.id" class="record" :class="{ 'record--revoked': item.revokedAt }">
|
||||||
|
<view class="record-head">
|
||||||
|
<view class="record-title"><text>系统补录</text><text class="record-sub">{{ item.revokedAt ? '已撤销' : '历史课程' }}</text></view>
|
||||||
|
<text class="record-quantity">{{ item.quantity }}<text class="record-unit"> 节</text></text>
|
||||||
|
</view>
|
||||||
|
<text class="record-date">{{ formatDateTimeFull(item.createdAt) }} 补录</text>
|
||||||
|
<text class="record-note">{{ item.deductedTimes ? `${item.cardName} · ${item.revokedAt ? '已返还' : '扣除'} ${item.deductedTimes} 次` : '仅补记录 · 未扣会员卡' }}</text>
|
||||||
|
<text v-if="item.remark" class="record-note">{{ item.remark }}</text>
|
||||||
|
<view v-if="editable" class="record-footer">
|
||||||
|
<text class="record-operator">由 {{ item.operatorName }} 补录</text>
|
||||||
|
<button v-if="!item.revokedAt" class="revoke" :disabled="busy" @tap="emit('revoke', item)">撤销补录</button>
|
||||||
|
<text v-else class="record-operator">{{ formatDateTimeFull(item.revokedAt) }} 撤销</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { LessonSupplementRecord } from '@mp-pilates/shared'
|
||||||
|
import { formatDateTimeFull } from '../utils/format'
|
||||||
|
defineProps<{ records: readonly LessonSupplementRecord[]; editable?: boolean; busy?: boolean }>()
|
||||||
|
const emit = defineEmits<{ revoke: [record: LessonSupplementRecord] }>()
|
||||||
|
</script>
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.supplements { display: flex; flex-direction: column; }
|
||||||
|
.record { padding: 24rpx 0; border-bottom: 1rpx solid #eee8e3; color: #514943; }
|
||||||
|
.record:last-child { border-bottom: none; }
|
||||||
|
.record--revoked { color: #8b817b; }
|
||||||
|
.record-head { display: flex; align-items: center; justify-content: space-between; gap: 20rpx; }
|
||||||
|
.record-title { display: flex; flex-direction: column; gap: 8rpx; font-size: 28rpx; }
|
||||||
|
.record-sub { font-size: 20rpx; color: #8b817b; }
|
||||||
|
.record-quantity { flex-shrink: 0; font-size: 42rpx; font-variant-numeric: tabular-nums; color: #617d73; }
|
||||||
|
.record-unit { font-size: 22rpx; }
|
||||||
|
.record-date { display: block; margin-top: 20rpx; color: #8b817b; font-size: 21rpx; }
|
||||||
|
.record-note { display: block; margin-top: 12rpx; font-size: 23rpx; color: #786b61; line-height: 1.6; overflow-wrap: anywhere; }
|
||||||
|
.record-footer { margin-top: 20rpx; padding-top: 16rpx; border-top: 1rpx solid #eee8e3; display: flex; align-items: center; justify-content: space-between; gap: 16rpx; flex-wrap: wrap; }
|
||||||
|
.record-operator { color: #8b817b; font-size: 21rpx; }
|
||||||
|
.revoke { margin: 0; padding: 8rpx 12rpx; font-size: 22rpx; line-height: 1.5; background: transparent; color: #967561; &::after { border: none; } }
|
||||||
|
</style>
|
||||||
78
packages/app/src/components/MonthDivider.vue
Normal file
78
packages/app/src/components/MonthDivider.vue
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
<template>
|
||||||
|
<view class="month-divider">
|
||||||
|
<!-- 左侧细线 -->
|
||||||
|
<view class="month-divider__line" />
|
||||||
|
|
||||||
|
<!-- 月份主体 -->
|
||||||
|
<view class="month-divider__body">
|
||||||
|
<!-- 月份装饰:一个小圆点 + 月份名 -->
|
||||||
|
<view class="month-divider__dot" />
|
||||||
|
<text class="month-divider__label">{{ label }}</text>
|
||||||
|
<view class="month-divider__dot" />
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 右侧细线 -->
|
||||||
|
<view class="month-divider__line" />
|
||||||
|
|
||||||
|
<!-- 副信息:节气或描述 -->
|
||||||
|
<text v-if="hint" class="month-divider__hint">{{ hint }}</text>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
/** 主标签,例如 "十月" */
|
||||||
|
label: string
|
||||||
|
/** 副信息,例如 "共 3 节" */
|
||||||
|
hint?: string
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.month-divider {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16rpx;
|
||||||
|
margin: 48rpx 32rpx 24rpx;
|
||||||
|
|
||||||
|
&__line {
|
||||||
|
flex: 1;
|
||||||
|
height: 1rpx;
|
||||||
|
background: linear-gradient(
|
||||||
|
to right,
|
||||||
|
transparent 0%,
|
||||||
|
rgba(155, 138, 117, 0.25) 30%,
|
||||||
|
rgba(155, 138, 117, 0.45) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__body {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14rpx;
|
||||||
|
padding: 0 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__dot {
|
||||||
|
width: 6rpx;
|
||||||
|
height: 6rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #b09a83;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
font-family: "Songti SC", "STSong", "Noto Serif SC", "Source Han Serif SC", serif;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #6f645a;
|
||||||
|
letter-spacing: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__hint {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #a89d92;
|
||||||
|
letter-spacing: 1rpx;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
382
packages/app/src/components/NextSessionHero.vue
Normal file
382
packages/app/src/components/NextSessionHero.vue
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
<template>
|
||||||
|
<view class="hero" :class="`hero--${tone}`" @tap="handleTap">
|
||||||
|
<!-- 背景纹理层 -->
|
||||||
|
<view class="hero__grain" />
|
||||||
|
|
||||||
|
<!-- 装饰:右上角的弧线(暗示月相) -->
|
||||||
|
<view class="hero__moon" />
|
||||||
|
|
||||||
|
<view class="hero__main">
|
||||||
|
<!-- 左侧:大日期块 -->
|
||||||
|
<view class="hero__date">
|
||||||
|
<text class="hero__day">{{ dayNumber }}</text>
|
||||||
|
<text class="hero__month">{{ monthLabel }}</text>
|
||||||
|
<text class="hero__weekday">{{ weekdayLabel }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 分隔:虚线 -->
|
||||||
|
<view class="hero__divider">
|
||||||
|
<view v-for="i in 6" :key="i" class="hero__divider-dot" />
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 右侧:课程信息 -->
|
||||||
|
<view class="hero__info">
|
||||||
|
<view class="hero__time-row">
|
||||||
|
<text class="hero__time">{{ startTime }}</text>
|
||||||
|
<text class="hero__time-end">— {{ endTime }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<text class="hero__membership">{{ cardName }}</text>
|
||||||
|
|
||||||
|
<view class="hero__status">
|
||||||
|
<view class="hero__dot" />
|
||||||
|
<text class="hero__status-text">{{ statusLabel }}</text>
|
||||||
|
<text v-if="countdownText" class="hero__countdown">· {{ countdownText }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 底部:诗意一句 -->
|
||||||
|
<view class="hero__footer">
|
||||||
|
<text class="hero__poem">{{ poem }}</text>
|
||||||
|
<view class="hero__cta">
|
||||||
|
<text class="hero__cta-text">查看详情</text>
|
||||||
|
<text class="hero__cta-arrow">→</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { BookingWithDetails } from '@mp-pilates/shared'
|
||||||
|
import { BookingStatus } from '@mp-pilates/shared'
|
||||||
|
import {
|
||||||
|
bookingStatusLabel,
|
||||||
|
bookingStatusBannerClass,
|
||||||
|
} from '../utils/booking-helpers'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
booking: BookingWithDetails
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
tap: [booking: BookingWithDetails]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const months = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十', '十一', '十二']
|
||||||
|
const weekdays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
|
||||||
|
|
||||||
|
function parseDate(dateStr: string): Date {
|
||||||
|
const normalized = dateStr.slice(0, 10)
|
||||||
|
const [y, m, d] = normalized.split('-').map(Number)
|
||||||
|
return new Date(y, m - 1, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = computed(() => parseDate(props.booking.timeSlot.date))
|
||||||
|
|
||||||
|
const dayNumber = computed(() => String(date.value.getDate()).padStart(2, '0'))
|
||||||
|
const monthLabel = computed(() => `${date.value.getMonth() + 1}月`)
|
||||||
|
const weekdayLabel = computed(() => weekdays[date.value.getDay()])
|
||||||
|
|
||||||
|
const startTime = computed(() => props.booking.timeSlot.startTime.slice(0, 5))
|
||||||
|
const endTime = computed(() => props.booking.timeSlot.endTime.slice(0, 5))
|
||||||
|
const cardName = computed(() => props.booking.membership?.cardType?.name || '会员卡')
|
||||||
|
|
||||||
|
const statusLabel = computed(() => bookingStatusLabel(props.booking.status))
|
||||||
|
const tone = computed(() => bookingStatusBannerClass(props.booking.status))
|
||||||
|
|
||||||
|
const today = new Date()
|
||||||
|
const todayStr = computed(() => {
|
||||||
|
const d = today
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const tomorrow = new Date(today.getTime() + 86400000)
|
||||||
|
const tomorrowStr = computed(() => {
|
||||||
|
const d = tomorrow
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const dateKey = computed(() => {
|
||||||
|
const d = date.value
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const relativeLabel = computed(() => {
|
||||||
|
if (dateKey.value === todayStr.value) return '今天'
|
||||||
|
if (dateKey.value === tomorrowStr.value) return '明天'
|
||||||
|
return `${months[date.value.getMonth()]}${date.value.getDate()}日`
|
||||||
|
})
|
||||||
|
|
||||||
|
const countdownText = computed(() => {
|
||||||
|
if (props.booking.status !== BookingStatus.CONFIRMED) return ''
|
||||||
|
if (dateKey.value !== todayStr.value && dateKey.value !== tomorrowStr.value) return ''
|
||||||
|
|
||||||
|
const target = new Date(`${dateKey.value}T${props.booking.timeSlot.startTime}:00`).getTime()
|
||||||
|
const diff = target - Date.now()
|
||||||
|
if (diff <= 0) return '即将开始'
|
||||||
|
|
||||||
|
const hours = Math.floor(diff / 3_600_000)
|
||||||
|
const mins = Math.floor((diff % 3_600_000) / 60_000)
|
||||||
|
|
||||||
|
if (dateKey.value === todayStr.value) {
|
||||||
|
if (hours <= 0) return `${mins} 分钟后`
|
||||||
|
return `${hours} 小时 ${mins} 分后`
|
||||||
|
}
|
||||||
|
return `${hours} 小时 ${mins} 分`
|
||||||
|
})
|
||||||
|
|
||||||
|
const poem = computed(() => {
|
||||||
|
const poems: Record<string, string> = {
|
||||||
|
today: '让呼吸慢一些,让脊柱回到中央。',
|
||||||
|
tomorrow: '明天见,记得早些休息。',
|
||||||
|
upcoming: '你的身体,会记得每一次到达。',
|
||||||
|
pending: '待确认中,我们会为你预留这朵花。',
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dateKey.value === todayStr.value) return poems.today
|
||||||
|
if (dateKey.value === tomorrowStr.value) return poems.tomorrow
|
||||||
|
if (props.booking.status === BookingStatus.PENDING_CONFIRMATION) return poems.pending
|
||||||
|
return poems.upcoming
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleTap() {
|
||||||
|
emit('tap', props.booking)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.hero {
|
||||||
|
position: relative;
|
||||||
|
margin: 24rpx 32rpx 0;
|
||||||
|
padding: 36rpx 32rpx 28rpx;
|
||||||
|
border-radius: 36rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
background: linear-gradient(140deg, #efe5d6 0%, #e6d8c4 100%);
|
||||||
|
color: #3a322b;
|
||||||
|
box-shadow:
|
||||||
|
0 1rpx 0 rgba(122, 99, 84, 0.04),
|
||||||
|
0 12rpx 32rpx rgba(122, 99, 84, 0.06);
|
||||||
|
|
||||||
|
&--pending {
|
||||||
|
background: linear-gradient(140deg, #ece1cd 0%, #e2d2b8 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--confirmed {
|
||||||
|
background: linear-gradient(140deg, #e2ebe2 0%, #d4e0d3 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--completed {
|
||||||
|
background: linear-gradient(140deg, #efe5d6 0%, #e6d8c4 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--cancelled {
|
||||||
|
background: linear-gradient(140deg, #ede4dc 0%, #e3d6c9 100%);
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--noshow {
|
||||||
|
background: linear-gradient(140deg, #ede0dc 0%, #e3cfc7 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__grain {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.4;
|
||||||
|
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='200' height='200'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 0.45 0 0 0 0 0.36 0 0 0 0 0.27 0 0 0 0.18 0'/></filter><rect width='200' height='200' filter='url(%23n)'/></svg>");
|
||||||
|
mix-blend-mode: multiply;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__moon {
|
||||||
|
position: absolute;
|
||||||
|
top: -80rpx;
|
||||||
|
right: -60rpx;
|
||||||
|
width: 220rpx;
|
||||||
|
height: 220rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: radial-gradient(circle at 30% 30%, rgba(255, 248, 235, 0.55) 0%, rgba(255, 248, 235, 0) 70%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__main {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__date {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 2rpx;
|
||||||
|
width: 132rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 8rpx 0;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
background: rgba(255, 251, 244, 0.5);
|
||||||
|
backdrop-filter: blur(8rpx);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__day {
|
||||||
|
font-family: "Songti SC", "STSong", "Noto Serif SC", "Source Han Serif SC", serif;
|
||||||
|
font-size: 72rpx;
|
||||||
|
line-height: 1;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #3a322b;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__month {
|
||||||
|
margin-top: 8rpx;
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #6f645a;
|
||||||
|
letter-spacing: 2rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__weekday {
|
||||||
|
font-size: 20rpx;
|
||||||
|
color: #8b7d70;
|
||||||
|
letter-spacing: 1rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__divider {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 2rpx;
|
||||||
|
padding: 8rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__divider-dot {
|
||||||
|
width: 2rpx;
|
||||||
|
height: 8rpx;
|
||||||
|
background: #b09a83;
|
||||||
|
border-radius: 1rpx;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8rpx;
|
||||||
|
padding: 4rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__time-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 6rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__time {
|
||||||
|
font-family: "Songti SC", "STSong", "Noto Serif SC", "Source Han Serif SC", serif;
|
||||||
|
font-size: 50rpx;
|
||||||
|
line-height: 1;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #2a2520;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
letter-spacing: -1rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__time-end {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #6f645a;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__membership {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #6f645a;
|
||||||
|
line-height: 1.4;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8rpx;
|
||||||
|
margin-top: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__dot {
|
||||||
|
width: 10rpx;
|
||||||
|
height: 10rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #6e8b7d;
|
||||||
|
box-shadow: 0 0 0 4rpx rgba(110, 139, 125, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__status-text {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #6e8b7d;
|
||||||
|
letter-spacing: 1rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__countdown {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #8b7d70;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__footer {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16rpx;
|
||||||
|
margin-top: 28rpx;
|
||||||
|
padding-top: 20rpx;
|
||||||
|
border-top: 1rpx dashed rgba(122, 99, 84, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__poem {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #6f645a;
|
||||||
|
letter-spacing: 2rpx;
|
||||||
|
font-style: italic;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__cta {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6rpx;
|
||||||
|
padding: 6rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__cta-text {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #8b7d70;
|
||||||
|
letter-spacing: 2rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__cta-arrow {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #8b7d70;
|
||||||
|
transform: translateY(-1rpx);
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero:active {
|
||||||
|
transform: scale(0.99);
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
|
||||||
|
.hero__cta-arrow {
|
||||||
|
transform: translate(4rpx, -1rpx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
126
packages/app/src/components/PracticeActivityCard.vue
Normal file
126
packages/app/src/components/PracticeActivityCard.vue
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
<template>
|
||||||
|
<view class="practice">
|
||||||
|
<view class="practice__heading">
|
||||||
|
<view>
|
||||||
|
<view class="practice__title">练习足迹<text class="practice__period">最近 30 天</text></view>
|
||||||
|
</view>
|
||||||
|
<view class="practice__summary"><text class="practice__number">{{ activity ? total : '—' }}</text><text>节课</text></view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="error" class="practice__state">
|
||||||
|
<text>暂时未能加载练习记录</text>
|
||||||
|
<button class="practice__retry" @tap="load">重新加载</button>
|
||||||
|
</view>
|
||||||
|
<template v-else>
|
||||||
|
<view class="practice__range">
|
||||||
|
<text>{{ activity ? formatDate(activity.days[0].date) : '正在读取练习记录' }}</text>
|
||||||
|
<text>{{ activity ? formatDate(activity.days[29].date) + ' · 今天' : '' }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="practice__grid" :class="{ 'practice__grid--loading': !activity }">
|
||||||
|
<button v-for="(day, index) in cells" :key="day.date || index"
|
||||||
|
class="practice__cell" :class="[
|
||||||
|
`practice__cell--${Math.min(day.count, 3)}`,
|
||||||
|
{ 'practice__cell--selected': selected === day.date && !!activity, 'practice__cell--today': index === 29 },
|
||||||
|
]"
|
||||||
|
:disabled="!activity" :aria-label="activity ? `${formatDate(day.date)},已完成 ${day.count} 节课` : '加载中'"
|
||||||
|
@tap="selected = day.date">
|
||||||
|
<text>{{ day.date ? Number(day.date.slice(-2)) : '' }}</text>
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
<view class="practice__footer">
|
||||||
|
<text class="practice__detail">{{ detail }}</text>
|
||||||
|
<view class="practice__legend">
|
||||||
|
<text>0</text><view class="practice__swatch practice__cell--0" />
|
||||||
|
<view class="practice__swatch practice__cell--1" /><view class="practice__swatch practice__cell--2" />
|
||||||
|
<view class="practice__swatch practice__cell--3" /><text>3+ 节</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
|
import type { PracticeActivity } from '@mp-pilates/shared'
|
||||||
|
import { get } from '../utils/request'
|
||||||
|
|
||||||
|
const props = defineProps<{ refreshKey: number }>()
|
||||||
|
const activity = ref<PracticeActivity | null>(null)
|
||||||
|
const selected = ref('')
|
||||||
|
const error = ref(false)
|
||||||
|
let loading = false
|
||||||
|
let disposed = false
|
||||||
|
const cells = computed(() => activity.value?.days ?? Array.from({ length: 30 }, () => ({ date: '', count: 0 })))
|
||||||
|
const total = computed(() => cells.value.reduce((sum, day) => sum + day.count, 0))
|
||||||
|
const activeDays = computed(() => cells.value.filter(day => day.count > 0).length)
|
||||||
|
const detail = computed(() => {
|
||||||
|
if (!activity.value) return '正在整理练习记录'
|
||||||
|
const day = activity.value.days.find(item => item.date === selected.value)
|
||||||
|
if (day) return `${formatDate(day.date)} · ${day.count ? `已完成 ${day.count} 节课` : '暂无已完成课程'}`
|
||||||
|
return total.value ? `已练习 ${activeDays.value} 天 · 点选查看` : '近 30 天暂无已完成课程'
|
||||||
|
})
|
||||||
|
function formatDate(date: string) {
|
||||||
|
return `${Number(date.slice(5, 7))}月${Number(date.slice(8, 10))}日`
|
||||||
|
}
|
||||||
|
async function load() {
|
||||||
|
if (loading || disposed) return
|
||||||
|
loading = true
|
||||||
|
error.value = false
|
||||||
|
try {
|
||||||
|
const result = await get<PracticeActivity>('/booking/my/activity')
|
||||||
|
if (!disposed) {
|
||||||
|
activity.value = result
|
||||||
|
if (!result.days.some(day => day.date === selected.value)) selected.value = ''
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!disposed) {
|
||||||
|
activity.value = null
|
||||||
|
error.value = true
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onMounted(load)
|
||||||
|
watch(() => props.refreshKey, load)
|
||||||
|
onUnmounted(() => { disposed = true })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.practice {
|
||||||
|
margin: 24rpx 32rpx 0;
|
||||||
|
padding: 28rpx;
|
||||||
|
border: 1rpx solid #eee8e3;
|
||||||
|
border-radius: 28rpx;
|
||||||
|
background: #fff;
|
||||||
|
color: #514943;
|
||||||
|
|
||||||
|
&__heading, &__range, &__footer, &__legend { display: flex; align-items: center; justify-content: space-between; }
|
||||||
|
&__title { font-size: 28rpx; font-weight: 500; }
|
||||||
|
&__period { margin-left: 16rpx; font-size: 21rpx; font-weight: 400; letter-spacing: 0; color: #8b817b; }
|
||||||
|
&__summary { display: flex; align-items: baseline; gap: 8rpx; font-size: 21rpx; color: #8b817b; }
|
||||||
|
&__number { font-size: 44rpx; font-weight: 400; line-height: 1; color: #617d73; font-variant-numeric: tabular-nums; }
|
||||||
|
&__range { margin: 24rpx 0 12rpx; font-size: 20rpx; color: #8b817b; }
|
||||||
|
&__grid { display: grid; grid-template-columns: repeat(10, minmax(0, 1fr)); gap: 9rpx; }
|
||||||
|
&__cell {
|
||||||
|
width: 100%; height: 44rpx; min-width: 0; margin: 0; padding: 0;
|
||||||
|
display: flex; justify-content: center; align-items: center;
|
||||||
|
border-radius: 8rpx; border: 2rpx solid transparent; box-sizing: border-box;
|
||||||
|
font-size: 19rpx; line-height: 1; font-variant-numeric: tabular-nums;
|
||||||
|
&::after { border: none; }
|
||||||
|
&--0 { background: #edf0e9; color: #7c8676; }
|
||||||
|
&--1 { background: #ccd9c6; color: #516d50; }
|
||||||
|
&--2 { background: #9cb594; color: #304b36; }
|
||||||
|
&--3 { background: #637f5f; color: #ffffff; }
|
||||||
|
&--today { border-bottom-color: #516d50; }
|
||||||
|
&--selected { box-shadow: 0 0 0 3rpx #fff, 0 0 0 5rpx #637f5f; }
|
||||||
|
}
|
||||||
|
&__grid--loading { opacity: 0.5; }
|
||||||
|
&__footer { margin-top: 22rpx; gap: 12rpx; flex-wrap: wrap; }
|
||||||
|
&__detail { font-size: 21rpx; color: #788477; }
|
||||||
|
&__legend { gap: 5rpx; font-size: 18rpx; color: #8b817b; }
|
||||||
|
&__swatch { width: 13rpx; height: 13rpx; border-radius: 3rpx; }
|
||||||
|
&__state { min-height: 200rpx; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 20rpx; font-size: 24rpx; color: #8b817b; }
|
||||||
|
&__retry { margin: 0; padding: 0 24rpx; line-height: 56rpx; font-size: 24rpx; color: #516d50; background: #edf0e9; border-radius: 28rpx; &::after { border: none; } }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,29 +1,30 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="profile-menu">
|
<view class="profile-menu">
|
||||||
<template v-for="item in menuItems" :key="item.key">
|
<view class="profile-menu__shortcuts">
|
||||||
<!-- Separator -->
|
<view v-for="item in primaryItems" :key="item.key" class="profile-menu__shortcut"
|
||||||
<view v-if="item.type === 'separator'" class="profile-menu__separator" />
|
hover-class="profile-menu__item--hover" @tap="handleTap(item)">
|
||||||
|
<view class="profile-menu__shortcut-head">
|
||||||
<!-- Menu item -->
|
<view class="profile-menu__icon" :class="'profile-menu__icon--' + item.key" />
|
||||||
<view
|
<text class="profile-menu__arrow">›</text>
|
||||||
v-else
|
|
||||||
class="profile-menu__item"
|
|
||||||
:class="{ 'profile-menu__item--admin': item.isAdmin }"
|
|
||||||
hover-class="profile-menu__item--hover"
|
|
||||||
hover-stay-time="150"
|
|
||||||
@tap="handleTap(item)"
|
|
||||||
>
|
|
||||||
<view class="profile-menu__icon-wrap" :class="{ 'profile-menu__icon-wrap--admin': item.isAdmin }">
|
|
||||||
<text class="profile-menu__icon">{{ item.icon }}</text>
|
|
||||||
</view>
|
</view>
|
||||||
<text class="profile-menu__title" :class="{ 'profile-menu__title--admin': item.isAdmin }">
|
<text class="profile-menu__shortcut-title">{{ item.title }}</text>
|
||||||
{{ item.title }}
|
<text class="profile-menu__shortcut-note">{{ !requireAuth ? '登录后查看' : item.key === 'membership' ? `${activeMembershipCount || 0} 张可用` : `${upcomingBookingCount || 0} 节待上` }}</text>
|
||||||
</text>
|
</view>
|
||||||
<text v-if="item.badge" class="profile-menu__badge">{{ item.badge }}</text>
|
</view>
|
||||||
|
|
||||||
|
<slot />
|
||||||
|
|
||||||
|
<view class="profile-menu__links">
|
||||||
|
<template v-for="item in secondaryItems" :key="item.key">
|
||||||
|
<view v-if="item.type === 'separator'" class="profile-menu__separator" />
|
||||||
|
<view v-else class="profile-menu__item" :class="{ 'profile-menu__item--admin': item.isAdmin }"
|
||||||
|
hover-class="profile-menu__item--hover" hover-stay-time="150" @tap="handleTap(item)">
|
||||||
|
<text class="profile-menu__title">{{ item.title }}</text>
|
||||||
<text class="profile-menu__arrow">›</text>
|
<text class="profile-menu__arrow">›</text>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
</view>
|
</view>
|
||||||
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -32,48 +33,74 @@ import { computed } from 'vue'
|
|||||||
interface MenuItem {
|
interface MenuItem {
|
||||||
key: string
|
key: string
|
||||||
type: 'item' | 'separator'
|
type: 'item' | 'separator'
|
||||||
icon?: string
|
|
||||||
title?: string
|
title?: string
|
||||||
path?: string
|
path?: string
|
||||||
isAdmin?: boolean
|
isAdmin?: boolean
|
||||||
badge?: string
|
badge?: string
|
||||||
action?: 'clear' | 'about'
|
action?: 'clear'
|
||||||
requireAuth?: boolean
|
requireAuth?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
isAdmin: boolean
|
isAdmin: boolean
|
||||||
requireAuth?: boolean
|
requireAuth?: boolean
|
||||||
|
activeMembershipCount?: number
|
||||||
|
upcomingBookingCount?: number
|
||||||
|
inviteShareEligible?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'clear-cache'): void
|
(e: 'clear-cache'): void
|
||||||
(e: 'about'): void
|
|
||||||
(e: 'require-login'): void
|
(e: 'require-login'): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const menuItems = computed<MenuItem[]>(() => {
|
const menuItems = computed<MenuItem[]>(() => {
|
||||||
|
const membershipBadge = props.activeMembershipCount && props.activeMembershipCount > 0
|
||||||
|
? `${props.activeMembershipCount}张`
|
||||||
|
: undefined
|
||||||
|
const bookingBadge = props.upcomingBookingCount && props.upcomingBookingCount > 0
|
||||||
|
? `${props.upcomingBookingCount}`
|
||||||
|
: undefined
|
||||||
|
|
||||||
const items: MenuItem[] = [
|
const items: MenuItem[] = [
|
||||||
{
|
{
|
||||||
key: 'membership',
|
key: 'membership',
|
||||||
type: 'item',
|
type: 'item',
|
||||||
icon: '💳',
|
|
||||||
title: '我的会员卡',
|
title: '我的会员卡',
|
||||||
path: '/pages/profile/membership',
|
path: '/pages/profile/membership',
|
||||||
|
badge: membershipBadge,
|
||||||
requireAuth: true,
|
requireAuth: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'bookings',
|
key: 'bookings',
|
||||||
type: 'item',
|
type: 'item',
|
||||||
icon: '📅',
|
|
||||||
title: '我的预约',
|
title: '我的预约',
|
||||||
path: '/pages/profile/bookings',
|
path: '/pages/profile/bookings',
|
||||||
|
badge: bookingBadge,
|
||||||
requireAuth: true,
|
requireAuth: true,
|
||||||
},
|
},
|
||||||
|
...(props.isAdmin
|
||||||
|
? [{
|
||||||
|
key: 'teaching-schedule',
|
||||||
|
type: 'item' as const,
|
||||||
|
title: '我的课表',
|
||||||
|
path: '/pages/profile/teaching-schedule',
|
||||||
|
requireAuth: true,
|
||||||
|
}]
|
||||||
|
: []),
|
||||||
|
// 临时隐藏邀请好友入口,后续恢复时直接取消这段注释即可。
|
||||||
|
// ...(props.inviteShareEligible
|
||||||
|
// ? [{
|
||||||
|
// key: 'invite',
|
||||||
|
// type: 'item' as const,
|
||||||
|
// title: '邀请好友',
|
||||||
|
// path: '/pages/profile/invite',
|
||||||
|
// requireAuth: true,
|
||||||
|
// }]
|
||||||
|
// : []),
|
||||||
{
|
{
|
||||||
key: 'info',
|
key: 'info',
|
||||||
type: 'item',
|
type: 'item',
|
||||||
icon: '👤',
|
|
||||||
title: '个人信息',
|
title: '个人信息',
|
||||||
path: '/pages/profile/info',
|
path: '/pages/profile/info',
|
||||||
requireAuth: true,
|
requireAuth: true,
|
||||||
@@ -85,17 +112,9 @@ const menuItems = computed<MenuItem[]>(() => {
|
|||||||
{
|
{
|
||||||
key: 'clear',
|
key: 'clear',
|
||||||
type: 'item',
|
type: 'item',
|
||||||
icon: '🗑️',
|
|
||||||
title: '清除缓存',
|
title: '清除缓存',
|
||||||
action: 'clear',
|
action: 'clear',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'about',
|
|
||||||
type: 'item',
|
|
||||||
icon: 'ℹ️',
|
|
||||||
title: '关于我们',
|
|
||||||
action: 'about',
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
if (props.isAdmin) {
|
if (props.isAdmin) {
|
||||||
@@ -103,7 +122,6 @@ const menuItems = computed<MenuItem[]>(() => {
|
|||||||
items.push({
|
items.push({
|
||||||
key: 'admin',
|
key: 'admin',
|
||||||
type: 'item',
|
type: 'item',
|
||||||
icon: '⚙️',
|
|
||||||
title: '管理中心',
|
title: '管理中心',
|
||||||
path: '/pages/admin/index',
|
path: '/pages/admin/index',
|
||||||
isAdmin: true,
|
isAdmin: true,
|
||||||
@@ -114,6 +132,9 @@ const menuItems = computed<MenuItem[]>(() => {
|
|||||||
return items
|
return items
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const primaryItems = computed(() => menuItems.value.filter(item => item.key === 'membership' || item.key === 'bookings'))
|
||||||
|
const secondaryItems = computed(() => menuItems.value.filter(item => item.key !== 'membership' && item.key !== 'bookings'))
|
||||||
|
|
||||||
function handleTap(item: MenuItem) {
|
function handleTap(item: MenuItem) {
|
||||||
if (item.requireAuth && !props.requireAuth) {
|
if (item.requireAuth && !props.requireAuth) {
|
||||||
emit('require-login')
|
emit('require-login')
|
||||||
@@ -121,8 +142,6 @@ function handleTap(item: MenuItem) {
|
|||||||
}
|
}
|
||||||
if (item.action === 'clear') {
|
if (item.action === 'clear') {
|
||||||
emit('clear-cache')
|
emit('clear-cache')
|
||||||
} else if (item.action === 'about') {
|
|
||||||
emit('about')
|
|
||||||
} else if (item.path) {
|
} else if (item.path) {
|
||||||
uni.navigateTo({ url: item.path })
|
uni.navigateTo({ url: item.path })
|
||||||
}
|
}
|
||||||
@@ -131,84 +150,20 @@ function handleTap(item: MenuItem) {
|
|||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.profile-menu {
|
.profile-menu {
|
||||||
background: $bg-card;
|
&__shortcuts { display: flex; gap: 20rpx; margin: 24rpx 32rpx 0; }
|
||||||
border-radius: $radius-lg;
|
&__shortcut { flex: 1; min-width: 0; padding: 24rpx; box-sizing: border-box; border-radius: 26rpx; background: #fff; border: 1rpx solid #eee8e3; }
|
||||||
margin: $spacing-lg $spacing-lg 0;
|
&__shortcut-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16rpx; }
|
||||||
overflow: hidden;
|
&__shortcut-title { display: block; font-size: 27rpx; font-weight: 500; color: #514943; }
|
||||||
|
&__shortcut-note { display: block; font-size: 22rpx; color: #8b817b; margin-top: 8rpx; }
|
||||||
&__separator {
|
&__icon { width: 36rpx; height: 30rpx; position: relative; box-sizing: border-box; border: 2rpx solid #9b8878; border-radius: 6rpx; }
|
||||||
height: $spacing-sm;
|
&__icon--membership::after { content: ''; position: absolute; top: 8rpx; left: 0; right: 0; border-top: 2rpx solid #9b8878; }
|
||||||
background: $bg-page;
|
&__icon--bookings { border-color: #829a8b; border-top-width: 7rpx; &::after { content: ''; position: absolute; left: 8rpx; top: 8rpx; width: 12rpx; border-top: 2rpx solid #829a8b; } }
|
||||||
}
|
&__links { margin: 24rpx 32rpx 0; border-radius: 26rpx; background: #fff; overflow: hidden; }
|
||||||
|
&__item { display: flex; align-items: center; gap: 20rpx; min-height: 96rpx; padding: 0 28rpx; box-sizing: border-box; border-bottom: 1rpx solid #f2ede8; &:last-child { border-bottom: none; } }
|
||||||
&__item {
|
&__item--hover { background: #f4f1ec; }
|
||||||
display: flex;
|
&__item--admin { background: #eff3ed; .profile-menu__title { color: #617d73; } }
|
||||||
align-items: center;
|
&__title { flex: 1; font-size: 27rpx; font-weight: 400; color: #6f655e; }
|
||||||
padding: $spacing-md $spacing-lg;
|
&__arrow { font-size: 30rpx; color: #b2a79e; line-height: 1; }
|
||||||
border-bottom: 1rpx solid $border-color;
|
&__separator { height: 12rpx; background: #fbf9f6; }
|
||||||
background: $bg-card;
|
|
||||||
transition: background 0.15s;
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--hover {
|
|
||||||
background: #f5f5f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--admin {
|
|
||||||
background: rgba($accent-color, 0.04);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__icon-wrap {
|
|
||||||
width: 64rpx;
|
|
||||||
height: 64rpx;
|
|
||||||
border-radius: $radius-sm;
|
|
||||||
background: rgba($brand-color, 0.08);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
margin-right: $spacing-md;
|
|
||||||
|
|
||||||
&--admin {
|
|
||||||
background: rgba($accent-color, 0.12);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__icon {
|
|
||||||
font-size: 32rpx;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__title {
|
|
||||||
flex: 1;
|
|
||||||
font-size: 30rpx;
|
|
||||||
color: $text-primary;
|
|
||||||
|
|
||||||
&--admin {
|
|
||||||
color: $accent-color;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__badge {
|
|
||||||
font-size: 22rpx;
|
|
||||||
color: #ffffff;
|
|
||||||
background: $error-color;
|
|
||||||
border-radius: 20rpx;
|
|
||||||
padding: 2rpx 12rpx;
|
|
||||||
margin-right: $spacing-sm;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__arrow {
|
|
||||||
font-size: 36rpx;
|
|
||||||
color: $text-hint;
|
|
||||||
line-height: 1;
|
|
||||||
transform: scaleX(0.6);
|
|
||||||
transform-origin: center;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,88 +1,51 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="quick-entry">
|
<view class="quick-entry">
|
||||||
<!-- ① Not logged in -->
|
<!-- ① Not logged in -->
|
||||||
<view v-if="!userStore.loggedIn" class="entry-card login-card" @tap="handleLogin">
|
<view v-if="!userStore.loggedIn" class="entry-pill pill-login" @tap="handleLogin">
|
||||||
<view class="entry-content">
|
<text class="pill-label">欢迎来到工作室</text>
|
||||||
<view class="entry-left">
|
<view class="pill-action action-login">
|
||||||
<text class="entry-icon">👋</text>
|
<text class="pill-action-text">微信登录</text>
|
||||||
<view>
|
|
||||||
<text class="entry-title">欢迎来到工作室</text>
|
|
||||||
<text class="entry-subtitle">登录后即可预约课程</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
<view class="entry-btn login-btn">
|
|
||||||
<text class="entry-btn-text">微信登录</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- ② Logged in, no memberships at all → new user -->
|
<!-- ② Logged in, no memberships → new user -->
|
||||||
<view
|
<view
|
||||||
v-else-if="userStore.loggedIn && userStore.memberships.length === 0"
|
v-else-if="userStore.loggedIn && userStore.memberships.length === 0"
|
||||||
class="entry-card trial-card"
|
class="entry-pill pill-trial"
|
||||||
@tap="handleTrialEntry"
|
@tap="handleTrialEntry"
|
||||||
>
|
>
|
||||||
<view class="entry-content">
|
|
||||||
<view class="entry-left">
|
<text class="pill-label">首次体验专属课程</text>
|
||||||
<text class="entry-icon">✨</text>
|
<view class="pill-action action-trial">
|
||||||
<view>
|
<text class="pill-action-text">预约体验课</text>
|
||||||
<text class="entry-title">初次体验</text>
|
|
||||||
<text class="entry-subtitle">专属体验课,了解普拉提</text>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="entry-btn trial-btn">
|
|
||||||
<text class="entry-btn-text">预约体验课</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
<view class="card-badge trial-badge">新会员专享</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- ③ Has valid active card + running low warning -->
|
<!-- ③ Has valid active card -->
|
||||||
<template v-else-if="userStore.hasValidMembership">
|
<template v-else-if="userStore.hasValidMembership">
|
||||||
<view class="entry-card active-card" @tap="handleBooking">
|
<view class="entry-pill pill-active" @tap="handleBooking">
|
||||||
<view class="entry-content">
|
<text class="pill-label pill-label-active">{{ activeMembershipLabel }}</text>
|
||||||
<view class="entry-left">
|
<view class="pill-action action-book">
|
||||||
<text class="entry-icon">🧘</text>
|
<text class="pill-action-text">预约课程</text>
|
||||||
<view>
|
|
||||||
<text class="entry-title">一键约课</text>
|
|
||||||
<text class="entry-subtitle">{{ activeMembershipLabel }}</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
<view class="entry-btn book-btn">
|
|
||||||
<text class="entry-btn-text">立即预约</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
<!-- Running low badge -->
|
|
||||||
<view v-if="isRunningLow" class="card-badge low-badge">
|
|
||||||
仅剩 {{ lowestRemainingTimes }} 次
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Renew reminder if running low -->
|
<!-- Running low: thin accent strip -->
|
||||||
<view v-if="isRunningLow" class="renew-tip" @tap="scrollToCardShop">
|
<view v-if="renewStripText" class="renew-strip" @tap="handleRenew">
|
||||||
<text class="renew-tip-icon">⚠️</text>
|
<text class="renew-strip-text">{{ renewStripText }}</text>
|
||||||
<text class="renew-tip-text">课次即将用完,点击续卡保持练习节奏</text>
|
<text class="renew-strip-arrow">›</text>
|
||||||
<text class="renew-tip-action">续卡 ›</text>
|
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- ④ Has memberships but none active → buy card -->
|
<!-- ④ Has memberships but none active → buy card -->
|
||||||
<view
|
<view
|
||||||
v-else
|
v-else
|
||||||
class="entry-card expired-card"
|
class="entry-pill pill-expired"
|
||||||
@tap="scrollToCardShop"
|
@tap="handleRenew"
|
||||||
>
|
>
|
||||||
<view class="entry-content">
|
<text class="pill-label">暂无可用会员卡</text>
|
||||||
<view class="entry-left">
|
<view class="pill-action action-renew">
|
||||||
<text class="entry-icon">💳</text>
|
<text class="pill-action-text">续卡</text>
|
||||||
<view>
|
|
||||||
<text class="entry-title">续费会员卡</text>
|
|
||||||
<text class="entry-subtitle">您的卡已到期,续卡继续练习</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
<view class="entry-btn renew-btn">
|
|
||||||
<text class="entry-btn-text">购买会员卡</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -91,7 +54,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useUserStore } from '../stores/user'
|
import { useUserStore } from '../stores/user'
|
||||||
import { CardTypeCategory } from '@mp-pilates/shared'
|
import { getErrorMessage } from '../utils/auth'
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'scroll-to-card-shop'): void
|
(e: 'scroll-to-card-shop'): void
|
||||||
@@ -104,17 +67,15 @@ async function handleLogin() {
|
|||||||
if (loading.value) return
|
if (loading.value) return
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
await userStore.login()
|
await userStore.loginWithSetup()
|
||||||
await userStore.fetchMemberships()
|
} catch (err: unknown) {
|
||||||
} catch {
|
uni.showToast({ title: getErrorMessage(err, '登录失败,请重试'), icon: 'none' })
|
||||||
uni.showToast({ title: '登录失败,请重试', icon: 'none' })
|
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTrialEntry() {
|
function handleTrialEntry() {
|
||||||
// Navigate to the first TRIAL card detail page
|
|
||||||
uni.navigateTo({ url: '/pages/card/detail?trial=1' })
|
uni.navigateTo({ url: '/pages/card/detail?trial=1' })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,13 +87,21 @@ function scrollToCardShop() {
|
|||||||
emit('scroll-to-card-shop')
|
emit('scroll-to-card-shop')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Computed: label for the active membership
|
function handleRenew() {
|
||||||
|
const cardTypeId = userStore.renewalHint?.cardTypeId
|
||||||
|
if (cardTypeId) {
|
||||||
|
uni.navigateTo({ url: `/pages/card/detail?id=${cardTypeId}` })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scrollToCardShop()
|
||||||
|
}
|
||||||
|
|
||||||
const activeMembershipLabel = computed(() => {
|
const activeMembershipLabel = computed(() => {
|
||||||
const active = userStore.activeMemberships
|
const active = userStore.activeMemberships
|
||||||
if (!active.length) return ''
|
if (!active.length) return ''
|
||||||
const m = active[0]
|
const m = active[0]
|
||||||
const cardName = m.cardType.name
|
const cardName = m.cardType.name
|
||||||
if (m.cardType.type === CardTypeCategory.TIMES && m.remainingTimes !== null) {
|
if (m.remainingTimes !== null) {
|
||||||
return `${cardName} · 剩余 ${m.remainingTimes} 次`
|
return `${cardName} · 剩余 ${m.remainingTimes} 次`
|
||||||
}
|
}
|
||||||
const expire = new Date(m.expireDate)
|
const expire = new Date(m.expireDate)
|
||||||
@@ -141,178 +110,32 @@ const activeMembershipLabel = computed(() => {
|
|||||||
return `${cardName} · 剩余 ${daysLeft} 天`
|
return `${cardName} · 剩余 ${daysLeft} 天`
|
||||||
})
|
})
|
||||||
|
|
||||||
// Check if any TIMES card has ≤ 2 remaining
|
const renewStripText = computed(() => {
|
||||||
const isRunningLow = computed(() => {
|
const hint = userStore.renewalHint
|
||||||
return userStore.activeMemberships.some(
|
if (!hint || !userStore.hasValidMembership) return ''
|
||||||
(m) =>
|
if (hint.kind === 'times_low') {
|
||||||
m.cardType.type === CardTypeCategory.TIMES &&
|
return `仅剩 ${hint.remainingTimes ?? 0} 次 · 续卡保持节奏`
|
||||||
m.remainingTimes !== null &&
|
}
|
||||||
m.remainingTimes <= 2,
|
if (hint.kind === 'days_low') {
|
||||||
)
|
return `还剩 ${hint.daysLeft ?? 0} 天到期 · 续卡不中断`
|
||||||
})
|
}
|
||||||
|
if (hint.kind === 'trial_low') {
|
||||||
const lowestRemainingTimes = computed(() => {
|
return '体验课将尽 · 选购会员卡'
|
||||||
const timesCards = userStore.activeMemberships.filter(
|
}
|
||||||
(m) =>
|
return ''
|
||||||
m.cardType.type === CardTypeCategory.TIMES &&
|
|
||||||
m.remainingTimes !== null &&
|
|
||||||
m.remainingTimes <= 2,
|
|
||||||
)
|
|
||||||
if (!timesCards.length) return 0
|
|
||||||
return Math.min(...timesCards.map((m) => m.remainingTimes as number))
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.quick-entry {
|
.quick-entry { margin: 24rpx 32rpx 0; }
|
||||||
margin: 24rpx 24rpx 0;
|
.entry-pill { display: flex; align-items: center; min-height: 120rpx; box-sizing: border-box; padding: 24rpx; gap: 20rpx; border-radius: 28rpx; background: #f1e8e1; }
|
||||||
}
|
.pill-active { background: #edf2ec; }
|
||||||
|
.pill-expired { background: #f0ece7; }
|
||||||
.entry-card {
|
.pill-label { flex: 1; min-width: 0; font-size: 26rpx; line-height: 1.6; color: #6f5c50; overflow-wrap: anywhere; }
|
||||||
position: relative;
|
.pill-label-active { color: #526b5d; }
|
||||||
border-radius: 16rpx;
|
.pill-action { flex-shrink: 0; min-height: 68rpx; padding: 0 24rpx; border-radius: 999rpx; display: flex; align-items: center; justify-content: center; background: #6b8276; }
|
||||||
padding: 36rpx 32rpx;
|
.pill-action-text { font-size: 24rpx; font-weight: 400; color: #fff; white-space: nowrap; }
|
||||||
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.10);
|
.renew-strip { display: flex; align-items: center; justify-content: space-between; gap: 16rpx; padding: 18rpx 12rpx 0; }
|
||||||
overflow: hidden;
|
.renew-strip-text { font-size: 22rpx; color: #8b817b; line-height: 1.5; }
|
||||||
}
|
.renew-strip-arrow { font-size: 28rpx; color: #8b817b; }
|
||||||
|
|
||||||
.login-card {
|
|
||||||
background: linear-gradient(135deg, #1a1a2e, #2d2d5e);
|
|
||||||
}
|
|
||||||
|
|
||||||
.trial-card {
|
|
||||||
background: linear-gradient(135deg, #2d2d5e, #4a3f7a);
|
|
||||||
}
|
|
||||||
|
|
||||||
.active-card {
|
|
||||||
background: linear-gradient(135deg, #1a1a2e, #3a2a1a);
|
|
||||||
}
|
|
||||||
|
|
||||||
.expired-card {
|
|
||||||
background: linear-gradient(135deg, #4a4a4a, #2a2a2a);
|
|
||||||
}
|
|
||||||
|
|
||||||
.entry-content {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 24rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.entry-left {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 24rpx;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.entry-icon {
|
|
||||||
font-size: 56rpx;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.entry-title {
|
|
||||||
display: block;
|
|
||||||
font-size: 34rpx;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #ffffff;
|
|
||||||
margin-bottom: 8rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.entry-subtitle {
|
|
||||||
display: block;
|
|
||||||
font-size: 24rpx;
|
|
||||||
color: rgba(255, 255, 255, 0.65);
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.entry-btn {
|
|
||||||
flex-shrink: 0;
|
|
||||||
padding: 16rpx 32rpx;
|
|
||||||
border-radius: 40rpx;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.entry-btn-text {
|
|
||||||
font-size: 28rpx;
|
|
||||||
font-weight: 600;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-btn {
|
|
||||||
background: #c9a87c;
|
|
||||||
}
|
|
||||||
|
|
||||||
.trial-btn {
|
|
||||||
background: #c9a87c;
|
|
||||||
}
|
|
||||||
|
|
||||||
.book-btn {
|
|
||||||
background: #c9a87c;
|
|
||||||
}
|
|
||||||
|
|
||||||
.renew-btn {
|
|
||||||
background: #888;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-btn .entry-btn-text,
|
|
||||||
.trial-btn .entry-btn-text,
|
|
||||||
.book-btn .entry-btn-text,
|
|
||||||
.renew-btn .entry-btn-text {
|
|
||||||
color: #1a1a2e;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Corner badge */
|
|
||||||
.card-badge {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
right: 0;
|
|
||||||
padding: 8rpx 20rpx;
|
|
||||||
font-size: 20rpx;
|
|
||||||
font-weight: 600;
|
|
||||||
border-radius: 0 16rpx 0 16rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.trial-badge {
|
|
||||||
background: #c9a87c;
|
|
||||||
color: #1a1a2e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.low-badge {
|
|
||||||
background: #e74c3c;
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Renew tip bar */
|
|
||||||
.renew-tip {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12rpx;
|
|
||||||
margin-top: 16rpx;
|
|
||||||
padding: 20rpx 24rpx;
|
|
||||||
background: #fff8f0;
|
|
||||||
border-radius: 12rpx;
|
|
||||||
border: 1rpx solid #f0d9bc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.renew-tip-icon {
|
|
||||||
font-size: 28rpx;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.renew-tip-text {
|
|
||||||
flex: 1;
|
|
||||||
font-size: 24rpx;
|
|
||||||
color: #a0622a;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.renew-tip-action {
|
|
||||||
font-size: 24rpx;
|
|
||||||
color: #c9a87c;
|
|
||||||
font-weight: 600;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
288
packages/app/src/components/SessionRow.vue
Normal file
288
packages/app/src/components/SessionRow.vue
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
<template>
|
||||||
|
<view
|
||||||
|
class="session"
|
||||||
|
:class="[
|
||||||
|
`session--${tone}`,
|
||||||
|
{ 'session--muted': muted, 'session--history': history },
|
||||||
|
]"
|
||||||
|
@tap="handleTap"
|
||||||
|
>
|
||||||
|
<!-- 左侧日期块 -->
|
||||||
|
<view class="session__date">
|
||||||
|
<text class="session__day">{{ dayNumber }}</text>
|
||||||
|
<text class="session__weekday">{{ weekdayLabel }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 中央分隔:虚线 -->
|
||||||
|
<view class="session__rail">
|
||||||
|
<view class="session__dot" />
|
||||||
|
<view class="session__line" />
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 主体信息 -->
|
||||||
|
<view class="session__body">
|
||||||
|
<view class="session__top">
|
||||||
|
<text class="session__time">{{ startTime }}</text>
|
||||||
|
<text class="session__time-end">— {{ endTime }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<text class="session__membership">{{ cardName }}</text>
|
||||||
|
|
||||||
|
<view class="session__bottom">
|
||||||
|
<view class="session__status">
|
||||||
|
<view class="session__status-dot" />
|
||||||
|
<text class="session__status-text">{{ statusLabel }}</text>
|
||||||
|
</view>
|
||||||
|
<text v-if="!history" class="session__cancel" @tap.stop="handleCancel">取消预约</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { BookingWithDetails } from '@mp-pilates/shared'
|
||||||
|
import { BookingStatus } from '@mp-pilates/shared'
|
||||||
|
import {
|
||||||
|
bookingStatusLabel,
|
||||||
|
bookingStatusStripeClass,
|
||||||
|
} from '../utils/booking-helpers'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
booking: BookingWithDetails
|
||||||
|
/** 历史记录模式(更紧凑,隐藏取消按钮) */
|
||||||
|
history?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
tap: [booking: BookingWithDetails]
|
||||||
|
cancel: [booking: BookingWithDetails]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const weekdays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
|
||||||
|
|
||||||
|
function parseDate(dateStr: string): Date {
|
||||||
|
const normalized = dateStr.slice(0, 10)
|
||||||
|
const [y, m, d] = normalized.split('-').map(Number)
|
||||||
|
return new Date(y, m - 1, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = computed(() => parseDate(props.booking.timeSlot.date))
|
||||||
|
|
||||||
|
const dayNumber = computed(() => {
|
||||||
|
const d = date.value.getDate()
|
||||||
|
return d < 10 ? `0${d}` : String(d)
|
||||||
|
})
|
||||||
|
|
||||||
|
const weekdayLabel = computed(() => weekdays[date.value.getDay()])
|
||||||
|
const startTime = computed(() => props.booking.timeSlot.startTime.slice(0, 5))
|
||||||
|
const endTime = computed(() => props.booking.timeSlot.endTime.slice(0, 5))
|
||||||
|
const cardName = computed(() => props.booking.membership?.cardType?.name || '会员卡')
|
||||||
|
const statusLabel = computed(() => bookingStatusLabel(props.booking.status))
|
||||||
|
const tone = computed(() => bookingStatusStripeClass(props.booking.status))
|
||||||
|
|
||||||
|
const muted = computed(() => {
|
||||||
|
return props.booking.status === BookingStatus.CANCELLED || props.booking.status === BookingStatus.NO_SHOW
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleTap() {
|
||||||
|
emit('tap', props.booking)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
emit('cancel', props.booking)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.session {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 20rpx;
|
||||||
|
padding: 28rpx 24rpx;
|
||||||
|
margin: 0 32rpx 16rpx;
|
||||||
|
border-radius: 28rpx;
|
||||||
|
background: #fdfbf7;
|
||||||
|
border: 1rpx solid #ede5d8;
|
||||||
|
box-shadow:
|
||||||
|
0 1rpx 0 rgba(122, 99, 84, 0.02),
|
||||||
|
0 4rpx 16rpx rgba(122, 99, 84, 0.03);
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
|
||||||
|
&__date {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 2rpx;
|
||||||
|
width: 80rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 4rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__day {
|
||||||
|
font-family: "Songti SC", "STSong", "Noto Serif SC", "Source Han Serif SC", serif;
|
||||||
|
font-size: 44rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1;
|
||||||
|
color: #3a322b;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__weekday {
|
||||||
|
font-size: 19rpx;
|
||||||
|
color: #a89d92;
|
||||||
|
letter-spacing: 1rpx;
|
||||||
|
margin-top: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__rail {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0;
|
||||||
|
width: 14rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 6rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__dot {
|
||||||
|
width: 10rpx;
|
||||||
|
height: 10rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #6e8b7d;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__line {
|
||||||
|
width: 1rpx;
|
||||||
|
flex: 1;
|
||||||
|
background: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
rgba(155, 138, 117, 0.3) 0%,
|
||||||
|
rgba(155, 138, 117, 0) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 状态颜色
|
||||||
|
&--stripe--pending &__dot { background: #b8967c; }
|
||||||
|
&--stripe--confirmed &__dot { background: #6e8b7d; }
|
||||||
|
&--stripe--completed &__dot { background: #6e8b7d; }
|
||||||
|
&--stripe--cancelled &__dot { background: #c4a09a; }
|
||||||
|
&--stripe--noshow &__dot { background: #b89a93; }
|
||||||
|
|
||||||
|
&__body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6rpx;
|
||||||
|
padding: 4rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__top {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 6rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__time {
|
||||||
|
font-family: "Songti SC", "STSong", "Noto Serif SC", "Source Han Serif SC", serif;
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #3a322b;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__time-end {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #a89d92;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__membership {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #786b61;
|
||||||
|
line-height: 1.5;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__bottom {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12rpx;
|
||||||
|
margin-top: 6rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__status-dot {
|
||||||
|
width: 8rpx;
|
||||||
|
height: 8rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #6e8b7d;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__status-text {
|
||||||
|
font-size: 21rpx;
|
||||||
|
color: #786b61;
|
||||||
|
letter-spacing: 1rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--stripe--pending &__status-text { color: #957c65; }
|
||||||
|
&--stripe--pending &__status-dot { background: #b8967c; }
|
||||||
|
&--stripe--cancelled &__status-text { color: #a89d92; }
|
||||||
|
&--stripe--cancelled &__status-dot { background: #c4a09a; }
|
||||||
|
&--stripe--noshow &__status-text { color: #9c7a6e; }
|
||||||
|
&--stripe--noshow &__status-dot { background: #b89a93; }
|
||||||
|
|
||||||
|
&__cancel {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #a89d92;
|
||||||
|
padding: 6rpx 0 6rpx 16rpx;
|
||||||
|
letter-spacing: 1rpx;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__cancel:active {
|
||||||
|
color: #9c7a6e;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已取消 / 未出席:弱化
|
||||||
|
&--muted {
|
||||||
|
background: #f5f0e8;
|
||||||
|
border-color: #e8e0d4;
|
||||||
|
|
||||||
|
.session__day {
|
||||||
|
color: #a89d92;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session__time {
|
||||||
|
color: #a89d92;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session__membership {
|
||||||
|
color: #a89d92;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session__dot {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.session:active {
|
||||||
|
transform: scale(0.99);
|
||||||
|
box-shadow:
|
||||||
|
0 1rpx 0 rgba(122, 99, 84, 0.02),
|
||||||
|
0 2rpx 8rpx rgba(122, 99, 84, 0.04);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,228 +1,120 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="slot-card">
|
<view class="slot-card" :class="`slot-card--${statusClass}`" @tap="emit('cardTap', timeSlot)">
|
||||||
<!-- Time & capacity info -->
|
<view class="slot-heading">
|
||||||
<view class="slot-main">
|
<view class="slot-time">
|
||||||
<view class="slot-time-block">
|
<text class="time-start">{{ startTimeDisplay }}</text>
|
||||||
<text class="slot-time">{{ slot.startTime.slice(0, 5) }} - {{ slot.endTime.slice(0, 5) }}</text>
|
<text class="time-end">— {{ endTimeDisplay }}</text>
|
||||||
<view class="slot-capacity" :class="capacityClass">
|
|
||||||
<text class="capacity-text">{{ capacityLabel }}</text>
|
|
||||||
</view>
|
</view>
|
||||||
|
<text class="slot-duration">{{ durationMin }} 分钟</text>
|
||||||
</view>
|
</view>
|
||||||
|
<view class="slot-body">
|
||||||
<!-- Action area -->
|
<view class="course-info">
|
||||||
<view class="slot-action">
|
<text class="course-name">普拉提私教</text>
|
||||||
<!-- OPEN + not booked by me -->
|
<text class="slot-status">{{ capacityLabel }}</text>
|
||||||
<template v-if="slot.status === TimeSlotStatus.OPEN && !slot.isBookedByMe">
|
</view>
|
||||||
<view class="btn btn-book" @tap.stop="emit('book', slot)">
|
<view class="action-area">
|
||||||
<text class="btn-text">可预约</text>
|
<template v-if="timeSlot.isBookedByMe">
|
||||||
|
<text class="booked-label">已预约</text>
|
||||||
|
<view v-if="timeSlot.status === TimeSlotStatus.OPEN" class="cancel-link" @tap.stop="emit('cancel', timeSlot)">
|
||||||
|
<text>取消预约</text>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
<button v-else-if="!isPast && timeSlot.status === TimeSlotStatus.OPEN && timeSlot.bookedCount < timeSlot.capacity"
|
||||||
<!-- OPEN + booked by me -->
|
class="book-button" hover-class="book-button--pressed" @tap.stop="emit('book', timeSlot)">
|
||||||
<template v-else-if="slot.status === TimeSlotStatus.OPEN && slot.isBookedByMe">
|
预约
|
||||||
<view class="booked-row">
|
</button>
|
||||||
<view class="badge-booked">
|
<text v-else class="unavailable-label">{{ isPast ? '已结束' : timeSlot.status === TimeSlotStatus.CLOSED ? '未开放' : '已约满' }}</text>
|
||||||
<text class="badge-text">已预约</text>
|
|
||||||
</view>
|
|
||||||
<view class="btn-cancel" @tap.stop="emit('cancel', slot)">
|
|
||||||
<text class="btn-cancel-text">取消</text>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- FULL -->
|
|
||||||
<template v-else-if="slot.status === TimeSlotStatus.FULL">
|
|
||||||
<view class="btn btn-disabled">
|
|
||||||
<text class="btn-text">已约满</text>
|
|
||||||
</view>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- CLOSED -->
|
|
||||||
<template v-else>
|
|
||||||
<view class="btn btn-disabled">
|
|
||||||
<text class="btn-text">已关闭</text>
|
|
||||||
</view>
|
|
||||||
</template>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- Booked indicator bar -->
|
|
||||||
<view v-if="slot.isBookedByMe" class="booked-bar" />
|
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { TimeSlotWithBookingStatus } from '@mp-pilates/shared'
|
import type { TimeSlotWithBookingStatus } from '@mp-pilates/shared'
|
||||||
import { TimeSlotStatus } from '@mp-pilates/shared'
|
import { BookingStatus, TimeSlotStatus } from '@mp-pilates/shared'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
import { isSlotPast } from '../utils/format'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
slot: TimeSlotWithBookingStatus
|
timeSlot: TimeSlotWithBookingStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
book: [slot: TimeSlotWithBookingStatus]
|
book: [timeSlot: TimeSlotWithBookingStatus]
|
||||||
cancel: [slot: TimeSlotWithBookingStatus]
|
cancel: [timeSlot: TimeSlotWithBookingStatus]
|
||||||
|
cardTap: [timeSlot: TimeSlotWithBookingStatus]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const capacityLabel = computed(() => {
|
const startTimeDisplay = computed(() => props.timeSlot.startTime.slice(0, 5))
|
||||||
const { bookedCount, capacity, status } = props.slot
|
const endTimeDisplay = computed(() => props.timeSlot.endTime.slice(0, 5))
|
||||||
if (status === TimeSlotStatus.CLOSED) return '已关闭'
|
|
||||||
return `${bookedCount}/${capacity} 人`
|
const durationMin = computed(() => {
|
||||||
|
const [sh, sm] = props.timeSlot.startTime.split(':').map(Number)
|
||||||
|
const [eh, em] = props.timeSlot.endTime.split(':').map(Number)
|
||||||
|
return (eh * 60 + em) - (sh * 60 + sm)
|
||||||
})
|
})
|
||||||
|
|
||||||
const capacityClass = computed(() => {
|
const myBookingLabel = computed(() => (
|
||||||
const { bookedCount, capacity, status } = props.slot
|
props.timeSlot.myBookingStatus === BookingStatus.PENDING_CONFIRMATION
|
||||||
if (status === TimeSlotStatus.CLOSED) return 'cap-closed'
|
? '待老师确认'
|
||||||
if (status === TimeSlotStatus.FULL) return 'cap-full'
|
: '已预约'
|
||||||
if (bookedCount >= capacity * 0.8) return 'cap-almost'
|
))
|
||||||
return 'cap-open'
|
|
||||||
|
const capacityLabel = computed(() => {
|
||||||
|
const { bookedCount, capacity, status, isBookedByMe } = props.timeSlot
|
||||||
|
if (isBookedByMe) return myBookingLabel.value
|
||||||
|
if (isPast.value) return '已结束'
|
||||||
|
if (status === TimeSlotStatus.CLOSED) return '暂未开放'
|
||||||
|
if (status === TimeSlotStatus.FULL || bookedCount >= capacity) return '名额已满'
|
||||||
|
return `还可约 ${Math.max(0, capacity - bookedCount)} 位`
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const statusClass = computed(() => {
|
||||||
|
if (isPast.value && !props.timeSlot.isBookedByMe) return 'expired'
|
||||||
|
if (props.timeSlot.isBookedByMe) return 'booked'
|
||||||
|
if (props.timeSlot.status === TimeSlotStatus.FULL) return 'full'
|
||||||
|
if (props.timeSlot.status === TimeSlotStatus.CLOSED) return 'closed'
|
||||||
|
return 'open'
|
||||||
|
})
|
||||||
|
|
||||||
|
const isPast = computed(() => isSlotPast(props.timeSlot.date, props.timeSlot.startTime))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.slot-card {
|
.slot-card {
|
||||||
|
margin: 0 32rpx 20rpx;
|
||||||
|
padding: 28rpx;
|
||||||
|
border: 1rpx solid #eee8e3;
|
||||||
|
border-radius: 28rpx;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 20rpx;
|
color: #514943;
|
||||||
overflow: hidden;
|
}
|
||||||
box-shadow: 0 2rpx 16rpx rgba(0, 0, 0, 0.06);
|
.slot-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 16rpx; }
|
||||||
position: relative;
|
.slot-time { display: flex; align-items: baseline; gap: 12rpx; font-variant-numeric: tabular-nums; }
|
||||||
|
.time-start { font-size: 40rpx; font-weight: 500; line-height: 1.2; }
|
||||||
.booked-bar {
|
.time-end { font-size: 28rpx; color: #8b817b; }
|
||||||
position: absolute;
|
.slot-duration { flex-shrink: 0; font-size: 22rpx; color: #8b817b; }
|
||||||
left: 0;
|
.slot-body { display: flex; align-items: center; justify-content: space-between; gap: 20rpx; margin-top: 24rpx; }
|
||||||
top: 0;
|
.course-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8rpx; }
|
||||||
bottom: 0;
|
.course-name { font-size: 26rpx; font-weight: 400; }
|
||||||
width: 6rpx;
|
.slot-status { font-size: 22rpx; color: #617d73; }
|
||||||
background: #c9a87c;
|
.action-area { display: flex; flex-direction: column; align-items: flex-end; gap: 2rpx; flex-shrink: 0; }
|
||||||
border-radius: 20rpx 0 0 20rpx;
|
.book-button {
|
||||||
}
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
min-width: 136rpx; height: 72rpx; margin: 0; padding: 0 30rpx;
|
||||||
.slot-main {
|
border: none; border-radius: 999rpx; background: #6b8276;
|
||||||
display: flex;
|
font-size: 26rpx; font-weight: 400; line-height: 1; color: #fff;
|
||||||
flex-direction: row;
|
&::after { border: none; }
|
||||||
align-items: center;
|
&--pressed { background: #597264; }
|
||||||
padding: 32rpx 28rpx 32rpx 36rpx;
|
}
|
||||||
gap: 20rpx;
|
.booked-label { font-size: 24rpx; color: #617d73; }
|
||||||
}
|
.cancel-link { padding: 12rpx 0 8rpx 20rpx; font-size: 21rpx; color: #8b817b; }
|
||||||
|
.unavailable-label { padding: 14rpx 24rpx; border-radius: 999rpx; background: #f3f0ed; color: #8b817b; font-size: 23rpx; }
|
||||||
.slot-time-block {
|
.slot-card--booked { background: #f2f5f0; border-color: #e2e9df; }
|
||||||
flex: 1;
|
.slot-card--expired, .slot-card--closed, .slot-card--full {
|
||||||
display: flex;
|
background: #f7f5f2;
|
||||||
flex-direction: column;
|
.time-start, .course-name { color: #8b817b; }
|
||||||
gap: 12rpx;
|
.slot-status { color: #948a82; }
|
||||||
}
|
|
||||||
|
|
||||||
.slot-time {
|
|
||||||
font-size: 36rpx;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #1a1a1a;
|
|
||||||
letter-spacing: 1rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slot-capacity {
|
|
||||||
display: inline-flex;
|
|
||||||
align-self: flex-start;
|
|
||||||
|
|
||||||
.capacity-text {
|
|
||||||
font-size: 22rpx;
|
|
||||||
font-weight: 500;
|
|
||||||
padding: 4rpx 14rpx;
|
|
||||||
border-radius: 20rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.cap-open .capacity-text {
|
|
||||||
background: #f0faf3;
|
|
||||||
color: #4caf50;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.cap-almost .capacity-text {
|
|
||||||
background: #fff8ed;
|
|
||||||
color: #f59e0b;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.cap-full .capacity-text {
|
|
||||||
background: #fef0f0;
|
|
||||||
color: #ef4444;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.cap-closed .capacity-text {
|
|
||||||
background: #f5f5f5;
|
|
||||||
color: #999;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.slot-action {
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
min-width: 140rpx;
|
|
||||||
height: 68rpx;
|
|
||||||
border-radius: 34rpx;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 0 28rpx;
|
|
||||||
|
|
||||||
.btn-text {
|
|
||||||
font-size: 26rpx;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.btn-book {
|
|
||||||
background: #c9a87c;
|
|
||||||
|
|
||||||
.btn-text {
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.btn-disabled {
|
|
||||||
background: #f0f0f0;
|
|
||||||
|
|
||||||
.btn-text {
|
|
||||||
color: #bbb;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.booked-row {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-booked {
|
|
||||||
height: 52rpx;
|
|
||||||
padding: 0 20rpx;
|
|
||||||
background: #fff8ee;
|
|
||||||
border-radius: 26rpx;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
.badge-text {
|
|
||||||
font-size: 24rpx;
|
|
||||||
color: #c9a87c;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-cancel {
|
|
||||||
height: 52rpx;
|
|
||||||
padding: 0 16rpx;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
.btn-cancel-text {
|
|
||||||
font-size: 24rpx;
|
|
||||||
color: #ef4444;
|
|
||||||
font-weight: 500;
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,61 +1,77 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="studio-info">
|
<view class="studio-info">
|
||||||
<!-- Horizontal photo strip -->
|
<text class="section-title">来工作室坐坐</text>
|
||||||
<scroll-view v-if="studioInfo?.photos?.length" scroll-x class="photo-strip" :show-scrollbar="false">
|
<!-- Address + Chat row -->
|
||||||
<view class="photo-strip-inner">
|
|
||||||
<image v-for="(photo, idx) in studioInfo.photos" :key="idx" class="strip-photo" :src="photo" mode="aspectFill"
|
|
||||||
@tap="previewPhoto(idx)" />
|
|
||||||
</view>
|
|
||||||
</scroll-view>
|
|
||||||
|
|
||||||
<!-- Address + Phone row -->
|
|
||||||
<view class="location-row">
|
<view class="location-row">
|
||||||
<view class="location-left" @tap="handleAddressTap">
|
<view class="location-left" @tap="handleAddressTap">
|
||||||
<text class="location-icon">📍</text>
|
<view class="location-content">
|
||||||
|
<text class="location-label">场馆地址</text>
|
||||||
<text class="location-text">
|
<text class="location-text">
|
||||||
{{ studioInfo?.address || '深圳市宝安区西乡街道财富港 D 座 1203D' }}
|
{{ studioInfo?.address || '深圳市宝安区西乡街道财富港 D 座 1203D' }}
|
||||||
</text>
|
</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="phone-btn" @tap="handlePhoneTap">
|
|
||||||
<text class="phone-icon">📞</text>
|
|
||||||
</view>
|
</view>
|
||||||
|
<button class="chat-btn" open-type="contact">
|
||||||
|
<text>联系老师</text>
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Horizontal gallery -->
|
||||||
|
<view class="gallery-block">
|
||||||
|
<scroll-view scroll-x class="gallery-scroll" :show-scrollbar="false" enhanced>
|
||||||
|
<view class="gallery-track">
|
||||||
|
<view
|
||||||
|
v-for="(photo, idx) in galleryPhotos"
|
||||||
|
:key="photo"
|
||||||
|
class="gallery-item"
|
||||||
|
@tap="previewPhoto(idx)"
|
||||||
|
>
|
||||||
|
<image class="gallery-image" :src="photo" mode="aspectFill" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { StudioConfig } from '@mp-pilates/shared'
|
import { computed } from 'vue'
|
||||||
|
import {
|
||||||
|
DEFAULT_STUDIO_GALLERY_PHOTOS,
|
||||||
|
type StudioConfig,
|
||||||
|
} from '@mp-pilates/shared'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
studioInfo: StudioConfig | null
|
studioInfo: StudioConfig | null
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const galleryPhotos = computed(() => {
|
||||||
|
const photos = props.studioInfo?.photos?.filter(Boolean) ?? []
|
||||||
|
return photos.length ? photos : [...DEFAULT_STUDIO_GALLERY_PHOTOS]
|
||||||
|
})
|
||||||
|
|
||||||
function previewPhoto(index: number) {
|
function previewPhoto(index: number) {
|
||||||
if (!props.studioInfo?.photos?.length) return
|
|
||||||
uni.previewImage({
|
uni.previewImage({
|
||||||
current: index,
|
current: index,
|
||||||
urls: props.studioInfo.photos,
|
urls: galleryPhotos.value,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleAddressTap() {
|
function handleAddressTap() {
|
||||||
if (!props.studioInfo) return
|
const latitude = props.studioInfo?.latitude ?? 22.567048
|
||||||
|
const longitude = props.studioInfo?.longitude ?? 113.867227
|
||||||
|
const address = props.studioInfo?.address || '深圳市宝安区西乡街道财富港 D 座 1203D'
|
||||||
|
const name = props.studioInfo?.name || 'Focus Core'
|
||||||
|
|
||||||
const { latitude, longitude, address, name } = props.studioInfo
|
|
||||||
|
|
||||||
if (latitude && longitude) {
|
|
||||||
uni.openLocation({
|
uni.openLocation({
|
||||||
latitude,
|
latitude,
|
||||||
longitude,
|
longitude,
|
||||||
name: name || 'Focus Core',
|
name,
|
||||||
address,
|
address,
|
||||||
fail() {
|
fail() {
|
||||||
copyAddress()
|
copyAddress()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
} else {
|
|
||||||
copyAddress()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyAddress() {
|
function copyAddress() {
|
||||||
@@ -68,87 +84,18 @@ function copyAddress() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePhoneTap() {
|
|
||||||
const phone = props.studioInfo?.phone
|
|
||||||
if (!phone) return
|
|
||||||
uni.makePhoneCall({
|
|
||||||
phoneNumber: phone,
|
|
||||||
fail() {
|
|
||||||
uni.showToast({ title: '拨号失败', icon: 'none' })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.studio-info {
|
.studio-info { margin: 36rpx 32rpx 0; }
|
||||||
background: #ffffff;
|
.section-title { display: block; margin-bottom: 20rpx; font-size: 30rpx; font-weight: 500; color: #514943; }
|
||||||
}
|
.location-row { display: flex; align-items: center; justify-content: space-between; gap: 20rpx; margin-bottom: 20rpx; }
|
||||||
|
.location-left, .location-content { flex: 1; min-width: 0; }
|
||||||
/* ── Photo strip ── */
|
.location-label { display: block; font-size: 21rpx; color: #8b817b; margin-bottom: 6rpx; }
|
||||||
.photo-strip {
|
.location-text { font-size: 24rpx; color: #6f655e; line-height: 1.7; overflow-wrap: anywhere; }
|
||||||
width: 100%;
|
.chat-btn { flex-shrink: 0; margin: 0; padding: 0 20rpx; line-height: 64rpx; font-size: 23rpx; border: none; border-radius: 999rpx; background: #f0eae4; color: #78675c; &::after { border: none; } }
|
||||||
padding: 24rpx 0;
|
.gallery-scroll { width: 100%; }
|
||||||
}
|
.gallery-track { display: inline-flex; gap: 16rpx; }
|
||||||
|
.gallery-item { width: 264rpx; height: 180rpx; border-radius: 20rpx; overflow: hidden; flex-shrink: 0; background: #e9e1d8; }
|
||||||
.photo-strip-inner {
|
.gallery-image { width: 100%; height: 100%; }
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
gap: 16rpx;
|
|
||||||
padding: 0 24rpx;
|
|
||||||
width: max-content;
|
|
||||||
}
|
|
||||||
|
|
||||||
.strip-photo {
|
|
||||||
width: 240rpx;
|
|
||||||
height: 160rpx;
|
|
||||||
border-radius: 12rpx;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Location row ── */
|
|
||||||
.location-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 24rpx 32rpx 28rpx;
|
|
||||||
gap: 24rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.location-left {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12rpx;
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.location-icon {
|
|
||||||
font-size: 28rpx;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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>
|
</style>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="time-period-filter">
|
<view class="time-period-filter" :class="`time-period-filter--${variant}`">
|
||||||
<view
|
<view
|
||||||
v-for="tab in tabs"
|
v-for="tab in tabs"
|
||||||
:key="tab.key ?? 'all'"
|
:key="tab.key ?? 'all'"
|
||||||
class="tab-item"
|
class="tab-item"
|
||||||
:class="{ active: modelValue === tab.key }"
|
:class="[`tab-item--${variant}`, { active: modelValue === tab.key }]"
|
||||||
@tap="handleChange(tab.key)"
|
@tap="handleChange(tab.key)"
|
||||||
>
|
>
|
||||||
<text class="tab-label">{{ tab.label }}</text>
|
<text class="tab-label">{{ tab.label }}</text>
|
||||||
@@ -25,14 +25,17 @@ interface Tab {
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
modelValue: PeriodKey
|
modelValue: PeriodKey
|
||||||
|
variant?: 'default' | 'booking' | 'soft'
|
||||||
}
|
}
|
||||||
|
|
||||||
defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
change: [period: PeriodKey]
|
change: [period: PeriodKey]
|
||||||
'update:modelValue': [period: PeriodKey]
|
'update:modelValue': [period: PeriodKey]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const variant = computed(() => props.variant ?? 'default')
|
||||||
|
|
||||||
const tabs = computed<Tab[]>(() => [
|
const tabs = computed<Tab[]>(() => [
|
||||||
{ key: null, label: '全部' },
|
{ key: null, label: '全部' },
|
||||||
...Object.entries(TIME_PERIODS).map(([key, val]) => ({
|
...Object.entries(TIME_PERIODS).map(([key, val]) => ({
|
||||||
@@ -53,7 +56,12 @@ function handleChange(key: PeriodKey) {
|
|||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
padding: 0 24rpx;
|
padding: 0 24rpx;
|
||||||
border-bottom: 1rpx solid #f0ece8;
|
border-bottom: 1rpx solid $primary-border;
|
||||||
|
|
||||||
|
&.time-period-filter--booking {
|
||||||
|
background: rgba(252, 250, 248, 0.96);
|
||||||
|
border-bottom-color: rgba(192, 154, 137, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
.tab-item {
|
.tab-item {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -71,7 +79,7 @@ function handleChange(key: PeriodKey) {
|
|||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
.tab-label {
|
.tab-label {
|
||||||
color: #c9a87c;
|
color: $primary-dark;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,10 +91,44 @@ function handleChange(key: PeriodKey) {
|
|||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
width: 40rpx;
|
width: 40rpx;
|
||||||
height: 4rpx;
|
height: 4rpx;
|
||||||
background: #c9a87c;
|
background: $primary-dark;
|
||||||
border-radius: 2rpx;
|
border-radius: 2rpx;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.tab-item--booking {
|
||||||
|
.tab-label {
|
||||||
|
color: #9d8b83;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
.tab-label {
|
||||||
|
color: #8f6759;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
width: 48rpx;
|
||||||
|
height: 5rpx;
|
||||||
|
background: linear-gradient(90deg, #c8a899, #a87d6c);
|
||||||
|
border-radius: 999rpx;
|
||||||
|
box-shadow: 0 4rpx 10rpx rgba(168, 125, 108, 0.18);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-period-filter.time-period-filter--soft {
|
||||||
|
margin: 0 32rpx 24rpx; padding: 6rpx;
|
||||||
|
border: none; border-radius: 999rpx; background: #f0ece7;
|
||||||
|
.tab-item {
|
||||||
|
padding: 14rpx 0; border-radius: 999rpx;
|
||||||
|
.tab-label { font-size: 24rpx; color: #8b817b; }
|
||||||
|
&.active {
|
||||||
|
background: #fff;
|
||||||
|
.tab-label { color: #617d73; font-weight: 500; }
|
||||||
|
&::after { display: none; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
v-for="booking in displayedBookings"
|
v-for="booking in displayedBookings"
|
||||||
:key="booking.id"
|
:key="booking.id"
|
||||||
class="booking-card"
|
class="booking-card"
|
||||||
|
@tap="goToBookingDetail(booking.id)"
|
||||||
>
|
>
|
||||||
<!-- Date column -->
|
<!-- Date column -->
|
||||||
<view class="date-col">
|
<view class="date-col">
|
||||||
@@ -24,7 +25,6 @@
|
|||||||
<view class="details-col">
|
<view class="details-col">
|
||||||
<text class="card-name">{{ booking.membership.cardType.name }}</text>
|
<text class="card-name">{{ booking.membership.cardType.name }}</text>
|
||||||
<view class="time-row">
|
<view class="time-row">
|
||||||
<text class="time-icon">🕐</text>
|
|
||||||
<text class="time-text">
|
<text class="time-text">
|
||||||
{{ formatTime(booking.timeSlot.startTime) }} – {{ formatTime(booking.timeSlot.endTime) }}
|
{{ formatTime(booking.timeSlot.startTime) }} – {{ formatTime(booking.timeSlot.endTime) }}
|
||||||
</text>
|
</text>
|
||||||
@@ -72,6 +72,7 @@ function formatTime(timeStr: string): string {
|
|||||||
|
|
||||||
function statusLabel(status: BookingStatus): string {
|
function statusLabel(status: BookingStatus): string {
|
||||||
const map: Record<BookingStatus, string> = {
|
const map: Record<BookingStatus, string> = {
|
||||||
|
[BookingStatus.PENDING_CONFIRMATION]: '待确认',
|
||||||
[BookingStatus.CONFIRMED]: '已确认',
|
[BookingStatus.CONFIRMED]: '已确认',
|
||||||
[BookingStatus.CANCELLED]: '已取消',
|
[BookingStatus.CANCELLED]: '已取消',
|
||||||
[BookingStatus.COMPLETED]: '已完成',
|
[BookingStatus.COMPLETED]: '已完成',
|
||||||
@@ -81,6 +82,7 @@ function statusLabel(status: BookingStatus): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function statusDotClass(status: BookingStatus): string {
|
function statusDotClass(status: BookingStatus): string {
|
||||||
|
if (status === BookingStatus.PENDING_CONFIRMATION) return 'dot--pending'
|
||||||
if (status === BookingStatus.CONFIRMED) return 'dot--confirmed'
|
if (status === BookingStatus.CONFIRMED) return 'dot--confirmed'
|
||||||
if (status === BookingStatus.COMPLETED) return 'dot--completed'
|
if (status === BookingStatus.COMPLETED) return 'dot--completed'
|
||||||
if (status === BookingStatus.CANCELLED) return 'dot--cancelled'
|
if (status === BookingStatus.CANCELLED) return 'dot--cancelled'
|
||||||
@@ -88,6 +90,7 @@ function statusDotClass(status: BookingStatus): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function statusTextClass(status: BookingStatus): string {
|
function statusTextClass(status: BookingStatus): string {
|
||||||
|
if (status === BookingStatus.PENDING_CONFIRMATION) return 'text--pending'
|
||||||
if (status === BookingStatus.CONFIRMED) return 'text--confirmed'
|
if (status === BookingStatus.CONFIRMED) return 'text--confirmed'
|
||||||
if (status === BookingStatus.COMPLETED) return 'text--completed'
|
if (status === BookingStatus.COMPLETED) return 'text--completed'
|
||||||
if (status === BookingStatus.CANCELLED) return 'text--cancelled'
|
if (status === BookingStatus.CANCELLED) return 'text--cancelled'
|
||||||
@@ -97,133 +100,32 @@ function statusTextClass(status: BookingStatus): string {
|
|||||||
function goToBookings() {
|
function goToBookings() {
|
||||||
uni.navigateTo({ url: '/pages/profile/bookings' })
|
uni.navigateTo({ url: '/pages/profile/bookings' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function goToBookingDetail(id: string) {
|
||||||
|
uni.navigateTo({ url: `/pages/booking/detail?id=${id}` })
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.upcoming-section {
|
.upcoming-section { margin: 36rpx 32rpx 0; }
|
||||||
margin: 24rpx 24rpx 0;
|
.section-header { display: flex; align-items: center; justify-content: space-between; gap: 16rpx; margin-bottom: 20rpx; }
|
||||||
}
|
.section-title { font-size: 30rpx; font-weight: 500; color: #514943; }
|
||||||
|
.section-more { font-size: 23rpx; color: #8b817b; padding: 8rpx 0; }
|
||||||
.section-header {
|
.booking-card { display: flex; align-items: center; gap: 24rpx; margin-bottom: 16rpx; padding: 26rpx; border-radius: 28rpx; background: #fff; border: 1rpx solid #eee8e3; }
|
||||||
display: flex;
|
.date-col { display: flex; flex-direction: column; align-items: center; gap: 4rpx; width: 72rpx; flex-shrink: 0; }
|
||||||
align-items: center;
|
.date-day { font-size: 44rpx; font-weight: 400; line-height: 1.1; color: #617d73; }
|
||||||
justify-content: space-between;
|
.date-month, .date-weekday { font-size: 20rpx; color: #8b817b; }
|
||||||
margin-bottom: 16rpx;
|
.booking-divider { width: 1rpx; height: 80rpx; background: #eee8e3; flex-shrink: 0; }
|
||||||
}
|
.details-col { flex: 1; min-width: 0; }
|
||||||
|
.card-name { display: block; font-size: 26rpx; color: #514943; font-weight: 400; line-height: 1.5; margin-bottom: 8rpx; overflow-wrap: anywhere; }
|
||||||
.section-title {
|
.time-row { margin-bottom: 8rpx; }
|
||||||
font-size: 32rpx;
|
.time-text { font-size: 24rpx; color: #6f655e; font-variant-numeric: tabular-nums; }
|
||||||
font-weight: 700;
|
.status-row { display: flex; align-items: center; gap: 8rpx; }
|
||||||
color: #1a1a2e;
|
.status-dot { width: 8rpx; height: 8rpx; border-radius: 50%; background: #a5b8ab; }
|
||||||
}
|
.status-text { font-size: 21rpx; color: #617d73; }
|
||||||
|
.dot--pending { background: #b8a18b; }
|
||||||
.section-more {
|
.text--pending { color: #957c65; }
|
||||||
font-size: 26rpx;
|
.dot--cancelled { background: #b69589; }
|
||||||
color: #c9a87c;
|
.text--cancelled { color: #9c7a6e; }
|
||||||
}
|
.booking-arrow { font-size: 32rpx; color: #b2a79e; flex-shrink: 0; }
|
||||||
|
|
||||||
.booking-card {
|
|
||||||
background: #ffffff;
|
|
||||||
border-radius: 16rpx;
|
|
||||||
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.08);
|
|
||||||
padding: 28rpx 28rpx;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 24rpx;
|
|
||||||
margin-bottom: 16rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.date-col {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 88rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.date-day {
|
|
||||||
font-size: 52rpx;
|
|
||||||
font-weight: 800;
|
|
||||||
color: #1a1a2e;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.date-month {
|
|
||||||
font-size: 22rpx;
|
|
||||||
color: #c9a87c;
|
|
||||||
margin-top: 4rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.date-weekday {
|
|
||||||
font-size: 22rpx;
|
|
||||||
color: #999;
|
|
||||||
margin-top: 4rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.booking-divider {
|
|
||||||
width: 2rpx;
|
|
||||||
height: 80rpx;
|
|
||||||
background: #f0f0f0;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.details-col {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-name {
|
|
||||||
display: block;
|
|
||||||
font-size: 30rpx;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #1a1a2e;
|
|
||||||
margin-bottom: 10rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.time-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8rpx;
|
|
||||||
margin-bottom: 10rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.time-icon {
|
|
||||||
font-size: 24rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.time-text {
|
|
||||||
font-size: 26rpx;
|
|
||||||
color: #555;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-dot {
|
|
||||||
width: 12rpx;
|
|
||||||
height: 12rpx;
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dot--confirmed { background: #27ae60; }
|
|
||||||
.dot--completed { background: #3498db; }
|
|
||||||
.dot--cancelled { background: #e74c3c; }
|
|
||||||
.dot--default { background: #999; }
|
|
||||||
|
|
||||||
.status-text {
|
|
||||||
font-size: 24rpx;
|
|
||||||
color: #999;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text--confirmed { color: #27ae60; }
|
|
||||||
.text--completed { color: #3498db; }
|
|
||||||
.text--cancelled { color: #e74c3c; }
|
|
||||||
|
|
||||||
.booking-arrow {
|
|
||||||
font-size: 36rpx;
|
|
||||||
color: #ccc;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="user-card">
|
<view class="user-card">
|
||||||
<!-- Header: gradient background -->
|
|
||||||
<view class="user-card__header">
|
<view class="user-card__header">
|
||||||
<!-- Not logged in state -->
|
<!-- Not logged in state -->
|
||||||
<view v-if="!loggedIn" class="user-card__guest">
|
<view v-if="!loggedIn" class="user-card__guest">
|
||||||
<view class="user-card__avatar-wrap">
|
<view class="user-card__avatar-wrap">
|
||||||
<image class="user-card__avatar-img" src="/static/default-avatar.png" mode="aspectFill" />
|
<image class="user-card__avatar-img" src="/static/default-avatar.jpg" mode="aspectFill" />
|
||||||
</view>
|
</view>
|
||||||
<view class="user-card__guest-info">
|
<view class="user-card__guest-info">
|
||||||
<text class="user-card__guest-title">Hi,欢迎来到普拉提</text>
|
<text class="user-card__guest-title">欢迎来到工作室</text>
|
||||||
<text class="user-card__guest-sub">登录后查看个人数据</text>
|
<text class="user-card__guest-sub">登录后查看会员卡与练习记录</text>
|
||||||
</view>
|
</view>
|
||||||
<button class="user-card__login-btn" :loading="loading" @tap="handleLogin">
|
<button class="user-card__login-btn" :loading="loading" @tap="handleLogin">
|
||||||
微信登录
|
微信登录
|
||||||
@@ -17,7 +16,7 @@
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Logged in + profile loaded -->
|
<!-- Logged in + profile loaded -->
|
||||||
<view v-else-if="loggedIn && hasProfile" class="user-card__user">
|
<view v-else-if="loggedIn && hasProfile" class="user-card__user" @tap="emit('edit')">
|
||||||
<view class="user-card__avatar-wrap">
|
<view class="user-card__avatar-wrap">
|
||||||
<image
|
<image
|
||||||
class="user-card__avatar-img"
|
class="user-card__avatar-img"
|
||||||
@@ -25,16 +24,15 @@
|
|||||||
mode="aspectFill"
|
mode="aspectFill"
|
||||||
@error="onAvatarError"
|
@error="onAvatarError"
|
||||||
/>
|
/>
|
||||||
<view class="user-card__vip-badge" v-if="vipLevel">
|
|
||||||
<text class="user-card__vip-text">{{ vipLevel }}</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
</view>
|
||||||
<view class="user-card__info">
|
<view class="user-card__info">
|
||||||
<view class="user-card__name-row">
|
<view class="user-card__name-row">
|
||||||
<text class="user-card__nickname">{{ user!.nickname }}</text>
|
<text class="user-card__nickname">{{ user!.nickname }}</text>
|
||||||
|
<text v-if="hasMembership" class="user-card__member-label">会员</text>
|
||||||
</view>
|
</view>
|
||||||
<text v-if="maskedPhone" class="user-card__phone">{{ maskedPhone }}</text>
|
<text class="user-card__phone">{{ maskedPhone || '完善个人资料' }}</text>
|
||||||
</view>
|
</view>
|
||||||
|
<text class="user-card__edit">资料 ›</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Logged in but profile still loading -->
|
<!-- Logged in but profile still loading -->
|
||||||
@@ -54,25 +52,25 @@
|
|||||||
<!-- Stats row: shown only when profile is loaded -->
|
<!-- Stats row: shown only when profile is loaded -->
|
||||||
<view v-if="loggedIn && hasProfile" class="user-card__stats">
|
<view v-if="loggedIn && hasProfile" class="user-card__stats">
|
||||||
<view class="user-card__stat-item">
|
<view class="user-card__stat-item">
|
||||||
<text class="user-card__stat-value">{{ stats?.totalBookings ?? 0 }}</text>
|
<text class="user-card__stat-value">{{ stats?.totalBookings ?? '—' }}</text>
|
||||||
<text class="user-card__stat-label">总训练(次)</text>
|
<text class="user-card__stat-label">累计上课 · 节</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="user-card__stat-divider" />
|
<view class="user-card__stat-divider" />
|
||||||
<view class="user-card__stat-item">
|
<view class="user-card__stat-item">
|
||||||
<text class="user-card__stat-value">{{ stats?.monthBookings ?? 0 }}</text>
|
<text class="user-card__stat-value">{{ stats?.monthBookings ?? '—' }}</text>
|
||||||
<text class="user-card__stat-label">本月(次)</text>
|
<text class="user-card__stat-label">本月上课 · 节</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="user-card__stat-divider" />
|
<view class="user-card__stat-divider" />
|
||||||
<view class="user-card__stat-item">
|
<view class="user-card__stat-item">
|
||||||
<text class="user-card__stat-value">{{ remainingSessions }}</text>
|
<text class="user-card__stat-value">{{ remainingSessions }}</text>
|
||||||
<text class="user-card__stat-label">剩余课时(节)</text>
|
<text class="user-card__stat-label">剩余课时 · 节</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import type { UserProfileResponse, UserStatsResponse, MembershipWithCardType } from '@mp-pilates/shared'
|
import type { UserProfileResponse, UserStatsResponse, MembershipWithCardType } from '@mp-pilates/shared'
|
||||||
import { MembershipStatus } from '@mp-pilates/shared'
|
import { MembershipStatus } from '@mp-pilates/shared'
|
||||||
|
|
||||||
@@ -87,13 +85,24 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'login'): void
|
(e: 'login'): void
|
||||||
|
(e: 'edit'): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const avatarFailed = ref(false)
|
const avatarFailed = ref(false)
|
||||||
|
|
||||||
|
// 头像 URL 变化时重置加载错误状态,避免新头像因偶发加载失败而被永久隐藏
|
||||||
|
watch(
|
||||||
|
() => props.user?.avatarUrl,
|
||||||
|
(newUrl, oldUrl) => {
|
||||||
|
if (newUrl && newUrl !== oldUrl) {
|
||||||
|
avatarFailed.value = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
const avatarSrc = computed(() => {
|
const avatarSrc = computed(() => {
|
||||||
if (avatarFailed.value || !props.user?.avatarUrl) {
|
if (avatarFailed.value || !props.user?.avatarUrl) {
|
||||||
return '/static/default-avatar.png'
|
return '/static/default-avatar.jpg'
|
||||||
}
|
}
|
||||||
return props.user.avatarUrl
|
return props.user.avatarUrl
|
||||||
})
|
})
|
||||||
@@ -104,24 +113,25 @@ const maskedPhone = computed(() => {
|
|||||||
return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')
|
return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')
|
||||||
})
|
})
|
||||||
|
|
||||||
// Derive VIP level from active memberships count
|
|
||||||
const activeMemberships = computed(() =>
|
const activeMemberships = computed(() =>
|
||||||
props.memberships?.filter((m) => m.status === MembershipStatus.ACTIVE) ?? [],
|
props.memberships?.filter((m) => m.status === MembershipStatus.ACTIVE) ?? [],
|
||||||
)
|
)
|
||||||
|
|
||||||
const vipLevel = computed(() => {
|
const activeMembershipCount = computed(
|
||||||
const count = activeMemberships.value.length
|
() => props.user?.activeMembershipCount ?? activeMemberships.value.length,
|
||||||
if (count >= 3) return 'VIP3'
|
)
|
||||||
if (count >= 2) return 'VIP2'
|
|
||||||
if (count >= 1) return 'VIP1'
|
|
||||||
return null
|
|
||||||
})
|
|
||||||
|
|
||||||
// Sum remaining sessions from all active time-based memberships
|
const hasMembership = computed(() => activeMembershipCount.value > 0)
|
||||||
|
|
||||||
|
function toSafeCount(value: number | null | undefined): number {
|
||||||
|
return typeof value === 'number' && Number.isFinite(value) ? value : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sum remaining sessions from all active count-limited memberships.
|
||||||
const remainingSessions = computed(() =>
|
const remainingSessions = computed(() =>
|
||||||
activeMemberships.value
|
activeMemberships.value
|
||||||
.filter((m) => m.cardType.type === 'TIMES')
|
.filter((m) => m.remainingTimes !== null)
|
||||||
.reduce((sum, m) => sum + m.remainingCount, 0),
|
.reduce((sum, m) => sum + toSafeCount(m.remainingTimes), 0),
|
||||||
)
|
)
|
||||||
|
|
||||||
function onAvatarError() {
|
function onAvatarError() {
|
||||||
@@ -135,186 +145,28 @@ function handleLogin() {
|
|||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.user-card {
|
.user-card {
|
||||||
background: linear-gradient(135deg, #7c3aed 0%, #a855f7 50%, #ec4899 100%);
|
margin: 20rpx 32rpx 0; padding: 28rpx; background: #f2e9e3; border-radius: 32rpx;
|
||||||
border-radius: 0 0 40rpx 40rpx;
|
&__user, &__loading { display: flex; align-items: center; gap: 20rpx; }
|
||||||
overflow: hidden;
|
&__avatar-wrap { width: 108rpx; height: 108rpx; flex-shrink: 0; }
|
||||||
|
&__avatar-img { width: 100%; height: 100%; box-sizing: border-box; border-radius: 50%; border: 5rpx solid #fcf8f4; }
|
||||||
&__header {
|
&__info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 10rpx; }
|
||||||
padding: 60rpx $spacing-lg $spacing-lg;
|
&__name-row { display: flex; align-items: center; flex-wrap: wrap; gap: 10rpx; }
|
||||||
}
|
&__nickname { font-size: 36rpx; font-weight: 500; line-height: 1.4; color: #514943; overflow-wrap: anywhere; }
|
||||||
|
&__member-label { flex-shrink: 0; font-size: 19rpx; color: #8b6c5b; background: #fbf5ef; padding: 4rpx 12rpx; border-radius: 999rpx; }
|
||||||
// ── Guest state ──
|
&__phone { font-size: 24rpx; color: #8b7b70; }
|
||||||
&__guest {
|
&__edit { flex-shrink: 0; font-size: 22rpx; color: #8b7b70; padding: 16rpx 0 16rpx 8rpx; }
|
||||||
display: flex;
|
&__stats { display: flex; align-items: stretch; margin-top: 28rpx; padding-top: 26rpx; border-top: 1rpx solid #e3d7ce; }
|
||||||
align-items: center;
|
&__stat-item { flex: 1; min-width: 0; display: flex; flex-direction: column; align-items: center; gap: 10rpx; }
|
||||||
gap: $spacing-md;
|
&__stat-value { font-size: 40rpx; font-weight: 400; color: #6f5c50; line-height: 1.15; font-variant-numeric: tabular-nums; }
|
||||||
}
|
&__stat-label { font-size: 20rpx; color: #8b7b70; }
|
||||||
|
&__stat-divider { width: 1rpx; margin: 6rpx 0; background: #e3d7ce; }
|
||||||
&__guest-info {
|
&__guest { display: flex; align-items: center; flex-wrap: wrap; gap: 20rpx; }
|
||||||
flex: 1;
|
&__guest-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 10rpx; }
|
||||||
display: flex;
|
&__guest-title { font-size: 32rpx; font-weight: 500; color: #514943; }
|
||||||
flex-direction: column;
|
&__guest-sub { font-size: 23rpx; color: #8b7b70; line-height: 1.6; }
|
||||||
gap: $spacing-xs;
|
&__login-btn { width: 100%; height: 76rpx; line-height: 76rpx; margin: 4rpx 0 0; padding: 0; border: none; border-radius: 999rpx; background: #6b8276; color: #fff; font-size: 26rpx; font-weight: 400; &::after { border: none; } }
|
||||||
}
|
&__avatar-skeleton { width: 108rpx; height: 108rpx; border-radius: 50%; background: #e6d9ce; }
|
||||||
|
&__nickname-skeleton { width: 150rpx; height: 32rpx; border-radius: 8rpx; background: #e6d9ce; }
|
||||||
&__guest-title {
|
&__phone-skeleton { width: 180rpx; height: 22rpx; border-radius: 6rpx; background: #e6d9ce; }
|
||||||
font-size: 32rpx;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__guest-sub {
|
|
||||||
font-size: 24rpx;
|
|
||||||
color: rgba(255, 255, 255, 0.65);
|
|
||||||
}
|
|
||||||
|
|
||||||
&__login-btn {
|
|
||||||
flex-shrink: 0;
|
|
||||||
background: rgba(255, 255, 255, 0.2);
|
|
||||||
color: #ffffff;
|
|
||||||
font-size: 26rpx;
|
|
||||||
font-weight: 500;
|
|
||||||
border: none;
|
|
||||||
border-radius: $radius-lg;
|
|
||||||
padding: 0 $spacing-md;
|
|
||||||
height: 64rpx;
|
|
||||||
line-height: 64rpx;
|
|
||||||
min-width: 160rpx;
|
|
||||||
|
|
||||||
&::after {
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Logged-in user ──
|
|
||||||
&__user {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: $spacing-md;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__avatar-wrap {
|
|
||||||
position: relative;
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 120rpx;
|
|
||||||
height: 120rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__avatar-img {
|
|
||||||
width: 120rpx;
|
|
||||||
height: 120rpx;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: 4rpx solid rgba(255, 255, 255, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
&__vip-badge {
|
|
||||||
position: absolute;
|
|
||||||
bottom: -6rpx;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
background: linear-gradient(135deg, #fbbf24, #f59e0b);
|
|
||||||
border-radius: 20rpx;
|
|
||||||
padding: 2rpx 12rpx;
|
|
||||||
border: 2rpx solid #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__vip-text {
|
|
||||||
font-size: 18rpx;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #7c2d12;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__info {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__name-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: $spacing-sm;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__nickname {
|
|
||||||
font-size: 36rpx;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__phone {
|
|
||||||
font-size: 26rpx;
|
|
||||||
color: rgba(255, 255, 255, 0.75);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Loading state ──
|
|
||||||
&__loading {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: $spacing-md;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__avatar-skeleton {
|
|
||||||
width: 120rpx;
|
|
||||||
height: 120rpx;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: rgba(255, 255, 255, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
&__nickname-skeleton {
|
|
||||||
width: 160rpx;
|
|
||||||
height: 36rpx;
|
|
||||||
border-radius: $radius-sm;
|
|
||||||
background: rgba(255, 255, 255, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
&__phone-skeleton {
|
|
||||||
width: 120rpx;
|
|
||||||
height: 26rpx;
|
|
||||||
border-radius: $radius-sm;
|
|
||||||
background: rgba(255, 255, 255, 0.2);
|
|
||||||
margin-top: 8rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Stats row ──
|
|
||||||
&__stats {
|
|
||||||
display: flex;
|
|
||||||
align-items: stretch;
|
|
||||||
background: rgba(255, 255, 255, 0.15);
|
|
||||||
margin: 0 $spacing-lg $spacing-lg;
|
|
||||||
border-radius: $radius-lg;
|
|
||||||
padding: $spacing-md 0;
|
|
||||||
backdrop-filter: blur(10rpx);
|
|
||||||
}
|
|
||||||
|
|
||||||
&__stat-item {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: $spacing-xs 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__stat-divider {
|
|
||||||
width: 1rpx;
|
|
||||||
background: rgba(255, 255, 255, 0.3);
|
|
||||||
margin: $spacing-xs 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__stat-value {
|
|
||||||
font-size: 44rpx;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #ffffff;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__stat-label {
|
|
||||||
font-size: 22rpx;
|
|
||||||
color: rgba(255, 255, 255, 0.8);
|
|
||||||
margin-top: 6rpx;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "普拉提约课",
|
"name": "普拉提约课",
|
||||||
"appid": "",
|
"appid": "wx3e7a133d2305fa2c",
|
||||||
"description": "普拉提工作室约课小程序",
|
"description": "普拉提工作室约课小程序",
|
||||||
"versionName": "0.1.0",
|
"versionName": "0.1.0",
|
||||||
"versionCode": "100",
|
"versionCode": "100",
|
||||||
"transformPx": false,
|
"transformPx": false,
|
||||||
"mp-weixin": {
|
"mp-weixin": {
|
||||||
"appid": "",
|
"appid": "wx3e7a133d2305fa2c",
|
||||||
"setting": {
|
"setting": {
|
||||||
"urlCheck": false,
|
"urlCheck": false,
|
||||||
"es6": true,
|
"es6": true,
|
||||||
|
|||||||
@@ -1,88 +1,154 @@
|
|||||||
{
|
{
|
||||||
|
"easycom": {
|
||||||
|
"autoscan": true
|
||||||
|
},
|
||||||
"pages": [
|
"pages": [
|
||||||
{
|
{
|
||||||
"path": "pages/home/index",
|
"path": "pages/home/index",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationBarTitleText": "首页",
|
|
||||||
"navigationStyle": "custom"
|
"navigationStyle": "custom"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/booking/index",
|
"path": "pages/booking/index",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationBarTitleText": "预约课程"
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/booking/detail",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/profile/index",
|
"path": "pages/profile/index",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationBarTitleText": "我的"
|
"navigationStyle": "custom"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/card/detail",
|
"path": "pages/card/detail",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationBarTitleText": "购买会员卡"
|
"navigationStyle": "custom"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/profile/membership",
|
"path": "pages/profile/membership",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationBarTitleText": "我的会员卡"
|
"navigationStyle": "custom"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/profile/bookings",
|
"path": "pages/profile/bookings",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationBarTitleText": "我的预约"
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/profile/teaching-schedule",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/profile/info",
|
"path": "pages/profile/info",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationBarTitleText": "个人信息"
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/profile/invite",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/teacher/detail",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/admin/index",
|
"path": "pages/admin/index",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationBarTitleText": "管理中心"
|
"navigationStyle": "custom"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/admin/week-template",
|
"path": "pages/admin/bookings",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationBarTitleText": "排课设置"
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/admin/schedule",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/admin/slot-adjust",
|
"path": "pages/admin/slot-adjust",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationBarTitleText": "时段调整"
|
"navigationStyle": "custom"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/admin/members",
|
"path": "pages/admin/members",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationBarTitleText": "会员管理"
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/admin/member-detail",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/admin/member-edit",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/admin/member-supplement",
|
||||||
|
"style": { "navigationStyle": "custom" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/admin/member-arrange",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/admin/orders",
|
"path": "pages/admin/orders",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationBarTitleText": "订单管理"
|
"navigationStyle": "custom"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/admin/card-types",
|
"path": "pages/admin/card-types",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationBarTitleText": "卡种管理"
|
"navigationStyle": "custom"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/admin/studio",
|
"path": "pages/admin/studio",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationBarTitleText": "工作室设置"
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/admin/flash-sales",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/flash-sale/detail",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -96,7 +162,7 @@
|
|||||||
"color": "#999999",
|
"color": "#999999",
|
||||||
"selectedColor": "#1a1a2e",
|
"selectedColor": "#1a1a2e",
|
||||||
"backgroundColor": "#ffffff",
|
"backgroundColor": "#ffffff",
|
||||||
"borderStyle": "black",
|
"borderStyle": "white",
|
||||||
"list": [
|
"list": [
|
||||||
{
|
{
|
||||||
"pagePath": "pages/home/index",
|
"pagePath": "pages/home/index",
|
||||||
|
|||||||
806
packages/app/src/pages/admin/bookings.vue
Normal file
806
packages/app/src/pages/admin/bookings.vue
Normal file
@@ -0,0 +1,806 @@
|
|||||||
|
<template>
|
||||||
|
<view class="admin-bookings-page" :style="{ paddingTop: navBarHeight }">
|
||||||
|
<CustomNavBar title="课程管理" show-back />
|
||||||
|
|
||||||
|
<!-- Stats row -->
|
||||||
|
<view class="stats-row">
|
||||||
|
<view class="stat-item" @tap="switchTab(null)">
|
||||||
|
<text class="stat-num">{{ stats.total }}</text>
|
||||||
|
<text class="stat-label">全部</text>
|
||||||
|
</view>
|
||||||
|
<view class="stat-item stat-item--pending" @tap="switchTab('PENDING_CONFIRMATION')">
|
||||||
|
<text class="stat-num">{{ stats.pending }}</text>
|
||||||
|
<text class="stat-label">待确认</text>
|
||||||
|
</view>
|
||||||
|
<view class="stat-item stat-item--confirmed" @tap="switchTab('CONFIRMED')">
|
||||||
|
<text class="stat-num">{{ stats.confirmed }}</text>
|
||||||
|
<text class="stat-label">已确认</text>
|
||||||
|
</view>
|
||||||
|
<view class="stat-item stat-item--completed" @tap="switchTab('COMPLETED')">
|
||||||
|
<text class="stat-num">{{ stats.completed }}</text>
|
||||||
|
<text class="stat-label">已完成</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Tab filter bar -->
|
||||||
|
<view class="filter-bar">
|
||||||
|
<view
|
||||||
|
v-for="tab in filterTabs"
|
||||||
|
:key="tab.value ?? 'all'"
|
||||||
|
class="filter-tab"
|
||||||
|
:class="{ active: activeFilter === tab.value }"
|
||||||
|
@tap="switchTab(tab.value)"
|
||||||
|
>
|
||||||
|
<text class="filter-tab-text">{{ tab.label }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Booking list -->
|
||||||
|
<scroll-view
|
||||||
|
class="scroll"
|
||||||
|
scroll-y
|
||||||
|
refresher-enabled
|
||||||
|
:refresher-triggered="refreshing"
|
||||||
|
@refresherrefresh="onRefresh"
|
||||||
|
>
|
||||||
|
<!-- Loading -->
|
||||||
|
<view v-if="loading && !refreshing" class="loading-wrap">
|
||||||
|
<view v-for="i in 4" :key="i" class="skeleton-card">
|
||||||
|
<view class="skeleton-stripe" />
|
||||||
|
<view class="skeleton-body">
|
||||||
|
<view class="skeleton-line skeleton-line--long" />
|
||||||
|
<view class="skeleton-line skeleton-line--medium" />
|
||||||
|
<view class="skeleton-line skeleton-line--short" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Empty -->
|
||||||
|
<view v-else-if="bookings.length === 0" class="empty-wrap">
|
||||||
|
<view class="empty-icon-circle">
|
||||||
|
<text class="empty-icon-text">📋</text>
|
||||||
|
</view>
|
||||||
|
<text class="empty-title">暂无预约</text>
|
||||||
|
<text class="empty-sub">当前筛选条件下没有预约记录</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Booking cards -->
|
||||||
|
<view v-else class="list">
|
||||||
|
<view
|
||||||
|
v-for="booking in bookings"
|
||||||
|
:key="booking.id"
|
||||||
|
class="booking-card"
|
||||||
|
@tap="goDetail(booking)"
|
||||||
|
>
|
||||||
|
<!-- Left stripe -->
|
||||||
|
<view class="booking-stripe" :class="bookingStatusStripeClass(booking.status)" />
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<view class="booking-content">
|
||||||
|
<!-- Header row -->
|
||||||
|
<view class="booking-header">
|
||||||
|
<view class="student-info">
|
||||||
|
<text class="student-name">{{ booking.user?.nickname || '匿名用户' }}</text>
|
||||||
|
<text v-if="booking.user?.phone" class="student-phone">{{ booking.user.phone }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="status-badge" :class="bookingStatusBadgeClass(booking.status)">
|
||||||
|
<text class="status-text">{{ bookingStatusLabel(booking.status) }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Course info -->
|
||||||
|
<view class="course-info">
|
||||||
|
<text class="course-date">{{ formatDateDisplay(booking.timeSlot.date) }}</text>
|
||||||
|
<text class="course-time">{{ booking.timeSlot.startTime.slice(0, 5) }} - {{ booking.timeSlot.endTime.slice(0, 5) }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Card type -->
|
||||||
|
<view class="card-info">
|
||||||
|
<text class="card-label">使用卡种</text>
|
||||||
|
<text class="card-name">{{ booking.membership?.cardType?.name }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Action buttons -->
|
||||||
|
<view v-if="booking.status === 'PENDING_CONFIRMATION'" class="action-row">
|
||||||
|
<view class="action-btn action-btn--confirm" @tap.stop="handleConfirm(booking)">
|
||||||
|
<text class="action-btn-text">确认预约</text>
|
||||||
|
</view>
|
||||||
|
<view class="action-btn action-btn--cancel" @tap.stop="handleCancel(booking)">
|
||||||
|
<text class="action-btn-text">取消</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-else-if="booking.status === 'CONFIRMED'" class="action-row">
|
||||||
|
<view class="action-btn action-btn--complete" @tap.stop="handleComplete(booking)">
|
||||||
|
<text class="action-btn-text">核销完成</text>
|
||||||
|
</view>
|
||||||
|
<view class="action-btn action-btn--noshow" @tap.stop="handleNoShow(booking)">
|
||||||
|
<text class="action-btn-text">标记未到</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Timeline preview -->
|
||||||
|
<view v-if="getHistory(booking.id).length > 0" class="timeline-preview">
|
||||||
|
<view
|
||||||
|
v-for="(h, idx) in getHistory(booking.id).slice(-2)"
|
||||||
|
:key="idx"
|
||||||
|
class="timeline-item"
|
||||||
|
>
|
||||||
|
<text class="timeline-dot" :class="bookingTimelineDotClass(h.toStatus)" />
|
||||||
|
<text class="timeline-text">{{ formatTimelineText(h) }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Load more / pagination -->
|
||||||
|
<view v-if="bookings.length > 0 && hasMore" class="load-more" @tap="loadMore">
|
||||||
|
<text class="load-more-text">加载更多</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="scroll-bottom-spacer" />
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { onShow } from '@dcloudio/uni-app'
|
||||||
|
import type { BookingWithUser, BookingStatusHistory } from '@mp-pilates/shared'
|
||||||
|
import { BookingStatus } from '@mp-pilates/shared'
|
||||||
|
import { useBookingStore } from '../../stores/booking'
|
||||||
|
import { getSystemLayout } from '../../utils/system'
|
||||||
|
import {
|
||||||
|
formatDateDisplay,
|
||||||
|
bookingStatusLabel,
|
||||||
|
bookingStatusBadgeClass,
|
||||||
|
bookingStatusStripeClass,
|
||||||
|
bookingTimelineDotClass,
|
||||||
|
} from '../../utils/booking-helpers'
|
||||||
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
|
|
||||||
|
// ─── Store & Nav ──────────────────────────────────────────────────────────
|
||||||
|
const bookingStore = useBookingStore()
|
||||||
|
|
||||||
|
const navBarHeight = ref('64px')
|
||||||
|
const refreshing = ref(false)
|
||||||
|
const loading = ref(false)
|
||||||
|
const hasLoadedOnce = ref(false)
|
||||||
|
|
||||||
|
// ─── Filter state ─────────────────────────────────────────────────────────
|
||||||
|
type FilterValue = string | null
|
||||||
|
|
||||||
|
const filterTabs: { label: string; value: FilterValue }[] = [
|
||||||
|
{ label: '全部', value: null },
|
||||||
|
{ label: '待确认', value: 'PENDING_CONFIRMATION' },
|
||||||
|
{ label: '已确认', value: 'CONFIRMED' },
|
||||||
|
{ label: '已完成', value: 'COMPLETED' },
|
||||||
|
{ label: '已取消', value: 'CANCELLED' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const activeFilter = ref<FilterValue>(null)
|
||||||
|
|
||||||
|
// ─── Pagination ───────────────────────────────────────────────────────────
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const pageSize = 20
|
||||||
|
const hasMore = ref(false)
|
||||||
|
const totalCount = ref(0)
|
||||||
|
|
||||||
|
// ─── Data ────────────────────────────────────────────────────────────────
|
||||||
|
const bookings = ref<BookingWithUser[]>([])
|
||||||
|
const allBookingsCache = ref<BookingWithUser[]>([]) // cache for stats
|
||||||
|
const historyMap = ref<Record<string, BookingStatusHistory[]>>({})
|
||||||
|
|
||||||
|
// ─── Computed stats ──────────────────────────────────────────────────────
|
||||||
|
const stats = computed(() => {
|
||||||
|
const cache = allBookingsCache.value
|
||||||
|
return {
|
||||||
|
total: cache.length,
|
||||||
|
pending: cache.filter((b) => b.status === BookingStatus.PENDING_CONFIRMATION).length,
|
||||||
|
confirmed: cache.filter((b) => b.status === BookingStatus.CONFIRMED).length,
|
||||||
|
completed: cache.filter(
|
||||||
|
(b) => b.status === BookingStatus.COMPLETED || b.status === BookingStatus.NO_SHOW,
|
||||||
|
).length,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── Timeline helpers ─────────────────────────────────────────────────────
|
||||||
|
function getHistory(bookingId: string): BookingStatusHistory[] {
|
||||||
|
return historyMap.value[bookingId] || []
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimelineText(h: BookingStatusHistory): string {
|
||||||
|
const d = new Date(h.createdAt)
|
||||||
|
const time = `${d.getMonth() + 1}月${d.getDate()}日 ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
||||||
|
return `${time} ${h.remark || bookingStatusLabel(h.toStatus)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Data loading ─────────────────────────────────────────────────────────
|
||||||
|
async function loadBookings(append = false, opts: { silent?: boolean } = {}) {
|
||||||
|
if (loading.value) return
|
||||||
|
if (!opts.silent) loading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const page = append ? currentPage.value + 1 : 1
|
||||||
|
const result = await bookingStore.fetchAllAdminBookings(page, pageSize, activeFilter.value ?? undefined)
|
||||||
|
|
||||||
|
if (append) {
|
||||||
|
bookings.value = [...bookings.value, ...(result.data as BookingWithUser[])]
|
||||||
|
currentPage.value = page
|
||||||
|
} else {
|
||||||
|
bookings.value = result.data as BookingWithUser[]
|
||||||
|
currentPage.value = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
totalCount.value = result.total
|
||||||
|
hasMore.value = bookings.value.length < result.total
|
||||||
|
|
||||||
|
// Fetch history for each booking. Skip in silent mode — history is only
|
||||||
|
// shown as a small inline preview, and the detail page has the full one.
|
||||||
|
if (!append && !opts.silent) {
|
||||||
|
await Promise.all(
|
||||||
|
bookings.value.map((b) => fetchHistory(b.id)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update cache for stats
|
||||||
|
if (!append && activeFilter.value === null) {
|
||||||
|
allBookingsCache.value = bookings.value
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Load bookings failed:', err)
|
||||||
|
if (!opts.silent) uni.showToast({ title: '加载失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
if (!opts.silent) loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchHistory(bookingId: string) {
|
||||||
|
try {
|
||||||
|
const history = await bookingStore.fetchBookingHistory(bookingId)
|
||||||
|
historyMap.value[bookingId] = history
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Fetch history failed:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAllForStats() {
|
||||||
|
try {
|
||||||
|
// Load all statuses for stats display
|
||||||
|
const result = await bookingStore.fetchAllAdminBookings(1, 200, undefined)
|
||||||
|
allBookingsCache.value = result.data as BookingWithUser[]
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Load stats failed:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onRefresh() {
|
||||||
|
refreshing.value = true
|
||||||
|
await Promise.all([loadBookings(false), loadAllForStats()])
|
||||||
|
refreshing.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMore() {
|
||||||
|
if (!hasMore.value) return
|
||||||
|
await loadBookings(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tab switching ───────────────────────────────────────────────────────
|
||||||
|
function switchTab(value: FilterValue) {
|
||||||
|
if (activeFilter.value === value) return
|
||||||
|
activeFilter.value = value
|
||||||
|
loadBookings(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Actions ──────────────────────────────────────────────────────────────
|
||||||
|
async function handleConfirm(booking: BookingWithUser) {
|
||||||
|
uni.showModal({
|
||||||
|
title: '确认预约',
|
||||||
|
content: `确认 ${booking.user?.nickname} 的预约?确认后将扣除会员次数。`,
|
||||||
|
confirmText: '确认',
|
||||||
|
success: async (res) => {
|
||||||
|
if (!res.confirm) return
|
||||||
|
uni.showLoading({ title: '处理中...' })
|
||||||
|
try {
|
||||||
|
await bookingStore.confirmBooking(booking.id)
|
||||||
|
uni.hideLoading()
|
||||||
|
uni.showToast({ title: '已确认', icon: 'success' })
|
||||||
|
await onRefresh()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
uni.hideLoading()
|
||||||
|
const msg = err instanceof Error ? err.message : '操作失败'
|
||||||
|
uni.showToast({ title: msg, icon: 'none' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleComplete(booking: BookingWithUser) {
|
||||||
|
uni.showModal({
|
||||||
|
title: '核销完成',
|
||||||
|
content: `标记 ${booking.user?.nickname} 的课程为已完成?`,
|
||||||
|
confirmText: '确认',
|
||||||
|
success: async (res) => {
|
||||||
|
if (!res.confirm) return
|
||||||
|
uni.showLoading({ title: '处理中...' })
|
||||||
|
try {
|
||||||
|
await bookingStore.completeBooking(booking.id)
|
||||||
|
uni.hideLoading()
|
||||||
|
uni.showToast({ title: '已核销', icon: 'success' })
|
||||||
|
await onRefresh()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
uni.hideLoading()
|
||||||
|
const msg = err instanceof Error ? err.message : '操作失败'
|
||||||
|
uni.showToast({ title: msg, icon: 'none' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleNoShow(booking: BookingWithUser) {
|
||||||
|
uni.showModal({
|
||||||
|
title: '标记未到',
|
||||||
|
content: `标记 ${booking.user?.nickname} 的课程为未出席?`,
|
||||||
|
confirmText: '确认',
|
||||||
|
success: async (res) => {
|
||||||
|
if (!res.confirm) return
|
||||||
|
uni.showLoading({ title: '处理中...' })
|
||||||
|
try {
|
||||||
|
await bookingStore.markNoShow(booking.id)
|
||||||
|
uni.hideLoading()
|
||||||
|
uni.showToast({ title: '已标记', icon: 'success' })
|
||||||
|
await onRefresh()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
uni.hideLoading()
|
||||||
|
const msg = err instanceof Error ? err.message : '操作失败'
|
||||||
|
uni.showToast({ title: msg, icon: 'none' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCancel(booking: BookingWithUser) {
|
||||||
|
uni.showModal({
|
||||||
|
title: '取消预约',
|
||||||
|
content: `取消 ${booking.user?.nickname} 的预约?`,
|
||||||
|
confirmText: '确认取消',
|
||||||
|
confirmColor: '#ef4444',
|
||||||
|
success: async (res) => {
|
||||||
|
if (!res.confirm) return
|
||||||
|
uni.showLoading({ title: '处理中...' })
|
||||||
|
try {
|
||||||
|
await bookingStore.cancelBooking(booking.id)
|
||||||
|
uni.hideLoading()
|
||||||
|
uni.showToast({ title: '已取消', icon: 'success' })
|
||||||
|
await onRefresh()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
uni.hideLoading()
|
||||||
|
const msg = err instanceof Error ? err.message : '操作失败'
|
||||||
|
uni.showToast({ title: msg, icon: 'none' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function goDetail(booking: BookingWithUser) {
|
||||||
|
uni.navigateTo({
|
||||||
|
url: `/pages/booking/detail?id=${booking.id}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Lifecycle ────────────────────────────────────────────────────────────
|
||||||
|
onMounted(() => {
|
||||||
|
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
|
||||||
|
loadBookings(false)
|
||||||
|
loadAllForStats()
|
||||||
|
hasLoadedOnce.value = true
|
||||||
|
})
|
||||||
|
|
||||||
|
// After returning from booking detail (where status may have changed),
|
||||||
|
// re-sync the list with the server. Local row actions still call onRefresh
|
||||||
|
// directly — this onShow is the safety net for the navigate-back path.
|
||||||
|
// Uses silent mode so the list stays visible (no skeleton flash).
|
||||||
|
onShow(() => {
|
||||||
|
if (!hasLoadedOnce.value) return
|
||||||
|
// Skip while a refresh is already in flight to avoid overlap.
|
||||||
|
if (refreshing.value || loading.value) return
|
||||||
|
Promise.all([
|
||||||
|
loadBookings(false, { silent: true }),
|
||||||
|
loadAllForStats(),
|
||||||
|
]).catch(() => {
|
||||||
|
// Errors are non-fatal here — list keeps showing stale data until
|
||||||
|
// the next explicit refresh.
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.admin-bookings-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: $primary-bg;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Stats row ──────────────────────────────────────── */
|
||||||
|
.stats-row {
|
||||||
|
display: flex;
|
||||||
|
background: #fff;
|
||||||
|
padding: 24rpx 16rpx;
|
||||||
|
gap: 8rpx;
|
||||||
|
border-bottom: 1rpx solid $primary-border;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6rpx;
|
||||||
|
padding: 16rpx 8rpx;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
transition: background 0.15s;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background: rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-num {
|
||||||
|
font-size: 36rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #4A4035;
|
||||||
|
font-family: 'DIN Alternate', 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #A09080;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item--pending .stat-num { color: #f59e0b; }
|
||||||
|
.stat-item--confirmed .stat-num { color: $primary-dark; }
|
||||||
|
.stat-item--completed .stat-num { color: #66bb6a; }
|
||||||
|
|
||||||
|
/* ── Filter bar ────────────────────────────────────── */
|
||||||
|
.filter-bar {
|
||||||
|
display: flex;
|
||||||
|
background: #fff;
|
||||||
|
padding: 0 16rpx 16rpx;
|
||||||
|
gap: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-tab {
|
||||||
|
padding: 10rpx 20rpx;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
background: rgba(0, 0, 0, 0.04);
|
||||||
|
transition: all 0.15s;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: $primary-dark;
|
||||||
|
|
||||||
|
.filter-tab-text {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-tab-text {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #666;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Scroll ──────────────────────────────────────────── */
|
||||||
|
.scroll {
|
||||||
|
flex: 1;
|
||||||
|
height: calc(100vh - 300rpx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Loading skeleton ────────────────────────────────── */
|
||||||
|
.loading-wrap {
|
||||||
|
padding: 24rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-card {
|
||||||
|
border-radius: 16rpx;
|
||||||
|
background: #fff;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-stripe {
|
||||||
|
width: 8rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-body {
|
||||||
|
flex: 1;
|
||||||
|
padding: 28rpx 24rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-line {
|
||||||
|
height: 28rpx;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
|
||||||
|
background-size: 400% 100%;
|
||||||
|
animation: shimmer 1.4s infinite;
|
||||||
|
|
||||||
|
&--long { width: 60%; }
|
||||||
|
&--medium { width: 40%; }
|
||||||
|
&--short { width: 30%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Empty ───────────────────────────────────────────── */
|
||||||
|
.empty-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 120rpx 40rpx;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon-circle {
|
||||||
|
width: 140rpx;
|
||||||
|
height: 140rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: $primary-border;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon-text {
|
||||||
|
font-size: 56rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-title {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-sub {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── List ────────────────────────────────────────────── */
|
||||||
|
.list {
|
||||||
|
padding: 20rpx 24rpx 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Booking card ────────────────────────────────────── */
|
||||||
|
.booking-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2rpx 16rpx rgba(0, 0, 0, 0.05);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.booking-stripe {
|
||||||
|
width: 8rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&.stripe--pending { background: #f59e0b; }
|
||||||
|
&.stripe--confirmed { background: $primary-dark; }
|
||||||
|
&.stripe--completed { background: #66bb6a; }
|
||||||
|
&.stripe--cancelled { background: #e0e0e0; }
|
||||||
|
&.stripe--noshow { background: #ef5350; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.booking-content {
|
||||||
|
flex: 1;
|
||||||
|
padding: 24rpx 20rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.booking-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-name {
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-phone {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status badge */
|
||||||
|
.status-badge {
|
||||||
|
padding: 8rpx 18rpx;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&.badge--pending { background: rgba(245, 158, 11, 0.12); }
|
||||||
|
&.badge--confirmed { background: rgba(201, 168, 124, 0.12); }
|
||||||
|
&.badge--completed { background: rgba(102, 187, 106, 0.12); }
|
||||||
|
&.badge--cancelled { background: rgba(0, 0, 0, 0.04); }
|
||||||
|
&.badge--noshow { background: rgba(239, 83, 80, 0.1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-text {
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
.badge--pending & { color: #f59e0b; }
|
||||||
|
.badge--confirmed & { color: $primary-dark; }
|
||||||
|
.badge--completed & { color: #66bb6a; }
|
||||||
|
.badge--cancelled & { color: #bbb; }
|
||||||
|
.badge--noshow & { color: #ef5350; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Course info */
|
||||||
|
.course-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-date {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-time {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card info */
|
||||||
|
.card-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-label {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #bbb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-name {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #666;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Action buttons */
|
||||||
|
.action-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 12rpx;
|
||||||
|
padding-top: 8rpx;
|
||||||
|
border-top: 1rpx solid #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 16rpx 0;
|
||||||
|
border-radius: 24rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--confirm {
|
||||||
|
background: linear-gradient(135deg, $primary-color, $primary-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--cancel {
|
||||||
|
background: rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--complete {
|
||||||
|
background: linear-gradient(135deg, #66bb6a, #4caf50);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--noshow {
|
||||||
|
background: rgba(239, 83, 80, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn-text {
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
.action-btn--cancel & {
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn--noshow & {
|
||||||
|
color: #ef5350;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Timeline preview */
|
||||||
|
.timeline-preview {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6rpx;
|
||||||
|
padding-top: 8rpx;
|
||||||
|
border-top: 1rpx solid #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-dot {
|
||||||
|
width: 8rpx;
|
||||||
|
height: 8rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&.dot--pending { background: #f59e0b; }
|
||||||
|
&.dot--confirmed { background: $primary-dark; }
|
||||||
|
&.dot--completed { background: #66bb6a; }
|
||||||
|
&.dot--cancelled { background: #e0e0e0; }
|
||||||
|
&.dot--noshow { background: #ef5350; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-text {
|
||||||
|
font-size: 20rpx;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Load more */
|
||||||
|
.load-more {
|
||||||
|
padding: 32rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-more-text {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: $primary-dark;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bottom spacer */
|
||||||
|
.scroll-bottom-spacer {
|
||||||
|
height: 48rpx;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="page">
|
<view class="page" :style="{ paddingTop: navBarHeight }">
|
||||||
<!-- Add button -->
|
<CustomNavBar title="卡种管理" show-back />
|
||||||
|
<!-- Toolbar -->
|
||||||
<view class="toolbar">
|
<view class="toolbar">
|
||||||
<text class="toolbar-hint">共 {{ cardTypes.length }} 个卡种</text>
|
<text class="toolbar-hint">共 {{ cardTypes.length }} 个卡种</text>
|
||||||
<view class="add-btn" @tap="openAdd">
|
<view class="add-btn" @tap="openAdd">
|
||||||
@@ -64,28 +65,37 @@
|
|||||||
|
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
<view class="ct-actions">
|
<view class="ct-actions">
|
||||||
<view class="ct-action-btn edit-btn" @tap="openEdit(ct)">
|
<view class="ct-action-btn edit-btn" @tap.stop="openEdit(ct)">
|
||||||
<text class="ct-action-text">编辑</text>
|
<text class="ct-action-text">编辑</text>
|
||||||
</view>
|
</view>
|
||||||
<view
|
<view
|
||||||
class="ct-action-btn toggle-btn"
|
class="ct-action-btn toggle-btn"
|
||||||
:class="ct.isActive ? 'toggle-off' : 'toggle-on'"
|
:class="ct.isActive ? 'toggle-off' : 'toggle-on'"
|
||||||
@tap="toggleActive(ct)"
|
@tap.stop="confirmToggle(ct)"
|
||||||
>
|
>
|
||||||
<text class="ct-action-text">{{ ct.isActive ? '下架' : '上架' }}</text>
|
<text class="ct-action-text">{{ ct.isActive ? '下架' : '上架' }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="ct-action-btn delete-btn" @tap="confirmDelete(ct)">
|
<view class="ct-action-btn delete-btn" @tap.stop="confirmDelete(ct)">
|
||||||
<text class="ct-action-text">删除</text>
|
<text class="ct-action-text">删除</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Add / Edit modal -->
|
<!-- ──────── Add / Edit modal ──────── -->
|
||||||
<view v-if="showModal" class="modal-mask" @tap.self="closeModal">
|
<view v-if="showModal" class="modal-mask" @tap.stop="closeModal">
|
||||||
<scroll-view scroll-y class="modal">
|
<view class="modal-container" @tap.stop>
|
||||||
|
<scroll-view scroll-y class="modal-scroll">
|
||||||
|
<!-- Header -->
|
||||||
|
<view class="modal-header">
|
||||||
<text class="modal-title">{{ editTarget ? '编辑卡种' : '新增卡种' }}</text>
|
<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">
|
||||||
<view class="modal-field">
|
<view class="modal-field">
|
||||||
<text class="modal-label">卡种名称</text>
|
<text class="modal-label">卡种名称</text>
|
||||||
<input
|
<input
|
||||||
@@ -103,7 +113,7 @@
|
|||||||
:range="typeOptions"
|
:range="typeOptions"
|
||||||
range-key="label"
|
range-key="label"
|
||||||
:value="form.typeIdx"
|
:value="form.typeIdx"
|
||||||
@change="(e: any) => form.typeIdx = Number(e.detail.value)"
|
@change="onTypeChange"
|
||||||
>
|
>
|
||||||
<view class="picker-display">
|
<view class="picker-display">
|
||||||
<text class="picker-text">{{ typeOptions[form.typeIdx].label }}</text>
|
<text class="picker-text">{{ typeOptions[form.typeIdx].label }}</text>
|
||||||
@@ -140,7 +150,7 @@
|
|||||||
class="modal-input"
|
class="modal-input"
|
||||||
type="number"
|
type="number"
|
||||||
v-model="form.totalTimesStr"
|
v-model="form.totalTimesStr"
|
||||||
placeholder="次卡必填,月卡留空"
|
placeholder="次卡必填;月卡可填写次数"
|
||||||
placeholder-style="color:#bbb"
|
placeholder-style="color:#bbb"
|
||||||
/>
|
/>
|
||||||
</view>
|
</view>
|
||||||
@@ -179,6 +189,34 @@
|
|||||||
/>
|
/>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<!-- Cover image upload -->
|
||||||
|
<view class="modal-field modal-field--cover">
|
||||||
|
<text class="modal-label">封面图</text>
|
||||||
|
<view class="cover-upload-area">
|
||||||
|
<view v-if="form.coverUrl" class="cover-preview-wrap">
|
||||||
|
<image class="cover-preview-img" :src="form.coverUrl" mode="aspectFill" />
|
||||||
|
<view class="cover-remove-btn" @tap="clearCover">
|
||||||
|
<text class="cover-remove-icon">✕</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view
|
||||||
|
v-else
|
||||||
|
class="cover-upload-btn"
|
||||||
|
:class="{ 'cover-upload-btn--loading': uploadingCover }"
|
||||||
|
@tap="uploadCover"
|
||||||
|
>
|
||||||
|
<text v-if="uploadingCover" class="cover-upload-hint">上传中...</text>
|
||||||
|
<template v-else>
|
||||||
|
<text class="cover-upload-plus">+</text>
|
||||||
|
<text class="cover-upload-hint">上传封面</text>
|
||||||
|
</template>
|
||||||
|
</view>
|
||||||
|
<text class="cover-upload-tip">可选,建议 3:2 比例</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Action buttons -->
|
||||||
<view class="modal-actions">
|
<view class="modal-actions">
|
||||||
<view class="modal-cancel" @tap="closeModal">
|
<view class="modal-cancel" @tap="closeModal">
|
||||||
<text class="modal-cancel-text">取消</text>
|
<text class="modal-cancel-text">取消</text>
|
||||||
@@ -188,27 +226,37 @@
|
|||||||
:class="{ 'modal-confirm--loading': submitting }"
|
:class="{ 'modal-confirm--loading': submitting }"
|
||||||
@tap="submitForm"
|
@tap="submitForm"
|
||||||
>
|
>
|
||||||
<text class="modal-confirm-text">{{ submitting ? '保存中...' : '确认' }}</text>
|
<text class="modal-confirm-text">{{ submitting ? '保存中...' : '确认保存' }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</scroll-view>
|
</scroll-view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
|
import { getSystemLayout } from '../../utils/system'
|
||||||
import { useAdminStore } from '../../stores/admin'
|
import { useAdminStore } from '../../stores/admin'
|
||||||
import { formatPrice } from '../../utils/format'
|
import { formatPrice } from '../../utils/format'
|
||||||
|
import { uploadStudioAsset } from '../../utils/studio-upload'
|
||||||
import { CardTypeCategory } from '@mp-pilates/shared'
|
import { CardTypeCategory } from '@mp-pilates/shared'
|
||||||
import type { CardType } from '@mp-pilates/shared'
|
import type { CardType } from '@mp-pilates/shared'
|
||||||
|
|
||||||
const adminStore = useAdminStore()
|
const adminStore = useAdminStore()
|
||||||
|
|
||||||
|
const navBarHeight = ref('64px')
|
||||||
|
onMounted(() => {
|
||||||
|
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
|
||||||
|
})
|
||||||
|
|
||||||
const cardTypes = ref<CardType[]>([])
|
const cardTypes = ref<CardType[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const showModal = ref(false)
|
const showModal = ref(false)
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
|
const uploadingCover = ref(false)
|
||||||
const editTarget = ref<CardType | null>(null)
|
const editTarget = ref<CardType | null>(null)
|
||||||
|
|
||||||
const typeOptions = [
|
const typeOptions = [
|
||||||
@@ -217,7 +265,7 @@ const typeOptions = [
|
|||||||
{ label: '体验卡', value: CardTypeCategory.TRIAL },
|
{ label: '体验卡', value: CardTypeCategory.TRIAL },
|
||||||
]
|
]
|
||||||
|
|
||||||
const form = ref({
|
const defaultForm = () => ({
|
||||||
name: '',
|
name: '',
|
||||||
typeIdx: 0,
|
typeIdx: 0,
|
||||||
priceStr: '',
|
priceStr: '',
|
||||||
@@ -226,8 +274,13 @@ const form = ref({
|
|||||||
durationDaysStr: '90',
|
durationDaysStr: '90',
|
||||||
sortOrderStr: '0',
|
sortOrderStr: '0',
|
||||||
description: '',
|
description: '',
|
||||||
|
coverUrl: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const form = ref(defaultForm())
|
||||||
|
|
||||||
|
// ─── Data loading ────────────────────────────────────
|
||||||
|
|
||||||
async function fetchCardTypes() {
|
async function fetchCardTypes() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -239,18 +292,11 @@ async function fetchCardTypes() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Modal open / close ──────────────────────────────
|
||||||
|
|
||||||
function openAdd() {
|
function openAdd() {
|
||||||
editTarget.value = null
|
editTarget.value = null
|
||||||
form.value = {
|
form.value = defaultForm()
|
||||||
name: '',
|
|
||||||
typeIdx: 0,
|
|
||||||
priceStr: '',
|
|
||||||
originalPriceStr: '',
|
|
||||||
totalTimesStr: '',
|
|
||||||
durationDaysStr: '90',
|
|
||||||
sortOrderStr: '0',
|
|
||||||
description: '',
|
|
||||||
}
|
|
||||||
showModal.value = true
|
showModal.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,12 +305,13 @@ function openEdit(ct: CardType) {
|
|||||||
form.value = {
|
form.value = {
|
||||||
name: ct.name,
|
name: ct.name,
|
||||||
typeIdx: typeOptions.findIndex((t) => t.value === ct.type),
|
typeIdx: typeOptions.findIndex((t) => t.value === ct.type),
|
||||||
priceStr: String(ct.price),
|
priceStr: String(Number(ct.price) / 100),
|
||||||
originalPriceStr: ct.originalPrice ? String(ct.originalPrice) : '',
|
originalPriceStr: ct.originalPrice ? String(Number(ct.originalPrice) / 100) : '',
|
||||||
totalTimesStr: ct.totalTimes ? String(ct.totalTimes) : '',
|
totalTimesStr: ct.totalTimes ? String(ct.totalTimes) : '',
|
||||||
durationDaysStr: String(ct.durationDays),
|
durationDaysStr: String(ct.durationDays),
|
||||||
sortOrderStr: String(ct.sortOrder),
|
sortOrderStr: String(ct.sortOrder),
|
||||||
description: ct.description ?? '',
|
description: ct.description ?? '',
|
||||||
|
coverUrl: ct.coverUrl ?? '',
|
||||||
}
|
}
|
||||||
showModal.value = true
|
showModal.value = true
|
||||||
}
|
}
|
||||||
@@ -274,8 +321,16 @@ function closeModal() {
|
|||||||
editTarget.value = null
|
editTarget.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onTypeChange(e: { detail: { value: number } }) {
|
||||||
|
form.value.typeIdx = Number(e.detail.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Form submit ─────────────────────────────────────
|
||||||
|
|
||||||
async function submitForm() {
|
async function submitForm() {
|
||||||
if (submitting.value) return
|
if (submitting.value) return
|
||||||
|
|
||||||
|
// Validation
|
||||||
if (!form.value.name.trim()) {
|
if (!form.value.name.trim()) {
|
||||||
uni.showToast({ title: '请填写卡种名称', icon: 'none' })
|
uni.showToast({ title: '请填写卡种名称', icon: 'none' })
|
||||||
return
|
return
|
||||||
@@ -291,23 +346,42 @@ async function submitForm() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const selectedType = typeOptions[form.value.typeIdx].value
|
||||||
|
const totalTimes = form.value.totalTimesStr ? parseInt(form.value.totalTimesStr, 10) : null
|
||||||
|
|
||||||
|
// Times-based card must have totalTimes
|
||||||
|
if (
|
||||||
|
(selectedType === CardTypeCategory.TIMES || selectedType === CardTypeCategory.TRIAL) &&
|
||||||
|
(!totalTimes || totalTimes < 1)
|
||||||
|
) {
|
||||||
|
uni.showToast({ title: '次卡/体验卡请填写次数', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert yuan → cents for storage
|
||||||
|
const priceCents = Math.round(price * 100)
|
||||||
|
|
||||||
const payload: Record<string, unknown> = {
|
const payload: Record<string, unknown> = {
|
||||||
name: form.value.name.trim(),
|
name: form.value.name.trim(),
|
||||||
type: typeOptions[form.value.typeIdx].value,
|
type: selectedType,
|
||||||
price,
|
price: priceCents,
|
||||||
durationDays,
|
durationDays,
|
||||||
sortOrder: parseInt(form.value.sortOrderStr, 10) || 0,
|
sortOrder: parseInt(form.value.sortOrderStr, 10) || 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (form.value.originalPriceStr) {
|
if (form.value.originalPriceStr) {
|
||||||
payload.originalPrice = parseFloat(form.value.originalPriceStr)
|
const originalPrice = parseFloat(form.value.originalPriceStr)
|
||||||
|
payload.originalPrice = Math.round(originalPrice * 100)
|
||||||
}
|
}
|
||||||
if (form.value.totalTimesStr) {
|
if (totalTimes) {
|
||||||
payload.totalTimes = parseInt(form.value.totalTimesStr, 10)
|
payload.totalTimes = totalTimes
|
||||||
}
|
}
|
||||||
if (form.value.description.trim()) {
|
if (form.value.description.trim()) {
|
||||||
payload.description = form.value.description.trim()
|
payload.description = form.value.description.trim()
|
||||||
}
|
}
|
||||||
|
if (form.value.coverUrl) {
|
||||||
|
payload.coverUrl = form.value.coverUrl
|
||||||
|
}
|
||||||
|
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
@@ -319,33 +393,70 @@ async function submitForm() {
|
|||||||
uni.showToast({ title: '保存成功', icon: 'success' })
|
uni.showToast({ title: '保存成功', icon: 'success' })
|
||||||
closeModal()
|
closeModal()
|
||||||
await fetchCardTypes()
|
await fetchCardTypes()
|
||||||
} catch (e: any) {
|
} catch (e: unknown) {
|
||||||
uni.showToast({ title: e?.message ?? '保存失败', icon: 'none' })
|
const message = e instanceof Error ? e.message : '保存失败'
|
||||||
|
uni.showToast({ title: message, icon: 'none' })
|
||||||
} finally {
|
} finally {
|
||||||
submitting.value = false
|
submitting.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleActive(ct: CardType) {
|
// ─── Toggle active (上架 / 下架) ─────────────────────
|
||||||
|
|
||||||
|
function confirmToggle(ct: CardType) {
|
||||||
|
const action = ct.isActive ? '下架' : '上架'
|
||||||
|
const content = ct.isActive
|
||||||
|
? `下架后用户将无法购买「${ct.name}」,已持有的会员卡不受影响。`
|
||||||
|
: `上架后「${ct.name}」将重新对用户可见并可购买。`
|
||||||
|
|
||||||
|
uni.showModal({
|
||||||
|
title: `确认${action}`,
|
||||||
|
content,
|
||||||
|
confirmText: action,
|
||||||
|
confirmColor: ct.isActive ? '#e67e22' : '#27ae60',
|
||||||
|
cancelText: '取消',
|
||||||
|
success: async (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
uni.showLoading({ title: `${action}中...` })
|
||||||
try {
|
try {
|
||||||
await adminStore.updateCardType(ct.id, { isActive: !ct.isActive })
|
await adminStore.updateCardType(ct.id, { isActive: !ct.isActive } as any)
|
||||||
|
uni.hideLoading()
|
||||||
|
uni.showToast({ title: `已${action}`, icon: 'success' })
|
||||||
await fetchCardTypes()
|
await fetchCardTypes()
|
||||||
} catch {
|
} catch {
|
||||||
uni.showToast({ title: '操作失败', icon: 'none' })
|
uni.hideLoading()
|
||||||
|
uni.showToast({ title: `${action}失败`, icon: 'none' })
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Delete ──────────────────────────────────────────
|
||||||
|
|
||||||
function confirmDelete(ct: CardType) {
|
function confirmDelete(ct: CardType) {
|
||||||
uni.showModal({
|
uni.showModal({
|
||||||
title: '确认删除',
|
title: '确认删除',
|
||||||
content: `删除卡种「${ct.name}」?此操作不可恢复。`,
|
content: `删除卡种「${ct.name}」?\n若有用户已购买此卡种,将自动下架而非删除。`,
|
||||||
|
confirmText: '删除',
|
||||||
|
confirmColor: '#c0392b',
|
||||||
|
cancelText: '取消',
|
||||||
success: async (res) => {
|
success: async (res) => {
|
||||||
if (res.confirm) {
|
if (res.confirm) {
|
||||||
|
uni.showLoading({ title: '删除中...' })
|
||||||
try {
|
try {
|
||||||
await adminStore.deleteCardType(ct.id)
|
const result = await adminStore.deleteCardType(ct.id)
|
||||||
|
uni.hideLoading()
|
||||||
|
// result may contain { deleted, deactivated } from server
|
||||||
|
const resultData = result as unknown as { deleted?: boolean; deactivated?: boolean }
|
||||||
|
if (resultData?.deactivated) {
|
||||||
|
uni.showToast({ title: '存在关联数据,已自动下架', icon: 'none', duration: 2500 })
|
||||||
|
} else {
|
||||||
uni.showToast({ title: '已删除', icon: 'success' })
|
uni.showToast({ title: '已删除', icon: 'success' })
|
||||||
|
}
|
||||||
await fetchCardTypes()
|
await fetchCardTypes()
|
||||||
} catch {
|
} catch {
|
||||||
|
uni.hideLoading()
|
||||||
uni.showToast({ title: '删除失败', icon: 'none' })
|
uni.showToast({ title: '删除失败', icon: 'none' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -353,6 +464,87 @@ function confirmDelete(ct: CardType) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Cover image upload ─────────────────────────────
|
||||||
|
|
||||||
|
async function uploadCover() {
|
||||||
|
if (uploadingCover.value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const file = await chooseSingleImage()
|
||||||
|
if (!file) return
|
||||||
|
|
||||||
|
uploadingCover.value = true
|
||||||
|
const url = await uploadStudioAsset({
|
||||||
|
adminStore,
|
||||||
|
filePath: file.path,
|
||||||
|
fileName: file.name,
|
||||||
|
assetType: 'card-cover',
|
||||||
|
})
|
||||||
|
form.value.coverUrl = url
|
||||||
|
uni.showToast({ title: '上传成功', icon: 'success' })
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : '上传失败'
|
||||||
|
uni.showToast({ title: message, icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
uploadingCover.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearCover() {
|
||||||
|
form.value.coverUrl = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PickedImage {
|
||||||
|
readonly path: string
|
||||||
|
readonly name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractFileName(filePath: string): string {
|
||||||
|
return filePath.split('/').pop() || `image_${Date.now()}.jpg`
|
||||||
|
}
|
||||||
|
|
||||||
|
function chooseSingleImage(): Promise<PickedImage | null> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
uni.chooseImage({
|
||||||
|
count: 1,
|
||||||
|
sizeType: ['compressed'],
|
||||||
|
sourceType: ['album', 'camera'],
|
||||||
|
success: (result) => {
|
||||||
|
const tempFilePaths = Array.isArray(result.tempFilePaths)
|
||||||
|
? result.tempFilePaths
|
||||||
|
: typeof result.tempFilePaths === 'string'
|
||||||
|
? [result.tempFilePaths]
|
||||||
|
: []
|
||||||
|
const path = tempFilePaths[0]
|
||||||
|
if (!path) {
|
||||||
|
resolve(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const tempFiles = Array.isArray(result.tempFiles)
|
||||||
|
? result.tempFiles
|
||||||
|
: result.tempFiles
|
||||||
|
? [result.tempFiles]
|
||||||
|
: []
|
||||||
|
const file = tempFiles[0] as { path?: string; tempFilePath?: string; name?: string } | undefined
|
||||||
|
resolve({
|
||||||
|
path,
|
||||||
|
name: file?.name || extractFileName(file?.path || file?.tempFilePath || path),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
fail: (error) => {
|
||||||
|
if ((error.errMsg || '').includes('cancel')) {
|
||||||
|
resolve(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reject(new Error(error.errMsg || '选择图片失败'))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────────
|
||||||
|
|
||||||
function typeLabel(ct: CardType): string {
|
function typeLabel(ct: CardType): string {
|
||||||
const map: Record<CardTypeCategory, string> = {
|
const map: Record<CardTypeCategory, string> = {
|
||||||
[CardTypeCategory.TIMES]: '次卡',
|
[CardTypeCategory.TIMES]: '次卡',
|
||||||
@@ -368,6 +560,8 @@ function headerClass(ct: CardType): string {
|
|||||||
return 'header--times'
|
return 'header--times'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Lifecycle ───────────────────────────────────────
|
||||||
|
|
||||||
onMounted(fetchCardTypes)
|
onMounted(fetchCardTypes)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -394,7 +588,7 @@ onMounted(fetchCardTypes)
|
|||||||
padding: 12rpx 28rpx;
|
padding: 12rpx 28rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-btn-text { font-size: 26rpx; font-weight: 600; color: #c9a87c; }
|
.add-btn-text { font-size: 26rpx; font-weight: 600; color: $primary-dark; }
|
||||||
|
|
||||||
/* ── Skeleton ────────────────────────────── */
|
/* ── Skeleton ────────────────────────────── */
|
||||||
.skeleton-list { padding: 0 24rpx; }
|
.skeleton-list { padding: 0 24rpx; }
|
||||||
@@ -403,16 +597,11 @@ onMounted(fetchCardTypes)
|
|||||||
height: 260rpx;
|
height: 260rpx;
|
||||||
border-radius: 16rpx;
|
border-radius: 16rpx;
|
||||||
margin-bottom: 20rpx;
|
margin-bottom: 20rpx;
|
||||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
background: linear-gradient(90deg, #f0ece8 25%, #e8e4df 50%, #f0ece8 75%);
|
||||||
background-size: 400% 100%;
|
background-size: 400% 100%;
|
||||||
animation: shimmer 1.4s infinite;
|
animation: shimmer 1.4s infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes shimmer {
|
|
||||||
0% { background-position: 100% 0; }
|
|
||||||
100% { background-position: -100% 0; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Empty ───────────────────────────────── */
|
/* ── Empty ───────────────────────────────── */
|
||||||
.empty-state {
|
.empty-state {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -435,7 +624,7 @@ onMounted(fetchCardTypes)
|
|||||||
margin-bottom: 20rpx;
|
margin-bottom: 20rpx;
|
||||||
box-shadow: 0 4rpx 16rpx rgba(0,0,0,0.08);
|
box-shadow: 0 4rpx 16rpx rgba(0,0,0,0.08);
|
||||||
|
|
||||||
&--inactive { opacity: 0.6; }
|
&--inactive { opacity: 0.55; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.ct-header {
|
.ct-header {
|
||||||
@@ -445,16 +634,16 @@ onMounted(fetchCardTypes)
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header--times { background: linear-gradient(90deg, #1a1a2e, #2d2d5e); }
|
.header--times { background: linear-gradient(90deg, #E8D5C4, #D4BFA8); }
|
||||||
.header--duration { background: linear-gradient(90deg, #6c3483, #9b59b6); }
|
.header--duration { background: linear-gradient(90deg, #D8C8DC, #C4AECB); }
|
||||||
.header--trial { background: linear-gradient(90deg, #7d6608, #c9a87c); }
|
.header--trial { background: linear-gradient(90deg, #C8D8D2, #A9C4BC); }
|
||||||
|
|
||||||
.ct-type-label { font-size: 22rpx; font-weight: 600; color: #ffffff; letter-spacing: 2rpx; }
|
.ct-type-label { font-size: 22rpx; font-weight: 600; color: $brand-color; letter-spacing: 2rpx; }
|
||||||
|
|
||||||
.ct-status-tag { border-radius: 20rpx; padding: 4rpx 16rpx; }
|
.ct-status-tag { border-radius: 20rpx; padding: 4rpx 16rpx; }
|
||||||
.tag--on { background: rgba(255,255,255,0.2); }
|
.tag--on { background: rgba(74, 64, 53, 0.1); }
|
||||||
.tag--off { background: rgba(0,0,0,0.2); }
|
.tag--off { background: rgba(74, 64, 53, 0.08); }
|
||||||
.ct-status-text { font-size: 20rpx; color: #ffffff; }
|
.ct-status-text { font-size: 20rpx; color: $brand-color; }
|
||||||
|
|
||||||
.ct-body { padding: 24rpx; }
|
.ct-body { padding: 24rpx; }
|
||||||
|
|
||||||
@@ -473,7 +662,7 @@ onMounted(fetchCardTypes)
|
|||||||
margin-bottom: 12rpx;
|
margin-bottom: 12rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ct-price { font-size: 40rpx; font-weight: 800; color: #c9a87c; }
|
.ct-price { font-size: 40rpx; font-weight: 800; color: $primary-dark; }
|
||||||
|
|
||||||
.ct-original {
|
.ct-original {
|
||||||
font-size: 24rpx;
|
font-size: 24rpx;
|
||||||
@@ -510,6 +699,8 @@ onMounted(fetchCardTypes)
|
|||||||
border-right: 1rpx solid #f5f5f5;
|
border-right: 1rpx solid #f5f5f5;
|
||||||
|
|
||||||
&:last-child { border-right: none; }
|
&:last-child { border-right: none; }
|
||||||
|
|
||||||
|
&:active { background: #f9f9f9; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.ct-action-text { font-size: 26rpx; font-weight: 600; }
|
.ct-action-text { font-size: 26rpx; font-weight: 600; }
|
||||||
@@ -526,23 +717,58 @@ onMounted(fetchCardTypes)
|
|||||||
background: rgba(0,0,0,0.5);
|
background: rgba(0,0,0,0.5);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
z-index: 100;
|
z-index: 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal {
|
.modal-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-height: 85vh;
|
max-height: 85vh;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
border-radius: 24rpx 24rpx 0 0;
|
border-radius: 24rpx 24rpx 0 0;
|
||||||
padding: 40rpx 32rpx 60rpx;
|
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: #ffffff;
|
||||||
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-title {
|
.modal-title {
|
||||||
font-size: 32rpx;
|
font-size: 32rpx;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #1a1a2e;
|
color: #1a1a2e;
|
||||||
display: block;
|
}
|
||||||
margin-bottom: 24rpx;
|
|
||||||
|
.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 {
|
.modal-field {
|
||||||
@@ -575,7 +801,8 @@ onMounted(fetchCardTypes)
|
|||||||
.modal-actions {
|
.modal-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 16rpx;
|
gap: 16rpx;
|
||||||
margin-top: 32rpx;
|
padding: 24rpx 32rpx calc(24rpx + env(safe-area-inset-bottom));
|
||||||
|
background: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-cancel {
|
.modal-cancel {
|
||||||
@@ -586,6 +813,8 @@ onMounted(fetchCardTypes)
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
|
&:active { background: #e8e8e8; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-cancel-text { font-size: 28rpx; color: #555; }
|
.modal-cancel-text { font-size: 28rpx; color: #555; }
|
||||||
@@ -599,8 +828,87 @@ onMounted(fetchCardTypes)
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
&--loading { opacity: 0.6; }
|
&:active { opacity: 0.85; }
|
||||||
|
&--loading { opacity: 0.6; pointer-events: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-confirm-text { font-size: 28rpx; font-weight: 700; color: #c9a87c; }
|
.modal-confirm-text { font-size: 28rpx; font-weight: 700; color: $primary-dark; }
|
||||||
|
|
||||||
|
/* ── Cover upload ───────────────────────── */
|
||||||
|
.modal-field--cover {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16rpx;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-upload-area {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-preview-wrap {
|
||||||
|
position: relative;
|
||||||
|
width: 300rpx;
|
||||||
|
height: 200rpx;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-preview-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-remove-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 8rpx;
|
||||||
|
right: 8rpx;
|
||||||
|
width: 44rpx;
|
||||||
|
height: 44rpx;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-remove-icon {
|
||||||
|
font-size: 20rpx;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-upload-btn {
|
||||||
|
width: 300rpx;
|
||||||
|
height: 200rpx;
|
||||||
|
border: 2rpx dashed #ddd;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8rpx;
|
||||||
|
background: #fafafa;
|
||||||
|
|
||||||
|
&:active { background: #f0f0f0; }
|
||||||
|
&--loading { opacity: 0.6; pointer-events: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-upload-plus {
|
||||||
|
font-size: 48rpx;
|
||||||
|
color: #bbb;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-upload-hint {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-upload-tip {
|
||||||
|
font-size: 20rpx;
|
||||||
|
color: #bbb;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
863
packages/app/src/pages/admin/flash-sales.vue
Normal file
863
packages/app/src/pages/admin/flash-sales.vue
Normal file
@@ -0,0 +1,863 @@
|
|||||||
|
<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>
|
||||||
@@ -1,61 +1,200 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="page">
|
<view class="page" :style="{ paddingTop: navBarHeight }">
|
||||||
<!-- Stats row -->
|
<CustomNavBar title="管理中心" show-back />
|
||||||
<view class="stats-row">
|
|
||||||
<view v-if="statsLoading" class="stats-shimmer-wrap">
|
<!-- Stats summary card -->
|
||||||
<view v-for="i in 3" :key="i" class="stats-shimmer" />
|
<view class="stats-card-wrap">
|
||||||
|
<view class="stats-card">
|
||||||
|
<view v-if="statsLoading" class="stats-loading">
|
||||||
|
<view v-for="i in 3" :key="i" class="stat-skeleton" />
|
||||||
</view>
|
</view>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<view class="stat-item">
|
<view class="stat-block">
|
||||||
<text class="stat-value">{{ stats.todayBookings }}</text>
|
<text class="stat-num">{{ stats.todayBookings }}</text>
|
||||||
<text class="stat-label">今日预约</text>
|
<text class="stat-sub">今日预约</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="stat-divider" />
|
<view class="stat-sep" />
|
||||||
<view class="stat-item">
|
<view class="stat-block">
|
||||||
<text class="stat-value">{{ stats.totalOrders }}</text>
|
<text class="stat-num">{{ stats.totalOrders }}</text>
|
||||||
<text class="stat-label">总订单</text>
|
<text class="stat-sub">总订单</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="stat-divider" />
|
<view class="stat-sep" />
|
||||||
<view class="stat-item">
|
<view class="stat-block">
|
||||||
<text class="stat-value">{{ stats.totalBookings }}</text>
|
<text class="stat-num">{{ stats.totalBookings }}</text>
|
||||||
<text class="stat-label">总预约</text>
|
<text class="stat-sub">总预约</text>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
</view>
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<!-- Nav grid -->
|
<!-- Section header: 课程管理 -->
|
||||||
<view class="nav-grid">
|
<view class="section-header">
|
||||||
<view
|
<text class="section-title">课程管理</text>
|
||||||
v-for="item in navItems"
|
</view>
|
||||||
:key="item.path"
|
|
||||||
class="nav-item"
|
<!-- List: schedule -->
|
||||||
@tap="navigate(item.path)"
|
<view class="list">
|
||||||
>
|
<view class="list-item" @tap="navigate('/pages/admin/bookings')">
|
||||||
<text class="nav-icon">{{ item.icon }}</text>
|
<view class="item-left">
|
||||||
<text class="nav-label">{{ item.label }}</text>
|
<view class="item-icon-wrap icon--bookings">
|
||||||
|
<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>
|
</view>
|
||||||
|
<view class="item-arrow">
|
||||||
|
<text class="arrow-text">›</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="list-item" @tap="navigate('/pages/admin/schedule')">
|
||||||
|
<view class="item-left">
|
||||||
|
<view class="item-icon-wrap icon--schedule">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- Section header: 会员与订单 -->
|
||||||
|
<view class="section-header">
|
||||||
|
<text class="section-title">会员与订单</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- List: members & orders -->
|
||||||
|
<view class="list">
|
||||||
|
<view class="list-item" @tap="navigate('/pages/admin/members')">
|
||||||
|
<view class="item-left">
|
||||||
|
<view class="item-icon-wrap icon--members">
|
||||||
|
<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 class="list-item" @tap="navigate('/pages/admin/orders')">
|
||||||
|
<view class="item-left">
|
||||||
|
<view class="item-icon-wrap icon--orders">
|
||||||
|
<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 class="list-item" @tap="navigate('/pages/admin/card-types')">
|
||||||
|
<view class="item-left">
|
||||||
|
<view class="item-icon-wrap icon--card">
|
||||||
|
<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 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>
|
||||||
|
|
||||||
|
<!-- Section header: 系统 -->
|
||||||
|
<view class="section-header">
|
||||||
|
<text class="section-title">系统</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- List: settings -->
|
||||||
|
<view class="list">
|
||||||
|
<view class="list-item" @tap="navigate('/pages/admin/studio')">
|
||||||
|
<view class="item-left">
|
||||||
|
<view class="item-icon-wrap icon--studio">
|
||||||
|
<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 class="list-item" @tap="handleIncreaseSubscriptionCount">
|
||||||
|
<view class="item-left">
|
||||||
|
<view class="item-icon-wrap icon--subscribe">
|
||||||
|
<text class="item-icon-text">✦</text>
|
||||||
|
</view>
|
||||||
|
<view class="item-text-group">
|
||||||
|
<text class="item-title">增加订阅次数</text>
|
||||||
|
<text class="item-desc">当前剩余 {{ user?.adminBookingSubscriptionCount ?? 0 }} 次</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="item-arrow">
|
||||||
|
<text class="arrow-text">{{ adminSubscribeLoading ? '...' : '›' }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view style="height: 40rpx" />
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
|
import { getSystemLayout } from '../../utils/system'
|
||||||
import { useAdminStore } from '../../stores/admin'
|
import { useAdminStore } from '../../stores/admin'
|
||||||
|
import { useUserStore } from '../../stores/user'
|
||||||
import type { AdminStats } from '../../stores/admin'
|
import type { AdminStats } from '../../stores/admin'
|
||||||
|
import { requestAdminBookingSubscriptionCount } from '../../utils/wechat-subscription'
|
||||||
|
import { getErrorMessage } from '../../utils/auth'
|
||||||
|
|
||||||
|
const navBarHeight = ref('64px')
|
||||||
|
|
||||||
const adminStore = useAdminStore()
|
const adminStore = useAdminStore()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const { user } = storeToRefs(userStore)
|
||||||
|
|
||||||
const statsLoading = ref(false)
|
const statsLoading = ref(false)
|
||||||
const stats = ref<AdminStats>({ todayBookings: 0, totalOrders: 0, totalBookings: 0 })
|
const stats = ref<AdminStats>({ todayBookings: 0, totalOrders: 0, totalBookings: 0 })
|
||||||
|
const adminSubscribeLoading = ref(false)
|
||||||
const navItems = [
|
|
||||||
{ icon: '📅', label: '排课设置', path: '/pages/admin/week-template' },
|
|
||||||
{ icon: '🔧', label: '临时调整', path: '/pages/admin/slot-adjust' },
|
|
||||||
{ icon: '👥', label: '会员管理', path: '/pages/admin/members' },
|
|
||||||
{ icon: '📋', label: '订单管理', path: '/pages/admin/orders' },
|
|
||||||
{ icon: '💳', label: '卡种管理', path: '/pages/admin/card-types' },
|
|
||||||
{ icon: '🏢', label: '工作室设置', path: '/pages/admin/studio' },
|
|
||||||
]
|
|
||||||
|
|
||||||
function navigate(path: string) {
|
function navigate(path: string) {
|
||||||
uni.navigateTo({ url: path })
|
uni.navigateTo({ url: path })
|
||||||
@@ -72,105 +211,201 @@ async function loadStats() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(loadStats)
|
async function handleIncreaseSubscriptionCount() {
|
||||||
|
if (adminSubscribeLoading.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
adminSubscribeLoading.value = true
|
||||||
|
try {
|
||||||
|
const profile = await requestAdminBookingSubscriptionCount()
|
||||||
|
if (!profile) {
|
||||||
|
uni.showToast({ title: '已取消本次授权', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userStore.setProfile(profile)
|
||||||
|
uni.showToast({ title: `订阅次数 +1,剩余 ${profile.adminBookingSubscriptionCount}`, icon: 'none' })
|
||||||
|
} catch (err: unknown) {
|
||||||
|
uni.showToast({ title: getErrorMessage(err, '订阅授权失败'), icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
adminSubscribeLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
|
||||||
|
loadStats()
|
||||||
|
userStore.fetchProfile()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
/* ── Page ───────────────────────────────────── */
|
||||||
.page {
|
.page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: #1a1a2e;
|
padding-bottom: 40rpx;
|
||||||
padding-bottom: 60rpx;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Stats row ───────────────────────────── */
|
/* ── Stats card ─────────────────────────────── */
|
||||||
.stats-row {
|
.stats-card-wrap {
|
||||||
display: flex;
|
padding: 24rpx 24rpx 8rpx;
|
||||||
align-items: center;
|
}
|
||||||
justify-content: space-around;
|
|
||||||
background: rgba(255, 255, 255, 0.06);
|
.stats-card {
|
||||||
margin: 24rpx 24rpx 32rpx;
|
background: #FFFFFF;
|
||||||
border-radius: 20rpx;
|
border-radius: 20rpx;
|
||||||
padding: 32rpx 16rpx;
|
padding: 32rpx 24rpx;
|
||||||
}
|
|
||||||
|
|
||||||
.stats-shimmer-wrap {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
width: 100%;
|
|
||||||
justify-content: space-around;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
box-shadow: 0 4rpx 20rpx rgba(180, 160, 130, 0.10);
|
||||||
|
border: 1rpx solid rgba(180, 160, 130, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stats-shimmer {
|
.stats-loading {
|
||||||
width: 120rpx;
|
width: 100%;
|
||||||
height: 60rpx;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-around;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-skeleton {
|
||||||
|
width: 100rpx;
|
||||||
|
height: 64rpx;
|
||||||
border-radius: 12rpx;
|
border-radius: 12rpx;
|
||||||
background: linear-gradient(90deg, rgba(255,255,255,0.08) 25%, rgba(255,255,255,0.15) 50%, rgba(255,255,255,0.08) 75%);
|
background: linear-gradient(90deg, $primary-border 25%, $primary-light 50%, $primary-border 75%);
|
||||||
background-size: 400% 100%;
|
background-size: 400% 100%;
|
||||||
animation: shimmer 1.4s infinite;
|
animation: shimmer 1.6s ease infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes shimmer {
|
.stat-block {
|
||||||
0% { background-position: 100% 0; }
|
flex: 1;
|
||||||
100% { background-position: -100% 0; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-item {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8rpx;
|
gap: 8rpx;
|
||||||
flex: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-value {
|
.stat-num {
|
||||||
font-size: 44rpx;
|
font-size: 44rpx;
|
||||||
font-weight: 800;
|
font-weight: 700;
|
||||||
color: #c9a87c;
|
color: #4A4035;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
font-family: 'DIN Alternate', 'Helvetica Neue', Arial, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-label {
|
.stat-sub {
|
||||||
font-size: 22rpx;
|
font-size: 22rpx;
|
||||||
color: rgba(255, 255, 255, 0.5);
|
color: #A09080;
|
||||||
|
letter-spacing: 0.5rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-divider {
|
.stat-sep {
|
||||||
width: 1rpx;
|
width: 1rpx;
|
||||||
height: 60rpx;
|
height: 56rpx;
|
||||||
background: rgba(255, 255, 255, 0.12);
|
background: rgba(180, 160, 130, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Nav grid ────────────────────────────── */
|
/* ── Section header ─────────────────────────── */
|
||||||
.nav-grid {
|
.section-header {
|
||||||
display: grid;
|
padding: 32rpx 24rpx 12rpx;
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 20rpx;
|
|
||||||
padding: 0 24rpx;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item {
|
.section-title {
|
||||||
background: rgba(255, 255, 255, 0.06);
|
font-size: 22rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #A09080;
|
||||||
|
letter-spacing: 2rpx;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── List ───────────────────────────────────── */
|
||||||
|
.list {
|
||||||
|
background: #FFFFFF;
|
||||||
|
margin: 0 24rpx;
|
||||||
border-radius: 20rpx;
|
border-radius: 20rpx;
|
||||||
padding: 40rpx 0;
|
overflow: hidden;
|
||||||
|
box-shadow: 0 4rpx 20rpx rgba(180, 160, 130, 0.08);
|
||||||
|
border: 1rpx solid rgba(180, 160, 130, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 16rpx;
|
justify-content: space-between;
|
||||||
border: 1rpx solid rgba(201, 168, 124, 0.15);
|
padding: 28rpx 24rpx;
|
||||||
|
border-bottom: 1rpx solid rgba(180, 160, 130, 0.1);
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
&:active {
|
&:active {
|
||||||
opacity: 0.7;
|
background: rgba(180, 160, 130, 0.05);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-icon {
|
.item-left {
|
||||||
font-size: 56rpx;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-label {
|
.item-icon-wrap {
|
||||||
font-size: 28rpx;
|
width: 72rpx;
|
||||||
|
height: 72rpx;
|
||||||
|
border-radius: 18rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-icon-text {
|
||||||
|
font-size: 32rpx;
|
||||||
|
color: #FFFFFF;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Icon variants — warm muted tones */
|
||||||
|
.icon--bookings { background: linear-gradient(135deg, #C4A87E, #B49868); }
|
||||||
|
.icon--schedule { background: linear-gradient(135deg, #8B9E7E, #7A8E6E); }
|
||||||
|
.icon--template { background: linear-gradient(135deg, #A090C0, #9080B0); }
|
||||||
|
.icon--members { background: linear-gradient(135deg, $primary-color, $primary-dark); }
|
||||||
|
.icon--orders { background: linear-gradient(135deg, #7E9EC4, #6E8EB4); }
|
||||||
|
.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--subscribe { background: linear-gradient(135deg, #5D8C8A, #476D72); }
|
||||||
|
|
||||||
|
.item-text-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-title {
|
||||||
|
font-size: 30rpx;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #ffffff;
|
color: #4A4035;
|
||||||
letter-spacing: 1rpx;
|
letter-spacing: 0.5rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-desc {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #A09080;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-arrow {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding-left: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow-text {
|
||||||
|
font-size: 40rpx;
|
||||||
|
color: rgba(180, 160, 130, 0.5);
|
||||||
|
font-weight: 300;
|
||||||
|
line-height: 1;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
574
packages/app/src/pages/admin/member-arrange.vue
Normal file
574
packages/app/src/pages/admin/member-arrange.vue
Normal file
@@ -0,0 +1,574 @@
|
|||||||
|
<template>
|
||||||
|
<view class="page" :style="{ paddingTop: navBarHeight }">
|
||||||
|
<CustomNavBar title="安排课程" show-back />
|
||||||
|
|
||||||
|
<view v-if="detail" class="who-bar">
|
||||||
|
<view class="who-avatar">
|
||||||
|
<image v-if="detail.user.avatarUrl" class="avatar-img" :src="detail.user.avatarUrl" mode="aspectFill" />
|
||||||
|
<view v-else class="avatar-fallback">
|
||||||
|
<text class="avatar-letter">{{ (detail.user.nickname || '?').slice(0, 1) }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="who-copy">
|
||||||
|
<text class="who-kicker">为学员安排</text>
|
||||||
|
<text class="who-name">{{ detail.user.nickname || '未知用户' }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="usableMemberships.length" class="card-switch">
|
||||||
|
<scroll-view scroll-x class="card-scroll" :show-scrollbar="false">
|
||||||
|
<view class="card-track">
|
||||||
|
<view
|
||||||
|
v-for="card in usableMemberships"
|
||||||
|
:key="card.id"
|
||||||
|
class="card-pill"
|
||||||
|
:class="{ 'card-pill--on': selectedMembershipId === card.id }"
|
||||||
|
@tap="selectedMembershipId = card.id"
|
||||||
|
>
|
||||||
|
<text class="card-pill-name">{{ card.cardType.name }}</text>
|
||||||
|
<text class="card-pill-meta">
|
||||||
|
{{ card.remainingTimes === null ? '有效期内不限次' : `剩 ${card.remainingTimes} 次` }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-else class="ban-banner">
|
||||||
|
<text class="ban-text">该会员没有可扣课的有效卡,请先开卡</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<DateSelector v-model="selectedDate" variant="booking" @select="onDateSelect" />
|
||||||
|
<TimePeriodFilter v-model="selectedPeriod" variant="booking" />
|
||||||
|
|
||||||
|
<view v-if="slotsLoading" class="slot-skeleton">
|
||||||
|
<view v-for="i in 4" :key="i" class="slot-skel" />
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-else-if="filteredSlots.length === 0" class="empty-slots">
|
||||||
|
<text class="empty-title">这天还没有可安排的课表</text>
|
||||||
|
<text class="empty-sub">请先去排课管理发布时段</text>
|
||||||
|
<view class="ghost-link" @tap="goSchedule">
|
||||||
|
<text class="ghost-link-text">前往排课管理</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-else class="slot-list">
|
||||||
|
<view
|
||||||
|
v-for="slot in filteredSlots"
|
||||||
|
:key="slot.id"
|
||||||
|
class="slot-row"
|
||||||
|
:class="{ 'slot-row--disabled': !canPickSlot(slot) }"
|
||||||
|
@tap="onPickSlot(slot)"
|
||||||
|
>
|
||||||
|
<view class="slot-time-col">
|
||||||
|
<text class="slot-time">{{ slot.startTime.slice(0, 5) }}</text>
|
||||||
|
<text class="slot-end">{{ slot.endTime.slice(0, 5) }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="slot-body">
|
||||||
|
<text class="slot-title">{{ slotLabel(slot) }}</text>
|
||||||
|
<text class="slot-cap">{{ slot.bookedCount }}/{{ slot.capacity }} 人</text>
|
||||||
|
</view>
|
||||||
|
<text class="slot-action">{{ slotActionLabel(slot) }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="confirmVisible && pendingSlot" class="mask" @tap="confirmVisible = false">
|
||||||
|
<view class="sheet" @tap.stop>
|
||||||
|
<text class="sheet-kicker">立即确认</text>
|
||||||
|
<text class="sheet-title">安排给 {{ detail?.user.nickname || '该会员' }}</text>
|
||||||
|
<view class="sheet-lines">
|
||||||
|
<view class="sheet-line">
|
||||||
|
<text class="sheet-label">时间</text>
|
||||||
|
<text class="sheet-value">{{ pendingSlot.date }} {{ pendingSlot.startTime.slice(0, 5) }}–{{ pendingSlot.endTime.slice(0, 5) }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="sheet-line">
|
||||||
|
<text class="sheet-label">扣卡</text>
|
||||||
|
<text class="sheet-value">{{ selectedMembership?.cardType.name }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<text class="sheet-note">{{ deductHint }}</text>
|
||||||
|
<view class="sheet-actions">
|
||||||
|
<view class="sheet-cancel" @tap="confirmVisible = false">
|
||||||
|
<text class="sheet-cancel-text">取消</text>
|
||||||
|
</view>
|
||||||
|
<view class="sheet-ok" :class="{ 'sheet-ok--disabled': arranging }" @tap="confirmArrange">
|
||||||
|
<text class="sheet-ok-text">{{ arranging ? '安排中...' : '确认安排' }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { onLoad } from '@dcloudio/uni-app'
|
||||||
|
import type {
|
||||||
|
AdminMemberDetail,
|
||||||
|
ScheduleSlotPreview,
|
||||||
|
} from '@mp-pilates/shared'
|
||||||
|
import { MembershipStatus, TIME_PERIODS, TimeSlotStatus } from '@mp-pilates/shared'
|
||||||
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
|
import DateSelector from '../../components/DateSelector.vue'
|
||||||
|
import TimePeriodFilter from '../../components/TimePeriodFilter.vue'
|
||||||
|
import { getSystemLayout } from '../../utils/system'
|
||||||
|
import { formatDate, isSlotPast } from '../../utils/format'
|
||||||
|
import { getErrorMessage } from '../../utils/auth'
|
||||||
|
import { useAdminStore } from '../../stores/admin'
|
||||||
|
|
||||||
|
type PeriodKey = keyof typeof TIME_PERIODS | null
|
||||||
|
|
||||||
|
const adminStore = useAdminStore()
|
||||||
|
const navBarHeight = ref('64px')
|
||||||
|
const userId = ref('')
|
||||||
|
const detail = ref<AdminMemberDetail | null>(null)
|
||||||
|
const selectedMembershipId = ref('')
|
||||||
|
const selectedDate = ref(formatDate(new Date()))
|
||||||
|
const selectedPeriod = ref<PeriodKey>(null)
|
||||||
|
const slots = ref<ScheduleSlotPreview[]>([])
|
||||||
|
const slotsLoading = ref(false)
|
||||||
|
const confirmVisible = ref(false)
|
||||||
|
const pendingSlot = ref<ScheduleSlotPreview | null>(null)
|
||||||
|
const arranging = ref(false)
|
||||||
|
|
||||||
|
const usableMemberships = computed(() =>
|
||||||
|
(detail.value?.memberships ?? []).filter((item) =>
|
||||||
|
item.status === MembershipStatus.ACTIVE
|
||||||
|
&& (item.remainingTimes === null || item.remainingTimes > 0)
|
||||||
|
&& new Date(item.expireDate) > new Date(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const selectedMembership = computed(
|
||||||
|
() => usableMemberships.value.find((item) => item.id === selectedMembershipId.value) ?? null,
|
||||||
|
)
|
||||||
|
|
||||||
|
const canArrange = computed(() => Boolean(selectedMembership.value))
|
||||||
|
|
||||||
|
const isCountLimited = computed(() => selectedMembership.value?.remainingTimes !== null)
|
||||||
|
|
||||||
|
const deductHint = computed(() => {
|
||||||
|
if (!isCountLimited.value) {
|
||||||
|
return '将立即确认该课,会员卡不限次,会员无需再确认。'
|
||||||
|
}
|
||||||
|
return '将立即确认该课并扣除 1 次,会员无需再确认。'
|
||||||
|
})
|
||||||
|
|
||||||
|
const publishedSlots = computed(() =>
|
||||||
|
slots.value.filter((slot): slot is ScheduleSlotPreview & { id: string } =>
|
||||||
|
Boolean(slot.isPublished && slot.id),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const filteredSlots = computed(() => {
|
||||||
|
if (!selectedPeriod.value) return publishedSlots.value
|
||||||
|
const period = TIME_PERIODS[selectedPeriod.value]
|
||||||
|
return publishedSlots.value.filter((slot) => slot.startTime >= period.start && slot.startTime < period.end)
|
||||||
|
})
|
||||||
|
|
||||||
|
function canPickSlot(slot: ScheduleSlotPreview): boolean {
|
||||||
|
if (!canArrange.value) return false
|
||||||
|
if (isSlotPast(slot.date, slot.startTime)) return false
|
||||||
|
if (slot.status === TimeSlotStatus.CLOSED) return false
|
||||||
|
if (slot.status === TimeSlotStatus.FULL || slot.bookedCount >= slot.capacity) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function slotLabel(slot: ScheduleSlotPreview): string {
|
||||||
|
if (slot.status === TimeSlotStatus.CLOSED) return '已关闭'
|
||||||
|
if (slot.status === TimeSlotStatus.FULL || slot.bookedCount >= slot.capacity) return '已满员'
|
||||||
|
if (isSlotPast(slot.date, slot.startTime)) return '已过点'
|
||||||
|
return '可安排'
|
||||||
|
}
|
||||||
|
|
||||||
|
function slotActionLabel(slot: ScheduleSlotPreview): string {
|
||||||
|
if (!canPickSlot(slot)) return '—'
|
||||||
|
return '安排'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSlots(date: string) {
|
||||||
|
slotsLoading.value = true
|
||||||
|
try {
|
||||||
|
slots.value = await adminStore.previewScheduleByDate(date)
|
||||||
|
} catch (err: unknown) {
|
||||||
|
slots.value = []
|
||||||
|
uni.showToast({ title: getErrorMessage(err, '课表加载失败'), icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
slotsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPage() {
|
||||||
|
detail.value = await adminStore.fetchMemberDetail(userId.value)
|
||||||
|
const first = usableMemberships.value[0]
|
||||||
|
selectedMembershipId.value = first?.id ?? ''
|
||||||
|
await loadSlots(selectedDate.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDateSelect(date: string) {
|
||||||
|
selectedDate.value = date
|
||||||
|
loadSlots(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
function goSchedule() {
|
||||||
|
uni.navigateTo({ url: '/pages/admin/schedule' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPickSlot(slot: ScheduleSlotPreview) {
|
||||||
|
if (!canPickSlot(slot) || !slot.id) return
|
||||||
|
pendingSlot.value = slot
|
||||||
|
confirmVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmArrange() {
|
||||||
|
const slot = pendingSlot.value
|
||||||
|
const membership = selectedMembership.value
|
||||||
|
if (!slot?.id || !membership || arranging.value) return
|
||||||
|
arranging.value = true
|
||||||
|
try {
|
||||||
|
await adminStore.arrangeMemberBooking({
|
||||||
|
userId: userId.value,
|
||||||
|
membershipId: membership.id,
|
||||||
|
timeSlotId: slot.id,
|
||||||
|
})
|
||||||
|
confirmVisible.value = false
|
||||||
|
uni.showToast({ title: '已安排并确认', icon: 'success' })
|
||||||
|
setTimeout(() => uni.navigateBack(), 500)
|
||||||
|
} catch (err: unknown) {
|
||||||
|
uni.showToast({ title: getErrorMessage(err, '安排失败'), icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
arranging.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onLoad((query) => {
|
||||||
|
userId.value = String(query?.userId || '')
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
|
||||||
|
try {
|
||||||
|
await loadPage()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
uni.showToast({ title: getErrorMessage(err, '加载失败'), icon: 'none' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: $bg-page;
|
||||||
|
padding-bottom: 80rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.who-bar {
|
||||||
|
margin: 16rpx 24rpx 0;
|
||||||
|
padding: 20rpx;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
background: linear-gradient(135deg, #3c3228, #5a4a3a);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.who-avatar {
|
||||||
|
width: 72rpx;
|
||||||
|
height: 72rpx;
|
||||||
|
border-radius: 18rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-img { width: 100%; height: 100%; }
|
||||||
|
|
||||||
|
.avatar-fallback {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: $accent-color;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-letter {
|
||||||
|
color: #fff8f0;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.who-copy {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.who-kicker {
|
||||||
|
font-size: 18rpx;
|
||||||
|
letter-spacing: 3rpx;
|
||||||
|
color: rgba(255, 248, 240, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.who-name {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-switch {
|
||||||
|
padding: 16rpx 0 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-scroll { white-space: nowrap; }
|
||||||
|
|
||||||
|
.card-track {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 12rpx;
|
||||||
|
padding: 0 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-pill {
|
||||||
|
padding: 14rpx 22rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
background: #fff;
|
||||||
|
border: 2rpx solid rgba(180, 160, 130, 0.18);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-pill--on {
|
||||||
|
border-color: $brand-color;
|
||||||
|
background: #3c3228;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-pill-name {
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $text-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-pill--on .card-pill-name,
|
||||||
|
.card-pill--on .card-pill-meta {
|
||||||
|
color: #fff8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-pill-meta {
|
||||||
|
font-size: 20rpx;
|
||||||
|
color: $text-hint;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ban-banner {
|
||||||
|
margin: 16rpx 24rpx;
|
||||||
|
padding: 20rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
background: rgba($error-color, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ban-text {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: $error-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-skeleton { padding: 16rpx 24rpx; }
|
||||||
|
|
||||||
|
.slot-skel {
|
||||||
|
height: 112rpx;
|
||||||
|
border-radius: 18rpx;
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
background: linear-gradient(90deg, #efe8df 25%, #f7f2ea 50%, #efe8df 75%);
|
||||||
|
background-size: 400% 100%;
|
||||||
|
animation: shimmer 1.4s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-slots {
|
||||||
|
padding: 48rpx 32rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-title {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $text-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-sub {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: $text-hint;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost-link {
|
||||||
|
margin-top: 8rpx;
|
||||||
|
padding: 10rpx 24rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
border: 1rpx solid $brand-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost-link-text {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: $brand-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-list {
|
||||||
|
padding: 16rpx 24rpx 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20rpx;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 18rpx;
|
||||||
|
padding: 22rpx 20rpx;
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
border: 1rpx solid rgba(180, 160, 130, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-row--disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-time-col {
|
||||||
|
width: 120rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-time {
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
color: $text-primary;
|
||||||
|
font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-end {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: $text-hint;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-body {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-title {
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-cap {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: $text-hint;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-action {
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $accent-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mask {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(44, 36, 28, 0.45);
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet {
|
||||||
|
width: 100%;
|
||||||
|
background: #fff8f0;
|
||||||
|
border-radius: 28rpx 28rpx 0 0;
|
||||||
|
padding: 32rpx 32rpx calc(32rpx + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-kicker {
|
||||||
|
font-size: 20rpx;
|
||||||
|
letter-spacing: 4rpx;
|
||||||
|
color: $text-hint;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-title {
|
||||||
|
display: block;
|
||||||
|
margin-top: 8rpx;
|
||||||
|
font-size: 36rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $text-primary;
|
||||||
|
font-family: 'Songti SC', Georgia, serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-lines {
|
||||||
|
margin: 28rpx 0 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-line {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 14rpx 0;
|
||||||
|
border-bottom: 1rpx solid rgba(180, 160, 130, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-label {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: $text-hint;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-value {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: $text-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-note {
|
||||||
|
display: block;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: $text-secondary;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 16rpx;
|
||||||
|
margin-top: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-cancel,
|
||||||
|
.sheet-ok {
|
||||||
|
flex: 1;
|
||||||
|
height: 84rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-cancel {
|
||||||
|
background: #fff;
|
||||||
|
border: 2rpx solid $brand-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-ok {
|
||||||
|
background: $brand-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-ok--disabled { opacity: 0.5; }
|
||||||
|
|
||||||
|
.sheet-cancel-text {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $brand-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-ok-text {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff8f0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
408
packages/app/src/pages/admin/member-detail.vue
Normal file
408
packages/app/src/pages/admin/member-detail.vue
Normal file
@@ -0,0 +1,408 @@
|
|||||||
|
<template>
|
||||||
|
<view class="page" :style="{ paddingTop: navBarHeight }">
|
||||||
|
<CustomNavBar title="会员档案" show-back />
|
||||||
|
|
||||||
|
<view v-if="loading && !detail" class="skeleton-wrap">
|
||||||
|
<view class="skeleton-hero" />
|
||||||
|
<view class="skeleton-block" />
|
||||||
|
<view class="skeleton-block" />
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<template v-else-if="detail">
|
||||||
|
<view class="hero">
|
||||||
|
<view class="hero-inner">
|
||||||
|
<view class="hero-avatar">
|
||||||
|
<image v-if="detail.user.avatarUrl" class="avatar-img" :src="detail.user.avatarUrl" mode="aspectFill" />
|
||||||
|
<view v-else class="avatar-fallback">
|
||||||
|
<text class="avatar-letter">{{ (detail.user.nickname || '?').slice(0, 1) }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="hero-copy">
|
||||||
|
<text class="hero-name">{{ detail.user.nickname || '未知用户' }}</text>
|
||||||
|
<text class="hero-phone">{{ detail.user.phone || '未绑定手机' }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view v-if="detail.user.openid" class="member-id" @tap="copyOpenid">
|
||||||
|
<text class="member-id-label">微信标识</text>
|
||||||
|
<text class="member-id-value">{{ detail.user.openid }}</text>
|
||||||
|
<text class="member-id-copy">复制</text>
|
||||||
|
</view>
|
||||||
|
<view class="time-pair">
|
||||||
|
<view class="time-cell">
|
||||||
|
<text class="time-label">注册时间</text>
|
||||||
|
<text class="time-value">{{ formatDateTimeFull(detail.user.createdAt) }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="time-rule" />
|
||||||
|
<view class="time-cell">
|
||||||
|
<text class="time-label">最近登录</text>
|
||||||
|
<text class="time-value">{{ detail.user.lastLoginAt ? formatDateTimeFull(detail.user.lastLoginAt) : '暂无登录记录' }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="section section--practice">
|
||||||
|
<view class="section-heading">
|
||||||
|
<text class="section-label">上课情况</text>
|
||||||
|
<button class="supplement-entry" @tap="goSupplement">补录上课 <text>›</text></button>
|
||||||
|
</view>
|
||||||
|
<view class="stats-strip">
|
||||||
|
<view class="stat">
|
||||||
|
<text class="stat-num">{{ detail.stats.totalBookings }}</text>
|
||||||
|
<text class="stat-name">累计预约</text>
|
||||||
|
</view>
|
||||||
|
<view class="stat stat--completed">
|
||||||
|
<text class="stat-num">{{ detail.stats.completedBookings }}</text>
|
||||||
|
<text class="stat-name">累计上课</text>
|
||||||
|
</view>
|
||||||
|
<view class="stat">
|
||||||
|
<text class="stat-num">{{ detail.stats.cancelledBookings }}</text>
|
||||||
|
<text class="stat-name">已取消</text>
|
||||||
|
</view>
|
||||||
|
<view class="stat">
|
||||||
|
<text class="stat-num">{{ detail.stats.noShowBookings }}</text>
|
||||||
|
<text class="stat-name">未到课</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="section">
|
||||||
|
<view class="section-heading">
|
||||||
|
<text class="section-label">会员卡</text>
|
||||||
|
<text class="section-note">{{ detail.memberships.length }} 张</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="detail.memberships.length" class="card-list">
|
||||||
|
<view
|
||||||
|
v-for="card in detail.memberships"
|
||||||
|
:key="card.id"
|
||||||
|
class="mship"
|
||||||
|
>
|
||||||
|
<view class="mship-head">
|
||||||
|
<view class="mship-titles">
|
||||||
|
<text class="mship-name">{{ card.cardType.name }}</text>
|
||||||
|
<text class="mship-type">{{ getCardTypeLabel(card.cardType.type) }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="mship-status" :class="'mship-status--' + card.status.toLowerCase()">
|
||||||
|
<text class="mship-status-text">{{ membershipStatusLabel(card.status) }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view v-if="card.remainingTimes !== null" class="mship-times">
|
||||||
|
<text class="mship-times-num">{{ card.remainingTimes }}</text>
|
||||||
|
<text class="mship-times-unit">次可用</text>
|
||||||
|
</view>
|
||||||
|
<view v-else class="mship-duration">
|
||||||
|
<text class="mship-duration-title">有效期内使用</text>
|
||||||
|
<text class="mship-times-unit">不限次数</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="card.remainingTimes !== null && getMembershipTotalTimes(card)" class="progress">
|
||||||
|
<view class="progress-bar">
|
||||||
|
<view class="progress-fill" :style="{ width: getMembershipProgressWidth(card) }" />
|
||||||
|
</view>
|
||||||
|
<text class="progress-text">
|
||||||
|
已用 {{ getMembershipUsedTimes(card) }} / {{ getMembershipTotalTimes(card) }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<view class="mship-dates">
|
||||||
|
<text>{{ formatDate(card.startDate) }} 起</text>
|
||||||
|
<text>{{ formatDate(card.expireDate) }} 止</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view v-else class="empty-card">
|
||||||
|
<text class="empty-card-title">尚未开卡</text>
|
||||||
|
<text class="empty-card-sub">开通体验卡、次卡或月卡后即可安排课程</text>
|
||||||
|
<view class="empty-card-btn" @tap="goEdit">
|
||||||
|
<text class="empty-card-btn-text">去开卡</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="section section--last">
|
||||||
|
<view class="section-heading">
|
||||||
|
<text class="section-label">即将上课</text>
|
||||||
|
<text v-if="detail.upcomingBookings.length" class="section-note">{{ detail.upcomingBookings.length }} 节待上</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="detail.upcomingBookings.length" class="upcoming-list">
|
||||||
|
<view v-for="item in detail.upcomingBookings" :key="item.id" class="upcoming-row">
|
||||||
|
<view class="upcoming-time">
|
||||||
|
<text class="upcoming-date">{{ item.date.slice(5, 10).replace('-', ' / ') }}</text>
|
||||||
|
<text class="upcoming-hour">{{ item.startTime.slice(0, 5) }}–{{ item.endTime.slice(0, 5) }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="upcoming-meta">
|
||||||
|
<text class="upcoming-card">{{ item.cardName }}</text>
|
||||||
|
<text class="upcoming-status">{{ bookingStatusLabel(item.status) }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view v-else class="upcoming-empty">
|
||||||
|
<text class="upcoming-empty-text">近期没有待上的课</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<view class="dock">
|
||||||
|
<view class="dock-btn dock-btn--ghost" @tap="goEdit">
|
||||||
|
<text class="dock-btn-text">编辑资料</text>
|
||||||
|
</view>
|
||||||
|
<view
|
||||||
|
class="dock-btn dock-btn--solid"
|
||||||
|
:class="{ 'dock-btn--disabled': !canArrange }"
|
||||||
|
@tap="goArrange"
|
||||||
|
>
|
||||||
|
<text class="dock-btn-text dock-btn-text--solid">安排课程</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { onLoad, onShow } from '@dcloudio/uni-app'
|
||||||
|
import type { AdminMemberDetail, MembershipWithCardType } from '@mp-pilates/shared'
|
||||||
|
import { MembershipStatus, BookingStatus } from '@mp-pilates/shared'
|
||||||
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
|
import { getSystemLayout } from '../../utils/system'
|
||||||
|
import {
|
||||||
|
formatDateTimeFull,
|
||||||
|
getCardTypeLabel,
|
||||||
|
getMembershipProgressWidth,
|
||||||
|
getMembershipUsedTimes,
|
||||||
|
getMembershipTotalTimes,
|
||||||
|
} from '../../utils/format'
|
||||||
|
import { BOOKING_STATUS_LABELS } from '../../utils/booking-helpers'
|
||||||
|
import { getErrorMessage } from '../../utils/auth'
|
||||||
|
import { useAdminStore } from '../../stores/admin'
|
||||||
|
|
||||||
|
const adminStore = useAdminStore()
|
||||||
|
const navBarHeight = ref('64px')
|
||||||
|
const userId = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
const detail = ref<AdminMemberDetail | null>(null)
|
||||||
|
|
||||||
|
const canArrange = computed(() => (detail.value?.memberships ?? []).some(isArrangableMembership))
|
||||||
|
|
||||||
|
function isArrangableMembership(membership: MembershipWithCardType): boolean {
|
||||||
|
if (membership.status !== MembershipStatus.ACTIVE) return false
|
||||||
|
if (membership.remainingTimes !== null && membership.remainingTimes <= 0) return false
|
||||||
|
return new Date(membership.expireDate) > new Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
function membershipStatusLabel(status: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
ACTIVE: '有效',
|
||||||
|
EXPIRED: '已过期',
|
||||||
|
USED_UP: '已用完',
|
||||||
|
}
|
||||||
|
return map[status] || status
|
||||||
|
}
|
||||||
|
|
||||||
|
function bookingStatusLabel(status: BookingStatus): string {
|
||||||
|
return BOOKING_STATUS_LABELS[status] || status
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr: string): string {
|
||||||
|
return dateStr.slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyOpenid() {
|
||||||
|
const openid = detail.value?.user.openid
|
||||||
|
if (!openid) return
|
||||||
|
uni.setClipboardData({
|
||||||
|
data: openid,
|
||||||
|
success: () => uni.showToast({ title: '已复制 OpenID', icon: 'success' }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDetail() {
|
||||||
|
if (!userId.value) return
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
detail.value = await adminStore.fetchMemberDetail(userId.value)
|
||||||
|
} catch (err: unknown) {
|
||||||
|
uni.showToast({ title: getErrorMessage(err, '加载失败'), icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goSupplement() {
|
||||||
|
if (userId.value) uni.navigateTo({ url: `/pages/admin/member-supplement?userId=${userId.value}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
function goEdit() {
|
||||||
|
if (!userId.value) return
|
||||||
|
uni.navigateTo({ url: `/pages/admin/member-edit?userId=${userId.value}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
function goArrange() {
|
||||||
|
if (!canArrange.value) {
|
||||||
|
uni.showToast({ title: '请先开通有效会员卡', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uni.navigateTo({ url: `/pages/admin/member-arrange?userId=${userId.value}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
onLoad((query) => {
|
||||||
|
userId.value = String(query?.userId || '')
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
|
||||||
|
})
|
||||||
|
|
||||||
|
onShow(() => {
|
||||||
|
if (userId.value) {
|
||||||
|
loadDetail()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.page {
|
||||||
|
--ink: #514943;
|
||||||
|
--muted: #8b817b;
|
||||||
|
--line: #eee8e3;
|
||||||
|
--sage: #617d73;
|
||||||
|
min-height: 100vh;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: #fbf9f6;
|
||||||
|
color: var(--ink);
|
||||||
|
padding-bottom: calc(152rpx + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-wrap { padding: 28rpx 32rpx; }
|
||||||
|
.skeleton-hero, .skeleton-block {
|
||||||
|
border-radius: 28rpx;
|
||||||
|
background: linear-gradient(90deg, #f0eae5 25%, #faf7f3 50%, #f0eae5 75%);
|
||||||
|
background-size: 400% 100%;
|
||||||
|
animation: shimmer 1.4s infinite;
|
||||||
|
}
|
||||||
|
.skeleton-hero { height: 300rpx; margin-bottom: 28rpx; }
|
||||||
|
.skeleton-block { height: 180rpx; margin-bottom: 28rpx; }
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
margin: 28rpx 32rpx 0;
|
||||||
|
padding: 32rpx;
|
||||||
|
border-radius: 32rpx;
|
||||||
|
background: #f3eae5;
|
||||||
|
}
|
||||||
|
.hero-inner { display: flex; align-items: center; gap: 24rpx; }
|
||||||
|
.hero-avatar {
|
||||||
|
width: 112rpx;
|
||||||
|
height: 112rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 6rpx solid #fcf8f4;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.avatar-img { width: 100%; height: 100%; }
|
||||||
|
.avatar-fallback {
|
||||||
|
width: 100%; height: 100%; background: #e0cec4;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.avatar-letter {
|
||||||
|
font-family: 'Songti SC', 'STSong', serif;
|
||||||
|
font-size: 44rpx; font-weight: 400; color: #735e53;
|
||||||
|
}
|
||||||
|
.hero-copy { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 12rpx; }
|
||||||
|
.hero-name { font-size: 38rpx; font-weight: 500; line-height: 1.4; overflow-wrap: anywhere; }
|
||||||
|
.hero-phone { font-size: 26rpx; color: #84746b; letter-spacing: 1rpx; }
|
||||||
|
.member-id {
|
||||||
|
display: flex; align-items: center; gap: 14rpx;
|
||||||
|
margin-top: 24rpx; min-height: 48rpx;
|
||||||
|
font-size: 20rpx; color: #88786f;
|
||||||
|
}
|
||||||
|
.member-id-label { flex-shrink: 0; }
|
||||||
|
.member-id-value { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.member-id-copy { flex-shrink: 0; color: #715a4e; padding: 8rpx 0 8rpx 8rpx; }
|
||||||
|
.time-pair {
|
||||||
|
display: flex; gap: 20rpx;
|
||||||
|
margin-top: 20rpx; padding-top: 24rpx;
|
||||||
|
border-top: 1rpx solid #e4d8d0;
|
||||||
|
}
|
||||||
|
.time-cell { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 10rpx; }
|
||||||
|
.time-rule { width: 1rpx; background: #e4d8d0; }
|
||||||
|
.time-label { font-size: 21rpx; color: #88786f; }
|
||||||
|
.time-value { font-size: 22rpx; color: #6d5c52; line-height: 1.5; font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
.section { padding: 36rpx 32rpx 0; }
|
||||||
|
.section--last { padding-bottom: 24rpx; }
|
||||||
|
.section-label { display: block; font-size: 28rpx; font-weight: 500; margin-bottom: 20rpx; }
|
||||||
|
.section-heading { display: flex; justify-content: space-between; align-items: baseline; gap: 16rpx; }
|
||||||
|
.supplement-entry { margin: 0; padding: 8rpx 18rpx; line-height: 1.5; font-size: 23rpx; border-radius: 999rpx; background: #edf2ec; color: #617d73; &::after { border: none; } }
|
||||||
|
.section-note { font-size: 22rpx; color: var(--muted); }
|
||||||
|
.stats-strip {
|
||||||
|
display: flex; padding: 26rpx 0;
|
||||||
|
border-radius: 24rpx; background: #ffffff;
|
||||||
|
}
|
||||||
|
.stat {
|
||||||
|
flex: 1; min-width: 0; display: flex; flex-direction: column;
|
||||||
|
align-items: center; gap: 12rpx; border-right: 1rpx solid var(--line);
|
||||||
|
&:last-child { border-right: none; }
|
||||||
|
}
|
||||||
|
.stat-num { font-size: 40rpx; font-weight: 400; line-height: 1.1; font-family: 'DIN Alternate', 'Avenir Next', sans-serif; font-variant-numeric: tabular-nums; }
|
||||||
|
.stat-name { font-size: 22rpx; color: var(--muted); }
|
||||||
|
.stat--completed { .stat-num, .stat-name { color: var(--sage); } }
|
||||||
|
|
||||||
|
.card-list { display: flex; flex-direction: column; gap: 20rpx; }
|
||||||
|
.mship { padding: 28rpx; background: #fff; border-radius: 28rpx; border: 1rpx solid var(--line); }
|
||||||
|
.mship-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 20rpx; }
|
||||||
|
.mship-titles { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8rpx; }
|
||||||
|
.mship-name { font-size: 29rpx; font-weight: 500; line-height: 1.5; overflow-wrap: anywhere; }
|
||||||
|
.mship-type { font-size: 21rpx; color: var(--muted); }
|
||||||
|
.mship-status {
|
||||||
|
flex-shrink: 0; padding: 6rpx 16rpx; border-radius: 999rpx;
|
||||||
|
background: #edf2ee; color: #617d73; line-height: 1.3;
|
||||||
|
&--expired, &--used_up { background: #f2efec; color: #8b817b; }
|
||||||
|
}
|
||||||
|
.mship-status-text { font-size: 21rpx; }
|
||||||
|
.mship-times { margin-top: 22rpx; display: flex; align-items: baseline; gap: 10rpx; }
|
||||||
|
.mship-times-num { font-size: 52rpx; font-weight: 400; font-family: 'DIN Alternate', 'Avenir Next', sans-serif; line-height: 1.2; }
|
||||||
|
.mship-times-unit { font-size: 22rpx; color: var(--muted); }
|
||||||
|
.mship-duration { display: flex; align-items: baseline; flex-wrap: wrap; gap: 12rpx; margin-top: 24rpx; }
|
||||||
|
.mship-duration-title { font-size: 28rpx; color: #617d73; }
|
||||||
|
.progress { margin-top: 18rpx; }
|
||||||
|
.progress-bar { height: 6rpx; border-radius: 6rpx; background: #f0efea; overflow: hidden; }
|
||||||
|
.progress-fill { height: 100%; border-radius: 6rpx; background: #a5b8ab; }
|
||||||
|
.progress-text { display: block; margin-top: 10rpx; font-size: 20rpx; color: var(--muted); }
|
||||||
|
.mship-dates {
|
||||||
|
margin-top: 22rpx; padding-top: 18rpx; border-top: 1rpx solid #f3efeb;
|
||||||
|
display: flex; justify-content: space-between; flex-wrap: wrap; gap: 8rpx 20rpx;
|
||||||
|
font-size: 21rpx; color: var(--muted); line-height: 1.5;
|
||||||
|
}
|
||||||
|
.empty-card {
|
||||||
|
background: #f4f2ed; border-radius: 28rpx; padding: 36rpx 28rpx;
|
||||||
|
display: flex; flex-direction: column; align-items: flex-start; gap: 12rpx;
|
||||||
|
}
|
||||||
|
.empty-card-title { font-size: 28rpx; font-weight: 500; }
|
||||||
|
.empty-card-sub { font-size: 23rpx; color: var(--muted); line-height: 1.7; }
|
||||||
|
.empty-card-btn { margin-top: 10rpx; padding: 14rpx 28rpx; border-radius: 999rpx; background: #e4ebe4; }
|
||||||
|
.empty-card-btn-text { font-size: 24rpx; color: #526e62; }
|
||||||
|
|
||||||
|
.upcoming-list { background: #fff; border-radius: 28rpx; padding: 0 28rpx; }
|
||||||
|
.upcoming-row {
|
||||||
|
display: flex; align-items: center; justify-content: space-between; gap: 24rpx;
|
||||||
|
padding: 26rpx 0; border-bottom: 1rpx solid var(--line);
|
||||||
|
&:last-child { border-bottom: none; }
|
||||||
|
}
|
||||||
|
.upcoming-time { flex-shrink: 0; display: flex; flex-direction: column; gap: 10rpx; }
|
||||||
|
.upcoming-date { font-size: 28rpx; font-weight: 500; }
|
||||||
|
.upcoming-hour { font-size: 23rpx; color: var(--muted); font-variant-numeric: tabular-nums; }
|
||||||
|
.upcoming-meta { min-width: 0; display: flex; flex-direction: column; align-items: flex-end; gap: 10rpx; }
|
||||||
|
.upcoming-card { font-size: 24rpx; text-align: right; overflow-wrap: anywhere; }
|
||||||
|
.upcoming-status { font-size: 21rpx; color: var(--sage); }
|
||||||
|
.upcoming-empty { padding: 32rpx 28rpx; background: #f4f2ed; border-radius: 24rpx; }
|
||||||
|
.upcoming-empty-text { font-size: 24rpx; color: var(--muted); }
|
||||||
|
|
||||||
|
.dock {
|
||||||
|
position: fixed; z-index: 10; left: 0; right: 0; bottom: 0;
|
||||||
|
display: flex; gap: 20rpx;
|
||||||
|
padding: 20rpx 32rpx calc(20rpx + env(safe-area-inset-bottom));
|
||||||
|
background: #fbf9f6; border-top: 1rpx solid var(--line);
|
||||||
|
}
|
||||||
|
.dock-btn { flex: 1; height: 88rpx; border-radius: 999rpx; display: flex; align-items: center; justify-content: center; }
|
||||||
|
.dock-btn--ghost { background: #f0eae4; }
|
||||||
|
.dock-btn--solid { flex: 1.35; background: #6b8276; }
|
||||||
|
.dock-btn--disabled { background: #d9dfd8; .dock-btn-text--solid { color: #677467; } }
|
||||||
|
.dock-btn-text { font-size: 28rpx; font-weight: 500; color: #78675c; }
|
||||||
|
.dock-btn-text--solid { color: #fff; }
|
||||||
|
</style>
|
||||||
435
packages/app/src/pages/admin/member-edit.vue
Normal file
435
packages/app/src/pages/admin/member-edit.vue
Normal file
@@ -0,0 +1,435 @@
|
|||||||
|
<template>
|
||||||
|
<view class="page" :style="{ paddingTop: navBarHeight }">
|
||||||
|
<CustomNavBar title="编辑资料" show-back />
|
||||||
|
|
||||||
|
<view v-if="pageLoading" class="loading-wrap">
|
||||||
|
<text class="loading-text">加载中...</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-else class="form">
|
||||||
|
<view class="block">
|
||||||
|
<text class="block-title">基本信息</text>
|
||||||
|
<view class="field">
|
||||||
|
<text class="field-label">昵称</text>
|
||||||
|
<input class="field-input" v-model="profileForm.nickname" maxlength="32" placeholder="会员昵称" />
|
||||||
|
</view>
|
||||||
|
<view class="field">
|
||||||
|
<text class="field-label">手机号</text>
|
||||||
|
<input class="field-input" v-model="profileForm.phone" maxlength="20" type="number" placeholder="未绑定可手动填写" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="block">
|
||||||
|
<text class="block-title">{{ editingMembership ? '会员卡' : '开通会员卡' }}</text>
|
||||||
|
|
||||||
|
<view v-if="existingMemberships.length > 1" class="field">
|
||||||
|
<text class="field-label">选择卡片</text>
|
||||||
|
<picker
|
||||||
|
class="field-picker"
|
||||||
|
mode="selector"
|
||||||
|
:value="membershipIndex"
|
||||||
|
:range="membershipPickerLabels"
|
||||||
|
@change="onMembershipPick"
|
||||||
|
>
|
||||||
|
<view class="picker-inner">
|
||||||
|
<text class="picker-text">{{ membershipPickerLabels[membershipIndex] }}</text>
|
||||||
|
<text class="picker-arrow">▾</text>
|
||||||
|
</view>
|
||||||
|
</picker>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="field">
|
||||||
|
<text class="field-label">卡类型</text>
|
||||||
|
<picker
|
||||||
|
class="field-picker"
|
||||||
|
mode="selector"
|
||||||
|
:value="editForm.cardTypeIndex"
|
||||||
|
:range="cardTypes"
|
||||||
|
range-key="name"
|
||||||
|
@change="onCardTypeChange"
|
||||||
|
>
|
||||||
|
<view class="picker-inner">
|
||||||
|
<text class="picker-text">{{ cardTypes[editForm.cardTypeIndex]?.name || '请选择' }}</text>
|
||||||
|
<text class="picker-arrow">▾</text>
|
||||||
|
</view>
|
||||||
|
</picker>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="selectedCardType" class="field">
|
||||||
|
<text class="field-label">
|
||||||
|
{{ canLeaveTimesEmpty ? '剩余次数(留空不限次)' : '剩余次数' }}
|
||||||
|
</text>
|
||||||
|
<input
|
||||||
|
class="field-input"
|
||||||
|
type="number"
|
||||||
|
v-model="editForm.remainingTimes"
|
||||||
|
:placeholder="canLeaveTimesEmpty ? '留空表示不限次' : '请输入剩余次数'"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="field">
|
||||||
|
<text class="field-label">开始日期</text>
|
||||||
|
<picker class="field-picker" mode="date" :value="editForm.startDate" @change="onStartDateChange">
|
||||||
|
<view class="picker-inner">
|
||||||
|
<text class="picker-text">{{ editForm.startDate || '请选择' }}</text>
|
||||||
|
<text class="picker-arrow">▾</text>
|
||||||
|
</view>
|
||||||
|
</picker>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="field">
|
||||||
|
<text class="field-label">到期日期</text>
|
||||||
|
<picker class="field-picker" mode="date" :value="editForm.expireDate" @change="onExpireDateChange">
|
||||||
|
<view class="picker-inner">
|
||||||
|
<text class="picker-text">{{ editForm.expireDate || '请选择' }}</text>
|
||||||
|
<text class="picker-arrow">▾</text>
|
||||||
|
</view>
|
||||||
|
</picker>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="editingMembership" class="danger-link" @tap="onClearMembership">
|
||||||
|
<text class="danger-link-text">解除该会员卡</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view
|
||||||
|
class="save-btn"
|
||||||
|
:class="{ 'save-btn--disabled': submitting }"
|
||||||
|
@tap="onSave"
|
||||||
|
>
|
||||||
|
<text class="save-btn-text">{{ submitting ? '保存中...' : '保存' }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { onLoad } from '@dcloudio/uni-app'
|
||||||
|
import type { AdminMemberDetail, CardType, MembershipWithCardType } from '@mp-pilates/shared'
|
||||||
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
|
import { getSystemLayout } from '../../utils/system'
|
||||||
|
import { formatDateLocal } from '../../utils/format'
|
||||||
|
import { getErrorMessage } from '../../utils/auth'
|
||||||
|
import { useAdminStore } from '../../stores/admin'
|
||||||
|
|
||||||
|
const adminStore = useAdminStore()
|
||||||
|
const navBarHeight = ref('64px')
|
||||||
|
const userId = ref('')
|
||||||
|
const pageLoading = ref(true)
|
||||||
|
const submitting = ref(false)
|
||||||
|
const detail = ref<AdminMemberDetail | null>(null)
|
||||||
|
const cardTypes = ref<CardType[]>([])
|
||||||
|
const membershipIndex = ref(0)
|
||||||
|
|
||||||
|
const profileForm = ref({
|
||||||
|
nickname: '',
|
||||||
|
phone: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const editForm = ref({
|
||||||
|
membershipId: '' as string | '',
|
||||||
|
cardTypeIndex: 0,
|
||||||
|
cardTypeId: '',
|
||||||
|
remainingTimes: null as number | string | null,
|
||||||
|
startDate: '',
|
||||||
|
expireDate: '',
|
||||||
|
manuallyEditedExpire: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const existingMemberships = computed(() => detail.value?.memberships ?? [])
|
||||||
|
const editingMembership = computed(() => existingMemberships.value[membershipIndex.value] ?? null)
|
||||||
|
|
||||||
|
const membershipPickerLabels = computed(() =>
|
||||||
|
existingMemberships.value.map((item) => `${item.cardType.name} · ${item.status}`),
|
||||||
|
)
|
||||||
|
|
||||||
|
const selectedCardType = computed(() => cardTypes.value[editForm.value.cardTypeIndex] ?? null)
|
||||||
|
const canLeaveTimesEmpty = computed(() => {
|
||||||
|
const cardType = selectedCardType.value
|
||||||
|
const membership = editingMembership.value
|
||||||
|
if (!cardType || cardType.type !== 'DURATION') return false
|
||||||
|
|
||||||
|
return cardType.totalTimes === null || (
|
||||||
|
membership?.cardTypeId === cardType.id && membership.remainingTimes === null
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
function calculateExpireDate(startDate: string, durationDays: number): string {
|
||||||
|
const d = new Date(startDate)
|
||||||
|
d.setDate(d.getDate() + durationDays)
|
||||||
|
return formatDateLocal(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyMembership(membership: MembershipWithCardType | null) {
|
||||||
|
const types = cardTypes.value
|
||||||
|
if (membership) {
|
||||||
|
const idx = types.findIndex((item) => item.id === membership.cardTypeId)
|
||||||
|
editForm.value = {
|
||||||
|
membershipId: membership.id,
|
||||||
|
cardTypeIndex: idx >= 0 ? idx : 0,
|
||||||
|
cardTypeId: membership.cardTypeId,
|
||||||
|
remainingTimes: membership.remainingTimes,
|
||||||
|
startDate: membership.startDate.slice(0, 10),
|
||||||
|
expireDate: membership.expireDate.slice(0, 10),
|
||||||
|
manuallyEditedExpire: false,
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
editForm.value = {
|
||||||
|
membershipId: '',
|
||||||
|
cardTypeIndex: 0,
|
||||||
|
cardTypeId: types[0]?.id || '',
|
||||||
|
remainingTimes: types[0]?.totalTimes ?? null,
|
||||||
|
startDate: formatDateLocal(new Date()),
|
||||||
|
expireDate: calculateExpireDate(formatDateLocal(new Date()), types[0]?.durationDays ?? 30),
|
||||||
|
manuallyEditedExpire: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMembershipPick(e: { detail: { value: number } }) {
|
||||||
|
membershipIndex.value = Number(e.detail.value)
|
||||||
|
applyMembership(existingMemberships.value[membershipIndex.value] ?? null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCardTypeChange(e: { detail: { value: number } }) {
|
||||||
|
const idx = Number(e.detail.value)
|
||||||
|
const cardType = cardTypes.value[idx]
|
||||||
|
if (!cardType) return
|
||||||
|
|
||||||
|
editForm.value.cardTypeIndex = idx
|
||||||
|
editForm.value.cardTypeId = cardType.id
|
||||||
|
editForm.value.remainingTimes = cardType.totalTimes
|
||||||
|
if (!editForm.value.manuallyEditedExpire) {
|
||||||
|
editForm.value.startDate = formatDateLocal(new Date())
|
||||||
|
editForm.value.expireDate = calculateExpireDate(formatDateLocal(new Date()), cardType.durationDays)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onStartDateChange(e: { detail: { value: string } }) {
|
||||||
|
editForm.value.startDate = e.detail.value
|
||||||
|
if (!editForm.value.manuallyEditedExpire) {
|
||||||
|
const cardType = cardTypes.value[editForm.value.cardTypeIndex]
|
||||||
|
if (cardType) {
|
||||||
|
editForm.value.expireDate = calculateExpireDate(e.detail.value, cardType.durationDays)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onExpireDateChange(e: { detail: { value: string } }) {
|
||||||
|
editForm.value.expireDate = e.detail.value
|
||||||
|
editForm.value.manuallyEditedExpire = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRemainingTimes(): number | null | undefined {
|
||||||
|
const rawValue = editForm.value.remainingTimes
|
||||||
|
if (rawValue === null || (typeof rawValue === 'string' && rawValue.trim() === '')) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const remainingTimes = Number(rawValue)
|
||||||
|
if (!Number.isInteger(remainingTimes) || remainingTimes < 0) return undefined
|
||||||
|
return remainingTimes
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPage() {
|
||||||
|
pageLoading.value = true
|
||||||
|
try {
|
||||||
|
const [member, types] = await Promise.all([
|
||||||
|
adminStore.fetchMemberDetail(userId.value),
|
||||||
|
cardTypes.value.length ? Promise.resolve(cardTypes.value) : adminStore.fetchCardTypes(),
|
||||||
|
])
|
||||||
|
detail.value = member
|
||||||
|
cardTypes.value = [...types]
|
||||||
|
profileForm.value = {
|
||||||
|
nickname: member.user.nickname || '',
|
||||||
|
phone: member.user.phone || '',
|
||||||
|
}
|
||||||
|
membershipIndex.value = 0
|
||||||
|
applyMembership(member.memberships[0] ?? null)
|
||||||
|
} catch (err: unknown) {
|
||||||
|
uni.showToast({ title: getErrorMessage(err, '加载失败'), icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
pageLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSave() {
|
||||||
|
if (submitting.value || !userId.value) return
|
||||||
|
if (!editForm.value.cardTypeId) {
|
||||||
|
uni.showToast({ title: '请选择卡类型', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const remainingTimes = parseRemainingTimes()
|
||||||
|
if (remainingTimes === undefined) {
|
||||||
|
uni.showToast({ title: '剩余次数需为非负整数', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (remainingTimes === null && !canLeaveTimesEmpty.value) {
|
||||||
|
uni.showToast({ title: '请输入剩余次数', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
await adminStore.updateMemberProfile(userId.value, {
|
||||||
|
nickname: profileForm.value.nickname.trim(),
|
||||||
|
phone: profileForm.value.phone.trim(),
|
||||||
|
})
|
||||||
|
await adminStore.updateUserMembership(userId.value, {
|
||||||
|
...(editForm.value.membershipId ? { membershipId: editForm.value.membershipId } : {}),
|
||||||
|
cardTypeId: editForm.value.cardTypeId,
|
||||||
|
remainingTimes,
|
||||||
|
startDate: editForm.value.startDate,
|
||||||
|
expireDate: editForm.value.expireDate,
|
||||||
|
})
|
||||||
|
uni.showToast({ title: '保存成功', icon: 'success' })
|
||||||
|
setTimeout(() => uni.navigateBack(), 400)
|
||||||
|
} catch (err: unknown) {
|
||||||
|
uni.showToast({ title: getErrorMessage(err, '保存失败'), icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClearMembership() {
|
||||||
|
if (!userId.value) return
|
||||||
|
uni.showModal({
|
||||||
|
title: '确认解除',
|
||||||
|
content: '确定要解除当前这张会员卡吗?其他卡不受影响。',
|
||||||
|
confirmColor: '#C47A7A',
|
||||||
|
success: async (res) => {
|
||||||
|
if (!res.confirm) return
|
||||||
|
const membershipId = editForm.value.membershipId
|
||||||
|
if (!membershipId) {
|
||||||
|
uni.showToast({ title: '没有可解除的会员卡', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await adminStore.deleteUserMembership(userId.value, membershipId)
|
||||||
|
uni.showToast({ title: '已解除', icon: 'success' })
|
||||||
|
setTimeout(() => uni.navigateBack(), 400)
|
||||||
|
} catch (err: unknown) {
|
||||||
|
uni.showToast({ title: getErrorMessage(err, '操作失败'), icon: 'none' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onLoad((query) => {
|
||||||
|
userId.value = String(query?.userId || '')
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
|
||||||
|
loadPage()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: $bg-page;
|
||||||
|
padding-bottom: 80rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-wrap {
|
||||||
|
padding: 80rpx 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-text {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: $text-hint;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form {
|
||||||
|
padding: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block {
|
||||||
|
background: $bg-card;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
padding: 8rpx 24rpx 16rpx;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
border: 1rpx solid rgba(180, 160, 130, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.block-title {
|
||||||
|
display: block;
|
||||||
|
padding: 20rpx 0 8rpx;
|
||||||
|
font-size: 22rpx;
|
||||||
|
letter-spacing: 3rpx;
|
||||||
|
color: $text-hint;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
padding: 20rpx 0;
|
||||||
|
border-bottom: 1rpx solid rgba(180, 160, 130, 0.1);
|
||||||
|
|
||||||
|
&:last-of-type { border-bottom: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: $text-hint;
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-input {
|
||||||
|
height: 64rpx;
|
||||||
|
font-size: 30rpx;
|
||||||
|
color: $text-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-picker {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-inner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: 64rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-text {
|
||||||
|
font-size: 30rpx;
|
||||||
|
color: $text-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-arrow {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: $text-hint;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-link {
|
||||||
|
padding: 20rpx 0 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-link-text {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: $error-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-btn {
|
||||||
|
margin-top: 12rpx;
|
||||||
|
height: 88rpx;
|
||||||
|
border-radius: 18rpx;
|
||||||
|
background: $brand-color;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-btn--disabled { opacity: 0.5; }
|
||||||
|
|
||||||
|
.save-btn-text {
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff8f0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
211
packages/app/src/pages/admin/member-supplement.vue
Normal file
211
packages/app/src/pages/admin/member-supplement.vue
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
<template>
|
||||||
|
<view class="page" :style="{ paddingTop: navBarHeight }">
|
||||||
|
<CustomNavBar title="补录上课" show-back />
|
||||||
|
<view v-if="loading && !detail" class="state">正在读取会员资料…</view>
|
||||||
|
<view v-else-if="!detail" class="state"><text>{{ loadError || '暂时无法读取会员资料' }}</text><button @tap="load">重新加载</button></view>
|
||||||
|
<template v-else>
|
||||||
|
<view v-if="loadError" class="pending-note">{{ loadError }}<button class="reload" :disabled="loading || busy" @tap="load">刷新资料</button></view>
|
||||||
|
<view class="intro">
|
||||||
|
<text class="eyebrow">历史课程</text>
|
||||||
|
<text class="title">补齐每一节练习</text>
|
||||||
|
<text class="intro-note">为 {{ detail.user.nickname || '这位学员' }} 补录已经上完的课程。</text>
|
||||||
|
<view class="current"><text>累计上课</text><text>{{ detail.stats.completedBookings }} 节</text></view>
|
||||||
|
</view>
|
||||||
|
<view v-if="pending" class="pending-note">有一笔补录正在核对。请点击下方重试,系统会避免重复计入。</view>
|
||||||
|
<view class="form-card">
|
||||||
|
<text class="label">补录节数</text>
|
||||||
|
<view class="quantity-row">
|
||||||
|
<input v-model="quantity" class="quantity-input" type="number" maxlength="3" placeholder="0" :disabled="busy || !!pending" :cursor-spacing="24" />
|
||||||
|
<text class="unit">节已上课程</text>
|
||||||
|
</view>
|
||||||
|
<view class="quick-values"><button v-for="n in [1, 5, 10, 20]" :key="n" :class="{ selected: Number(quantity) === n }" :disabled="busy || !!pending" @tap="quantity = String(n)">{{ n }} 节</button></view>
|
||||||
|
<text class="hint">可填写 1–999 节,无需逐节创建预约</text>
|
||||||
|
</view>
|
||||||
|
<view class="form-card">
|
||||||
|
<text class="label">会员卡次数</text>
|
||||||
|
<view class="options">
|
||||||
|
<button class="option" :class="{ chosen: !deduct }" :disabled="busy || !!pending" @tap="deduct = false"><text class="option-title">仅补记录</text><text class="option-note">不改变会员卡余额</text></button>
|
||||||
|
<button class="option" :class="{ chosen: deduct }" :disabled="busy || !!pending" @tap="deduct = true"><text class="option-title">同时扣次</text><text class="option-note">从指定会员卡扣除</text></button>
|
||||||
|
</view>
|
||||||
|
<template v-if="deduct">
|
||||||
|
<text v-if="!cards.length" class="field-error">暂无可扣次的会员卡,请选择仅补记录。</text>
|
||||||
|
<picker v-else :range="cardLabels" :value="cardIndex" :disabled="busy || !!pending" @change="cardIndex = Number($event.detail.value)">
|
||||||
|
<view class="card-picker"><text>{{ cardLabels[cardIndex] || '请选择会员卡' }}</text><text>⌄</text></view>
|
||||||
|
</picker>
|
||||||
|
<text v-if="selectedCard && validQuantity" class="balance" :class="{ 'field-error': insufficient }">{{ insufficient ? '余额不足,请调整节数或更换会员卡' : `扣除 ${count} 次后,剩余 ${selectedCard.remainingTimes! - count} 次` }}</text>
|
||||||
|
<text class="hint">过去已扣过的课程,请选择「仅补记录」。不限次卡无需扣次。</text>
|
||||||
|
</template>
|
||||||
|
</view>
|
||||||
|
<view class="form-card">
|
||||||
|
<view class="label-row"><text class="label">备注</text><text class="optional">选填</text></view>
|
||||||
|
<textarea v-model="remark" class="remark" maxlength="200" placeholder="例如:系统启用前已完成的 10 节私教课" :disabled="busy || !!pending" :cursor-spacing="32" />
|
||||||
|
<text class="hint">{{ remark.length }} / 200</text>
|
||||||
|
</view>
|
||||||
|
<view class="summary">
|
||||||
|
<text class="summary-title">{{ validQuantity ? `累计上课将增加 ${count} 节` : '填写已完成的课程节数' }}</text>
|
||||||
|
<text class="hint">记录显示为「系统补录」。不计入本月上课、练习天数与最近 30 天网格。</text>
|
||||||
|
</view>
|
||||||
|
<view class="submit-wrap"><button class="submit" :disabled="busy || loading || !!loadError || (!pending && !canSubmit)" :loading="busy" @tap="submit">{{ busy ? '正在处理…' : pending ? '核对并重试补录' : '确认补录' }}</button><text v-if="submitError" class="field-error">{{ submitError }}</text></view>
|
||||||
|
<view class="history">
|
||||||
|
<view class="label-row"><text class="label">补录记录</text><text class="optional">{{ records.length }} 笔</text></view>
|
||||||
|
<LessonSupplementList v-if="records.length" :records="records" editable :busy="busy || !!pending" @revoke="revoke" />
|
||||||
|
<text v-else class="empty">还没有补录记录</text>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { onLoad } from '@dcloudio/uni-app'
|
||||||
|
import type { AdminMemberDetail, CreateLessonSupplementDto, LessonSupplementRecord } from '@mp-pilates/shared'
|
||||||
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
|
import LessonSupplementList from '../../components/LessonSupplementList.vue'
|
||||||
|
import { useAdminStore } from '../../stores/admin'
|
||||||
|
import { getSystemLayout } from '../../utils/system'
|
||||||
|
import { HttpRequestError } from '../../utils/request'
|
||||||
|
import { getErrorMessage } from '../../utils/auth'
|
||||||
|
|
||||||
|
const store = useAdminStore()
|
||||||
|
const navBarHeight = `${getSystemLayout().navBarHeight}px`
|
||||||
|
const userId = ref('')
|
||||||
|
const detail = ref<AdminMemberDetail | null>(null)
|
||||||
|
const records = ref<LessonSupplementRecord[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const busy = ref(false)
|
||||||
|
const loadError = ref('')
|
||||||
|
const submitError = ref('')
|
||||||
|
const quantity = ref('')
|
||||||
|
const remark = ref('')
|
||||||
|
const deduct = ref(false)
|
||||||
|
const cardIndex = ref(0)
|
||||||
|
const pending = ref<CreateLessonSupplementDto | null>(null)
|
||||||
|
const storageKey = computed(() => `lesson-supplement-pending:${userId.value}`)
|
||||||
|
const cards = computed(() => (detail.value?.memberships ?? []).filter(c => c.remainingTimes !== null && c.remainingTimes > 0))
|
||||||
|
const cardLabels = computed(() => cards.value.map(c => `${c.cardType.name} · 余 ${c.remainingTimes} 次${new Date(c.expireDate) <= new Date() || c.status === 'EXPIRED' ? ' · 已过期' : ''}`))
|
||||||
|
const selectedCard = computed(() => cards.value[cardIndex.value])
|
||||||
|
const count = computed(() => Number(quantity.value))
|
||||||
|
const validQuantity = computed(() => /^\d{1,3}$/.test(quantity.value) && count.value >= 1 && count.value <= 999)
|
||||||
|
const insufficient = computed(() => !!selectedCard.value && count.value > selectedCard.value.remainingTimes!)
|
||||||
|
const canSubmit = computed(() => validQuantity.value && (!deduct.value || (!!selectedCard.value && !insufficient.value)))
|
||||||
|
|
||||||
|
function clearPending() {
|
||||||
|
pending.value = null
|
||||||
|
uni.removeStorageSync(storageKey.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
if (!userId.value) { loadError.value = '缺少会员信息,请返回会员档案'; return }
|
||||||
|
loading.value = true
|
||||||
|
loadError.value = ''
|
||||||
|
try {
|
||||||
|
const [member, items] = await Promise.all([store.fetchMemberDetail(userId.value), store.fetchLessonSupplements(userId.value)])
|
||||||
|
detail.value = member
|
||||||
|
records.value = items
|
||||||
|
if (pending.value && items.some(item => item.requestId === pending.value!.requestId)) {
|
||||||
|
clearPending()
|
||||||
|
quantity.value = ''
|
||||||
|
remark.value = ''
|
||||||
|
submitError.value = ''
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
loadError.value = getErrorMessage(err, '加载失败,请重试')
|
||||||
|
if (detail.value) uni.showToast({ title: loadError.value, icon: 'none' })
|
||||||
|
} finally { loading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (busy.value || loading.value || loadError.value || (!pending.value && !canSubmit.value)) return
|
||||||
|
busy.value = true
|
||||||
|
submitError.value = ''
|
||||||
|
try {
|
||||||
|
if (!pending.value) {
|
||||||
|
const result = await uni.showModal({
|
||||||
|
title: `补录 ${count.value} 节已上课程`,
|
||||||
|
content: `学员:${detail.value?.user.nickname || '未命名学员'}。${deduct.value ? `从「${selectedCard.value.cardType.name}」扣除 ${count.value} 次,剩余 ${selectedCard.value.remainingTimes! - count.value} 次。` : '仅增加上课记录,不扣会员卡次数。'}`,
|
||||||
|
confirmText: '确认补录', confirmColor: '#617d73',
|
||||||
|
})
|
||||||
|
if (!result.confirm) return
|
||||||
|
const payload: CreateLessonSupplementDto = {
|
||||||
|
requestId: `supp_${Date.now()}_${Math.random().toString(36).slice(2)}_${Math.random().toString(36).slice(2)}`,
|
||||||
|
quantity: count.value, ...(deduct.value ? { membershipId: selectedCard.value.id } : {}),
|
||||||
|
...(remark.value.trim() ? { remark: remark.value.trim() } : {}),
|
||||||
|
}
|
||||||
|
// Retain the exact request across timeouts, navigation and application restarts.
|
||||||
|
uni.setStorageSync(storageKey.value, payload)
|
||||||
|
pending.value = payload
|
||||||
|
}
|
||||||
|
const record = await store.createLessonSupplement(userId.value, pending.value)
|
||||||
|
if (!records.value.some(item => item.id === record.id)) records.value = [record, ...records.value]
|
||||||
|
clearPending()
|
||||||
|
quantity.value = ''
|
||||||
|
remark.value = ''
|
||||||
|
uni.showToast({ title: `已补录 ${record.quantity} 节课`, icon: 'success' })
|
||||||
|
await load()
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof HttpRequestError && err.statusCode >= 400 && err.statusCode < 500 && err.statusCode !== 408) clearPending()
|
||||||
|
submitError.value = getErrorMessage(err, '提交失败,请重试')
|
||||||
|
if (pending.value) submitError.value = '暂未确认提交结果,请核对并重试,不会重复补录。'
|
||||||
|
} finally { busy.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revoke(item: LessonSupplementRecord) {
|
||||||
|
if (busy.value || pending.value) return
|
||||||
|
busy.value = true
|
||||||
|
try {
|
||||||
|
const result = await uni.showModal({ title: '撤销这笔补录?', content: `累计上课减少 ${item.quantity} 节。${item.deductedTimes ? `向原会员卡返还 ${item.deductedTimes} 次。` : '会员卡余额不变。'}记录会保留为已撤销。`, confirmText: '确认撤销', confirmColor: '#967561' })
|
||||||
|
if (!result.confirm) return
|
||||||
|
await store.revokeLessonSupplement(userId.value, item.id)
|
||||||
|
uni.showToast({ title: '已撤销补录', icon: 'success' })
|
||||||
|
await load()
|
||||||
|
} catch (err) { uni.showToast({ title: getErrorMessage(err, '撤销失败,请重试'), icon: 'none' }) }
|
||||||
|
finally { busy.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
onLoad(query => {
|
||||||
|
userId.value = String(query?.userId || '')
|
||||||
|
const saved = uni.getStorageSync(storageKey.value) as CreateLessonSupplementDto | undefined
|
||||||
|
if (saved?.requestId && Number.isInteger(saved.quantity)) {
|
||||||
|
pending.value = saved
|
||||||
|
quantity.value = String(saved.quantity)
|
||||||
|
remark.value = saved.remark || ''
|
||||||
|
deduct.value = !!saved.membershipId
|
||||||
|
}
|
||||||
|
load().then(() => {
|
||||||
|
if (pending.value?.membershipId) cardIndex.value = cards.value.findIndex(c => c.id === pending.value!.membershipId)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.page { min-height: 100vh; box-sizing: border-box; background: #fbf9f6; color: #514943; padding-bottom: calc(40rpx + env(safe-area-inset-bottom)); }
|
||||||
|
.intro { margin: 28rpx 32rpx; padding: 32rpx; border-radius: 30rpx; background: #f2e9e3; }
|
||||||
|
.eyebrow { display: block; font-size: 21rpx; color: #967c69; }
|
||||||
|
.title { display: block; margin-top: 14rpx; font-size: 38rpx; font-family: 'Songti SC', 'STSong', serif; }
|
||||||
|
.intro-note { display: block; margin-top: 16rpx; font-size: 24rpx; line-height: 1.7; color: #87786b; overflow-wrap: anywhere; }
|
||||||
|
.current { display: flex; justify-content: space-between; gap: 20rpx; margin-top: 26rpx; padding-top: 22rpx; border-top: 1rpx solid #e4d8cf; font-size: 24rpx; color: #78675c; }
|
||||||
|
.form-card { margin: 24rpx 32rpx; padding: 28rpx; border-radius: 26rpx; border: 1rpx solid #eee8e3; background: #fff; }
|
||||||
|
.label { font-size: 27rpx; font-weight: 500; }
|
||||||
|
.label-row { display: flex; align-items: center; justify-content: space-between; gap: 20rpx; margin-bottom: 20rpx; }
|
||||||
|
.optional { font-size: 21rpx; color: #9a9086; }
|
||||||
|
.quantity-row { display: flex; align-items: baseline; gap: 20rpx; margin-top: 20rpx; }
|
||||||
|
.quantity-input { width: 180rpx; min-width: 0; height: 92rpx; font-size: 64rpx; color: #617d73; font-variant-numeric: tabular-nums; }
|
||||||
|
.unit { font-size: 24rpx; color: #8b817b; }
|
||||||
|
.quick-values { display: flex; gap: 14rpx; margin-top: 20rpx; button { flex: 1; padding: 0; margin: 0; border-radius: 14rpx; font-size: 24rpx; line-height: 64rpx; background: #f5f3ef; color: #81756b; &::after { border: none; } &.selected { background: #e8efe7; color: #617d73; } } }
|
||||||
|
.hint { display: block; margin-top: 18rpx; color: #92867b; font-size: 22rpx; line-height: 1.7; }
|
||||||
|
.options { display: flex; gap: 16rpx; margin-top: 22rpx; }
|
||||||
|
.option { flex: 1; min-width: 0; margin: 0; padding: 22rpx 12rpx; text-align: left; border-radius: 18rpx; border: 2rpx solid #eee8e3; background: #fff; line-height: 1.5; &::after { border: none; } &.chosen { border-color: #a6b8a9; background: #eff3ed; } }
|
||||||
|
.option-title { display: block; font-size: 26rpx; color: #617d73; }
|
||||||
|
.option-note { display: block; margin-top: 8rpx; font-size: 20rpx; color: #8b817b; }
|
||||||
|
.card-picker { display: flex; align-items: center; justify-content: space-between; gap: 16rpx; margin-top: 22rpx; padding: 20rpx; border-radius: 16rpx; background: #f6f3ef; font-size: 24rpx; line-height: 1.6; }
|
||||||
|
.balance { display: block; margin-top: 16rpx; font-size: 23rpx; color: #617d73; }
|
||||||
|
.remark { width: 100%; height: 136rpx; box-sizing: border-box; padding: 18rpx; border-radius: 16rpx; background: #faf8f5; font-size: 25rpx; line-height: 1.6; }
|
||||||
|
.summary { margin: 28rpx 40rpx; }
|
||||||
|
.summary-title { font-size: 26rpx; color: #617d73; }
|
||||||
|
.submit-wrap { margin: 28rpx 32rpx; }
|
||||||
|
.submit { display: block; width: 100%; padding: 0; margin: 0; line-height: 88rpx; border-radius: 999rpx; background: #6b8276; color: #fff; font-size: 28rpx; &::after { border: none; } &[disabled] { background: #e3e8df; color: #8a9586; } }
|
||||||
|
.history { margin: 44rpx 32rpx 0; }
|
||||||
|
.empty { display: block; padding: 36rpx 28rpx; border-radius: 24rpx; background: #f3f0eb; font-size: 24rpx; color: #8b817b; }
|
||||||
|
.field-error { display: block; margin-top: 18rpx; color: #a36e59; font-size: 23rpx; line-height: 1.6; }
|
||||||
|
.pending-note { margin: 24rpx 32rpx; padding: 24rpx; background: #f2e9e3; color: #8b7160; border-radius: 20rpx; font-size: 24rpx; line-height: 1.7; }
|
||||||
|
.reload { margin: 16rpx 0 0; font-size: 24rpx; line-height: 2; background: #e5eade; color: #617d73; &::after { border: none; } }
|
||||||
|
.state { padding: 60rpx 32rpx; font-size: 26rpx; color: #8b817b; text-align: center; button { margin-top: 24rpx; font-size: 26rpx; } }
|
||||||
|
</style>
|
||||||
@@ -1,40 +1,55 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="page">
|
<view class="page" :style="{ paddingTop: navBarHeight }">
|
||||||
<!-- Search bar -->
|
<CustomNavBar title="会员管理" show-back />
|
||||||
|
|
||||||
<view class="filter-bar">
|
<view class="filter-bar">
|
||||||
<input
|
<input
|
||||||
class="search-input"
|
class="search-input"
|
||||||
v-model="searchQuery"
|
v-model="searchQuery"
|
||||||
placeholder="搜索昵称或手机号"
|
placeholder="搜索昵称 / 手机 / OpenID"
|
||||||
placeholder-style="color:#bbb"
|
placeholder-style="color:#bbb"
|
||||||
@confirm="onSearch"
|
@confirm="onSearch"
|
||||||
confirm-type="search"
|
confirm-type="search"
|
||||||
/>
|
/>
|
||||||
|
<view v-if="searchQuery" class="search-clear" @tap="onClear">
|
||||||
|
<text class="search-clear-icon">×</text>
|
||||||
|
</view>
|
||||||
|
<picker
|
||||||
|
class="type-picker"
|
||||||
|
mode="selector"
|
||||||
|
:value="cardTypeIndex"
|
||||||
|
:range="cardTypeOptions"
|
||||||
|
range-key="label"
|
||||||
|
@change="onCardTypeChange"
|
||||||
|
>
|
||||||
|
<view class="type-picker-inner">
|
||||||
|
<text class="type-picker-text">{{ cardTypeOptions[cardTypeIndex].label }}</text>
|
||||||
|
<text class="type-picker-arrow">▾</text>
|
||||||
|
</view>
|
||||||
|
</picker>
|
||||||
<view class="search-btn" @tap="onSearch">
|
<view class="search-btn" @tap="onSearch">
|
||||||
<text class="search-btn-text">搜索</text>
|
<text class="search-btn-text">搜索</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Stats row -->
|
|
||||||
<view class="stats-row">
|
<view class="stats-row">
|
||||||
<view class="stat-item">
|
<view class="stat-item">
|
||||||
<text class="stat-value">{{ total }}</text>
|
<text class="stat-value">{{ total }}</text>
|
||||||
<text class="stat-label">总会员</text>
|
<text class="stat-label">位会员</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Loading skeleton -->
|
|
||||||
<view v-if="loading && !members.length" class="skeleton-list">
|
<view v-if="loading && !members.length" class="skeleton-list">
|
||||||
<view v-for="i in 5" :key="i" class="skeleton-item" />
|
<view v-for="i in 5" :key="i" class="skeleton-item" />
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Empty -->
|
|
||||||
<view v-else-if="!loading && !members.length" class="empty-state">
|
<view v-else-if="!loading && !members.length" class="empty-state">
|
||||||
<text class="empty-icon">👥</text>
|
<view class="empty-icon-wrap">
|
||||||
<text class="empty-text">暂无会员数据</text>
|
<view class="empty-icon-person" />
|
||||||
|
</view>
|
||||||
|
<text class="empty-text">{{ searchQuery ? '未找到匹配的会员' : '暂无会员数据' }}</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Member list -->
|
|
||||||
<view v-else class="member-list">
|
<view v-else class="member-list">
|
||||||
<view
|
<view
|
||||||
v-for="m in members"
|
v-for="m in members"
|
||||||
@@ -49,8 +64,20 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="member-info">
|
<view class="member-info">
|
||||||
|
<view class="member-name-row">
|
||||||
<text class="member-name">{{ m.nickname || '未知用户' }}</text>
|
<text class="member-name">{{ m.nickname || '未知用户' }}</text>
|
||||||
<text class="member-phone">{{ m.phone || '未绑定手机' }}</text>
|
<view
|
||||||
|
v-if="m.activeCard"
|
||||||
|
class="card-chip"
|
||||||
|
:class="'card-chip--' + m.activeCard.type.toLowerCase()"
|
||||||
|
>
|
||||||
|
<text class="card-chip-text">{{ getCardTypeLabel(m.activeCard.type) }}</text>
|
||||||
|
</view>
|
||||||
|
<view v-else class="card-chip card-chip--none">
|
||||||
|
<text class="card-chip-text">无卡</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<text class="member-sub">{{ m.phone || m.openid }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="member-stats">
|
<view class="member-stats">
|
||||||
<text class="member-stat-value">{{ m.totalBookings }}</text>
|
<text class="member-stat-value">{{ m.totalBookings }}</text>
|
||||||
@@ -60,65 +87,56 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Load more -->
|
<view v-if="members.length" class="list-footer">
|
||||||
<view v-if="hasMore" class="load-more" @tap="loadMore">
|
<text class="list-footer-text">
|
||||||
<text class="load-more-text">{{ loading ? '加载中...' : '加载更多' }}</text>
|
{{ loading ? '加载中...' : hasMore ? '上拉加载更多' : '— 已加载全部 —' }}
|
||||||
</view>
|
</text>
|
||||||
|
|
||||||
<!-- Detail modal -->
|
|
||||||
<view v-if="showDetail && detailMember" class="modal-mask" @tap.self="showDetail = false">
|
|
||||||
<view class="modal">
|
|
||||||
<view class="detail-header">
|
|
||||||
<view class="detail-avatar">
|
|
||||||
<image v-if="detailMember.avatarUrl" class="avatar-img" :src="detailMember.avatarUrl" mode="aspectFill" />
|
|
||||||
<view v-else class="avatar-placeholder avatar-placeholder--lg">
|
|
||||||
<text class="avatar-text avatar-text--lg">{{ (detailMember.nickname || '?').slice(0, 1) }}</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
<text class="detail-name">{{ detailMember.nickname || '未知用户' }}</text>
|
|
||||||
<text class="detail-phone">{{ detailMember.phone || '未绑定手机' }}</text>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="detail-stats">
|
|
||||||
<view class="detail-stat">
|
|
||||||
<text class="detail-stat-value">{{ detailMember.totalBookings }}</text>
|
|
||||||
<text class="detail-stat-label">总预约</text>
|
|
||||||
</view>
|
|
||||||
<view class="detail-stat">
|
|
||||||
<text class="detail-stat-value">{{ detailMember.completedBookings }}</text>
|
|
||||||
<text class="detail-stat-label">已完成</text>
|
|
||||||
</view>
|
|
||||||
<view class="detail-stat">
|
|
||||||
<text class="detail-stat-value">{{ detailMember.cancelledBookings }}</text>
|
|
||||||
<text class="detail-stat-label">已取消</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="modal-close" @tap="showDetail = false">
|
|
||||||
<text class="modal-close-text">关闭</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { onReachBottom, onShow } from '@dcloudio/uni-app'
|
||||||
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
|
import { getSystemLayout } from '../../utils/system'
|
||||||
|
import { getCardTypeLabel } from '../../utils/format'
|
||||||
import { useAdminStore } from '../../stores/admin'
|
import { useAdminStore } from '../../stores/admin'
|
||||||
import type { MemberSummary } from '../../stores/admin'
|
import type { MemberSummary } from '../../stores/admin'
|
||||||
|
|
||||||
const adminStore = useAdminStore()
|
const adminStore = useAdminStore()
|
||||||
|
|
||||||
|
const navBarHeight = ref('64px')
|
||||||
const members = ref<MemberSummary[]>([])
|
const members = ref<MemberSummary[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const hasMore = ref(false)
|
const hasMore = ref(false)
|
||||||
const showDetail = ref(false)
|
|
||||||
const detailMember = ref<MemberSummary | null>(null)
|
|
||||||
|
|
||||||
const LIMIT = 20
|
const LIMIT = 20
|
||||||
|
const cardTypeOptions = [
|
||||||
|
{ label: '全部', value: '' },
|
||||||
|
{ label: '体验卡', value: 'TRIAL' },
|
||||||
|
{ label: '次卡', value: 'TIMES' },
|
||||||
|
{ label: '月卡', value: 'DURATION' },
|
||||||
|
{ label: '无卡', value: 'NONE' },
|
||||||
|
]
|
||||||
|
const cardTypeIndex = ref(0)
|
||||||
|
let cardTypeDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
function onCardTypeChange(e: { detail: { value: number } }) {
|
||||||
|
cardTypeIndex.value = Number(e.detail.value)
|
||||||
|
if (cardTypeDebounceTimer) clearTimeout(cardTypeDebounceTimer)
|
||||||
|
cardTypeDebounceTimer = setTimeout(() => {
|
||||||
|
loadMembers(true)
|
||||||
|
cardTypeDebounceTimer = null
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (cardTypeDebounceTimer) clearTimeout(cardTypeDebounceTimer)
|
||||||
|
})
|
||||||
|
|
||||||
async function loadMembers(reset = false) {
|
async function loadMembers(reset = false) {
|
||||||
if (loading.value) return
|
if (loading.value) return
|
||||||
@@ -128,15 +146,18 @@ async function loadMembers(reset = false) {
|
|||||||
}
|
}
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
|
const search = searchQuery.value.trim()
|
||||||
|
const cardType = cardTypeOptions[cardTypeIndex.value].value
|
||||||
const result = await adminStore.fetchMembers({
|
const result = await adminStore.fetchMembers({
|
||||||
page: page.value,
|
page: page.value,
|
||||||
limit: LIMIT,
|
limit: LIMIT,
|
||||||
search: searchQuery.value.trim() || undefined,
|
...(search ? { search } : {}),
|
||||||
|
...(cardType ? { cardType } : {}),
|
||||||
})
|
})
|
||||||
if (reset) {
|
if (reset) {
|
||||||
members.value = [...result.items]
|
members.value = [...result.items]
|
||||||
} else {
|
} else {
|
||||||
members.value.push(...result.items)
|
members.value = [...members.value, ...result.items]
|
||||||
}
|
}
|
||||||
total.value = result.total
|
total.value = result.total
|
||||||
hasMore.value = members.value.length < result.total
|
hasMore.value = members.value.length < result.total
|
||||||
@@ -147,110 +168,226 @@ async function loadMembers(reset = false) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshVisibleMembers() {
|
||||||
|
if (loading.value) return
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const search = searchQuery.value.trim()
|
||||||
|
const cardType = cardTypeOptions[cardTypeIndex.value].value
|
||||||
|
const limit = Math.max(members.value.length, LIMIT)
|
||||||
|
const result = await adminStore.fetchMembers({
|
||||||
|
page: 1,
|
||||||
|
limit,
|
||||||
|
...(search ? { search } : {}),
|
||||||
|
...(cardType ? { cardType } : {}),
|
||||||
|
})
|
||||||
|
members.value = [...result.items]
|
||||||
|
total.value = result.total
|
||||||
|
page.value = Math.max(1, Math.ceil(members.value.length / LIMIT) || 1)
|
||||||
|
hasMore.value = members.value.length < result.total
|
||||||
|
} catch {
|
||||||
|
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function onSearch() {
|
function onSearch() {
|
||||||
loadMembers(true)
|
loadMembers(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadMore() {
|
function onClear() {
|
||||||
|
searchQuery.value = ''
|
||||||
|
loadMembers(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
onReachBottom(() => {
|
||||||
if (!hasMore.value || loading.value) return
|
if (!hasMore.value || loading.value) return
|
||||||
page.value++
|
page.value++
|
||||||
loadMembers(false)
|
loadMembers(false)
|
||||||
}
|
})
|
||||||
|
|
||||||
function openDetail(m: MemberSummary) {
|
function openDetail(m: MemberSummary) {
|
||||||
detailMember.value = m
|
uni.navigateTo({ url: `/pages/admin/member-detail?userId=${m.userId}` })
|
||||||
showDetail.value = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => loadMembers(true))
|
let hasLoaded = false
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
|
||||||
|
loadMembers(true).then(() => {
|
||||||
|
hasLoaded = true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onShow(() => {
|
||||||
|
if (hasLoaded) {
|
||||||
|
refreshVisibleMembers()
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.page {
|
.page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: #f5f3f0;
|
background: $bg-page;
|
||||||
padding-bottom: 40rpx;
|
padding-bottom: 40rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Filter bar ──────────────────────────── */
|
|
||||||
.filter-bar {
|
.filter-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 16rpx;
|
gap: 16rpx;
|
||||||
padding: 24rpx;
|
padding: 24rpx;
|
||||||
background: #ffffff;
|
background: $bg-card;
|
||||||
border-bottom: 1rpx solid #eee;
|
border-bottom: 1rpx solid $border-color;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-input {
|
.search-input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: 72rpx;
|
height: 72rpx;
|
||||||
background: #f5f3f0;
|
background: $bg-page;
|
||||||
border-radius: 36rpx;
|
border-radius: 36rpx;
|
||||||
padding: 0 28rpx;
|
padding: 0 28rpx;
|
||||||
font-size: 26rpx;
|
font-size: 26rpx;
|
||||||
color: #333;
|
color: $text-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-clear {
|
||||||
|
position: absolute;
|
||||||
|
right: 260rpx;
|
||||||
|
width: 44rpx;
|
||||||
|
height: 44rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-clear-icon {
|
||||||
|
font-size: 32rpx;
|
||||||
|
color: $text-hint;
|
||||||
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-btn {
|
.search-btn {
|
||||||
background: #1a1a2e;
|
background: $brand-color;
|
||||||
border-radius: 36rpx;
|
border-radius: 36rpx;
|
||||||
padding: 16rpx 32rpx;
|
padding: 16rpx 32rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-btn-text { font-size: 26rpx; font-weight: 600; color: #c9a87c; }
|
.search-btn-text { font-size: 26rpx; font-weight: 600; color: $accent-color; }
|
||||||
|
|
||||||
|
.type-picker {
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: $bg-page;
|
||||||
|
border-radius: 36rpx;
|
||||||
|
padding: 0 20rpx;
|
||||||
|
height: 72rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-picker-inner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-picker-text {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: $text-secondary;
|
||||||
|
max-width: 80rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-picker-arrow {
|
||||||
|
font-size: 20rpx;
|
||||||
|
color: $text-hint;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Stats row ───────────────────────────── */
|
|
||||||
.stats-row {
|
.stats-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
padding: 24rpx 28rpx 16rpx;
|
padding: 24rpx 28rpx 16rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-item { display: flex; align-items: baseline; gap: 8rpx; }
|
.stat-item { display: flex; align-items: baseline; gap: 8rpx; }
|
||||||
.stat-value { font-size: 36rpx; font-weight: 800; color: #c9a87c; }
|
.stat-value { font-size: 36rpx; font-weight: 800; color: $accent-color; }
|
||||||
.stat-label { font-size: 24rpx; color: #999; }
|
.stat-label { font-size: 24rpx; color: $text-hint; }
|
||||||
|
|
||||||
/* ── Skeleton ────────────────────────────── */
|
|
||||||
.skeleton-list { padding: 0 24rpx; }
|
.skeleton-list { padding: 0 24rpx; }
|
||||||
|
|
||||||
.skeleton-item {
|
.skeleton-item {
|
||||||
height: 100rpx;
|
height: 120rpx;
|
||||||
border-radius: 16rpx;
|
border-radius: $radius-md;
|
||||||
margin-bottom: 16rpx;
|
margin-bottom: 16rpx;
|
||||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
|
||||||
background-size: 400% 100%;
|
background-size: 400% 100%;
|
||||||
animation: shimmer 1.4s infinite;
|
animation: shimmer 1.4s infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes shimmer {
|
|
||||||
0% { background-position: 100% 0; }
|
|
||||||
100% { background-position: -100% 0; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Empty ───────────────────────────────── */
|
|
||||||
.empty-state {
|
.empty-state {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 100rpx 0;
|
padding: 120rpx 0;
|
||||||
gap: 20rpx;
|
gap: 24rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-icon { font-size: 80rpx; }
|
.empty-icon-wrap {
|
||||||
.empty-text { font-size: 28rpx; color: #bbb; }
|
width: 96rpx;
|
||||||
|
height: 96rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba($brand-color, 0.06);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon-person {
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
width: 20rpx;
|
||||||
|
height: 20rpx;
|
||||||
|
border: 3rpx solid $text-hint;
|
||||||
|
border-radius: 50%;
|
||||||
|
position: absolute;
|
||||||
|
top: 22rpx;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
width: 36rpx;
|
||||||
|
height: 16rpx;
|
||||||
|
border: 3rpx solid $text-hint;
|
||||||
|
border-bottom: none;
|
||||||
|
border-radius: 20rpx 20rpx 0 0;
|
||||||
|
position: absolute;
|
||||||
|
bottom: 20rpx;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-text { font-size: 28rpx; color: $text-hint; }
|
||||||
|
|
||||||
/* ── Member list ─────────────────────────── */
|
|
||||||
.member-list { padding: 0 24rpx; padding-top: 8rpx; }
|
.member-list { padding: 0 24rpx; padding-top: 8rpx; }
|
||||||
|
|
||||||
.member-row {
|
.member-row {
|
||||||
background: #ffffff;
|
background: $bg-card;
|
||||||
border-radius: 16rpx;
|
border-radius: $radius-md;
|
||||||
padding: 24rpx;
|
padding: 24rpx;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 20rpx;
|
gap: 20rpx;
|
||||||
margin-bottom: 16rpx;
|
margin-bottom: 16rpx;
|
||||||
box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.05);
|
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-avatar {
|
.member-avatar {
|
||||||
@@ -267,100 +404,101 @@ onMounted(() => loadMembers(true))
|
|||||||
width: 80rpx;
|
width: 80rpx;
|
||||||
height: 80rpx;
|
height: 80rpx;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #1a1a2e;
|
background: $brand-color;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar-placeholder--lg {
|
|
||||||
width: 120rpx;
|
|
||||||
height: 120rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatar-text {
|
.avatar-text {
|
||||||
font-size: 32rpx;
|
font-size: 28rpx;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #c9a87c;
|
color: $primary-dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar-text--lg { font-size: 48rpx; }
|
.member-info {
|
||||||
|
flex: 1;
|
||||||
.member-info { flex: 1; display: flex; flex-direction: column; gap: 8rpx; }
|
min-width: 0;
|
||||||
.member-name { font-size: 28rpx; font-weight: 600; color: #1a1a2e; }
|
|
||||||
.member-phone { font-size: 22rpx; color: #999; }
|
|
||||||
|
|
||||||
.member-stats { display: flex; flex-direction: column; align-items: flex-end; gap: 4rpx; }
|
|
||||||
.member-stat-value { font-size: 32rpx; font-weight: 700; color: #c9a87c; }
|
|
||||||
.member-stat-label { font-size: 20rpx; color: #bbb; }
|
|
||||||
|
|
||||||
.member-arrow { font-size: 36rpx; color: #ccc; }
|
|
||||||
|
|
||||||
/* ── Load more ───────────────────────────── */
|
|
||||||
.load-more {
|
|
||||||
text-align: center;
|
|
||||||
padding: 32rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.load-more-text { font-size: 26rpx; color: #c9a87c; }
|
|
||||||
|
|
||||||
/* ── Detail modal ────────────────────────── */
|
|
||||||
.modal-mask {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(0,0,0,0.5);
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-end;
|
|
||||||
z-index: 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal {
|
|
||||||
width: 100%;
|
|
||||||
background: #ffffff;
|
|
||||||
border-radius: 24rpx 24rpx 0 0;
|
|
||||||
padding: 48rpx 32rpx 60rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-header {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
gap: 8rpx;
|
||||||
gap: 16rpx;
|
|
||||||
margin-bottom: 40rpx;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-avatar {
|
.member-name-row {
|
||||||
width: 120rpx;
|
display: flex;
|
||||||
height: 120rpx;
|
align-items: center;
|
||||||
border-radius: 50%;
|
gap: 12rpx;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-name {
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $text-primary;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 280rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-name { font-size: 32rpx; font-weight: 700; color: #1a1a2e; }
|
.card-chip {
|
||||||
.detail-phone { font-size: 26rpx; color: #888; }
|
flex-shrink: 0;
|
||||||
|
padding: 2rpx 12rpx;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
background: rgba($accent-color, 0.16);
|
||||||
|
|
||||||
.detail-stats {
|
&--trial { background: rgba(#C47A7A, 0.14); }
|
||||||
|
&--times { background: rgba($accent-color, 0.16); }
|
||||||
|
&--duration { background: rgba(#7A9E7E, 0.16); }
|
||||||
|
&--none { background: rgba($text-hint, 0.12); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-chip-text {
|
||||||
|
font-size: 20rpx;
|
||||||
|
color: $text-secondary;
|
||||||
|
letter-spacing: 1rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-sub {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: $text-hint;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-stats {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-around;
|
flex-direction: column;
|
||||||
background: #f5f3f0;
|
align-items: flex-end;
|
||||||
border-radius: 16rpx;
|
flex-shrink: 0;
|
||||||
padding: 28rpx;
|
|
||||||
margin-bottom: 32rpx;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-stat { display: flex; flex-direction: column; align-items: center; gap: 8rpx; }
|
.member-stat-value {
|
||||||
.detail-stat-value { font-size: 40rpx; font-weight: 800; color: #c9a87c; }
|
font-size: 32rpx;
|
||||||
.detail-stat-label { font-size: 22rpx; color: #999; }
|
font-weight: 800;
|
||||||
|
color: $text-primary;
|
||||||
.modal-close {
|
font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif;
|
||||||
width: 100%;
|
|
||||||
height: 88rpx;
|
|
||||||
background: #f0f0f0;
|
|
||||||
border-radius: 44rpx;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-close-text { font-size: 28rpx; color: #555; }
|
.member-stat-label {
|
||||||
|
font-size: 20rpx;
|
||||||
|
color: $text-hint;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-arrow {
|
||||||
|
font-size: 32rpx;
|
||||||
|
color: $text-hint;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-footer {
|
||||||
|
padding: 16rpx 0 40rpx;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-footer-text {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: $text-hint;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,94 +1,162 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="page">
|
<view class="page" :style="{ '--status-bar': statusBarHeight + 'px' }">
|
||||||
|
<CustomNavBar title="订单管理" show-back />
|
||||||
|
|
||||||
|
<!-- Summary stats bar -->
|
||||||
|
<view class="stats-bar">
|
||||||
|
<view class="stat-item">
|
||||||
|
<text class="stat-num">{{ totalCount || '--' }}</text>
|
||||||
|
<text class="stat-label">全部订单</text>
|
||||||
|
</view>
|
||||||
|
<view class="stat-divider" />
|
||||||
|
<view class="stat-item">
|
||||||
|
<text class="stat-num paid">{{ paidCount || '--' }}</text>
|
||||||
|
<text class="stat-label">已支付</text>
|
||||||
|
</view>
|
||||||
|
<view class="stat-divider" />
|
||||||
|
<view class="stat-item">
|
||||||
|
<text class="stat-num pending">{{ pendingCount || '--' }}</text>
|
||||||
|
<text class="stat-label">待支付</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<!-- Status filter tabs -->
|
<!-- Status filter tabs -->
|
||||||
|
<view class="filter-wrap">
|
||||||
<scroll-view scroll-x class="filter-scroll" :show-scrollbar="false">
|
<scroll-view scroll-x class="filter-scroll" :show-scrollbar="false">
|
||||||
<view class="filter-row">
|
<view class="filter-row">
|
||||||
<view
|
<view
|
||||||
v-for="f in filters"
|
v-for="f in filters"
|
||||||
:key="f.value"
|
:key="f.value"
|
||||||
class="filter-chip"
|
class="filter-pill"
|
||||||
:class="{ 'filter-chip--active': activeFilter === f.value }"
|
:class="{ active: activeFilter === f.value }"
|
||||||
@tap="selectFilter(f.value)"
|
@tap="selectFilter(f.value)"
|
||||||
>
|
>
|
||||||
<text class="filter-chip-text">{{ f.label }}</text>
|
<text class="filter-pill-text">{{ f.label }}</text>
|
||||||
|
<view v-if="f.count != null" class="filter-pill-dot" />
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</scroll-view>
|
</scroll-view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<!-- Pull-to-refresh wrapper -->
|
<!-- Pull-to-refresh -->
|
||||||
<scroll-view
|
<scroll-view
|
||||||
scroll-y
|
scroll-y
|
||||||
class="list-scroll"
|
class="list-scroll"
|
||||||
:refresher-enabled="true"
|
:refresher-enabled="true"
|
||||||
:refresher-triggered="refreshing"
|
:refresher-triggered="refreshing"
|
||||||
|
:lower-threshold="120"
|
||||||
@refresherrefresh="onRefresh"
|
@refresherrefresh="onRefresh"
|
||||||
|
@scrolltolower="loadMore"
|
||||||
>
|
>
|
||||||
<!-- Loading skeleton -->
|
<!-- Loading skeleton -->
|
||||||
<view v-if="loading && !orders.length" class="skeleton-list">
|
<view v-if="loading && !orders.length" class="order-list">
|
||||||
<view v-for="i in 5" :key="i" class="skeleton-item" />
|
<view v-for="i in 5" :key="i" class="skeleton-card" />
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Empty -->
|
<!-- Empty -->
|
||||||
<view v-else-if="!loading && !orders.length" class="empty-state">
|
<view v-else-if="!loading && !orders.length" class="empty-state">
|
||||||
<text class="empty-icon">📋</text>
|
<view class="empty-illustration">
|
||||||
<text class="empty-text">暂无订单</text>
|
<text class="empty-icon">📭</text>
|
||||||
|
</view>
|
||||||
|
<text class="empty-title">暂无订单</text>
|
||||||
|
<text class="empty-sub">当前筛选条件下没有找到订单</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Order list -->
|
<!-- Order cards -->
|
||||||
<view v-else class="order-list">
|
<view v-else class="order-list">
|
||||||
<view v-for="order in orders" :key="order.id" class="order-card">
|
<view
|
||||||
<view class="order-header">
|
v-for="(order, idx) in orders"
|
||||||
<text class="order-card-name">{{ order.cardType?.name ?? '-' }}</text>
|
:key="order.id"
|
||||||
<view class="order-status-badge" :class="statusBadgeClass(order.status)">
|
class="order-card"
|
||||||
<text class="order-status-text">{{ statusLabel(order.status) }}</text>
|
:class="{ 'order-card--paid': order.status === OrderStatus.PAID, 'order-card--pending': order.status === OrderStatus.PENDING }"
|
||||||
|
:style="{ animationDelay: `${idx * 40}ms` }"
|
||||||
|
>
|
||||||
|
<!-- Card accent bar -->
|
||||||
|
<view class="card-accent" :class="statusAccentClass(order.status)" />
|
||||||
|
|
||||||
|
<!-- Card header -->
|
||||||
|
<view class="card-header">
|
||||||
|
<view class="card-title-row">
|
||||||
|
<text class="card-plan">{{ order.cardType?.name ?? '未知套餐' }}</text>
|
||||||
|
<view class="badge" :class="statusBadgeClass(order.status)">
|
||||||
|
<text class="badge-text">{{ statusLabel(order.status) }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="order-body">
|
<text class="card-order-no">#{{ order.orderNo }}</text>
|
||||||
<view class="order-row">
|
|
||||||
<text class="order-row-label">用户</text>
|
|
||||||
<text class="order-row-value">{{ order.user?.nickname ?? '-' }}</text>
|
|
||||||
</view>
|
</view>
|
||||||
<view class="order-row">
|
|
||||||
<text class="order-row-label">手机</text>
|
<!-- Card divider -->
|
||||||
<text class="order-row-value">{{ order.user?.phone ?? '未绑定' }}</text>
|
<view class="card-divider" />
|
||||||
|
|
||||||
|
<!-- Card body -->
|
||||||
|
<view class="card-body">
|
||||||
|
<view class="info-row">
|
||||||
|
<view class="info-left">
|
||||||
|
<text class="info-label">用户</text>
|
||||||
|
<text class="info-value">{{ order.user?.nickname ?? '未知用户' }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="order-row">
|
<view class="info-right">
|
||||||
<text class="order-row-label">金额</text>
|
<text class="info-label">手机</text>
|
||||||
<text class="order-row-value order-price">¥{{ formatPrice(order.amount) }}</text>
|
<text class="info-value mono">{{ order.user?.phone ?? '未绑定' }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="order-row">
|
</view>
|
||||||
<text class="order-row-label">时间</text>
|
|
||||||
<text class="order-row-value">{{ formatDate(order.createdAt) }}</text>
|
<view class="info-row">
|
||||||
|
<view class="info-left">
|
||||||
|
<text class="info-label">金额</text>
|
||||||
|
<text class="info-value price">¥{{ formatPrice(order.amount) }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-right">
|
||||||
|
<text class="info-label">下单时间</text>
|
||||||
|
<text class="info-value">{{ formatDateTime(order.createdAt) }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Paid time if available -->
|
||||||
|
<view v-if="order.paidAt && order.status === OrderStatus.PAID" class="info-row">
|
||||||
|
<text class="info-label">支付时间</text>
|
||||||
|
<text class="info-value">{{ formatDateTime(order.paidAt) }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Load more -->
|
<!-- Load more / no more -->
|
||||||
<view v-if="hasMore" class="load-more" @tap="loadMore">
|
<view v-if="hasMore" class="load-more">
|
||||||
<text class="load-more-text">{{ loading ? '加载中...' : '加载更多' }}</text>
|
<text class="load-more-text">{{ loading ? '加载中...' : '加载更多' }}</text>
|
||||||
</view>
|
</view>
|
||||||
|
<view v-else-if="orders.length > 0" class="no-more">
|
||||||
|
<text class="no-more-text">— 已加载全部 {{ orders.length }} 条订单 —</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
<!-- Bottom spacer -->
|
<view style="height: 60rpx" />
|
||||||
<view style="height: 40rpx;" />
|
|
||||||
</scroll-view>
|
</scroll-view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
import { useAdminStore } from '../../stores/admin'
|
import { useAdminStore } from '../../stores/admin'
|
||||||
import { formatPrice, formatDate } from '../../utils/format'
|
import { formatPrice, formatDateTime } from '../../utils/format'
|
||||||
import { OrderStatus } from '@mp-pilates/shared'
|
import { OrderStatus } from '@mp-pilates/shared'
|
||||||
import type { OrderWithDetails } from '@mp-pilates/shared'
|
import type { OrderWithDetails } from '@mp-pilates/shared'
|
||||||
|
|
||||||
const adminStore = useAdminStore()
|
const adminStore = useAdminStore()
|
||||||
|
|
||||||
|
// 动态计算顶部模块高度
|
||||||
|
const statusBarHeight = ref(0)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const windowInfo = uni.getWindowInfo()
|
||||||
|
statusBarHeight.value = windowInfo.statusBarHeight ?? 20
|
||||||
|
})
|
||||||
|
|
||||||
const filters = [
|
const filters = [
|
||||||
{ label: '全部', value: '' },
|
{ label: '全部', value: '', count: null },
|
||||||
{ label: '已支付', value: OrderStatus.PAID },
|
{ label: '已支付', value: OrderStatus.PAID, count: null },
|
||||||
{ label: '待支付', value: OrderStatus.PENDING },
|
{ label: '待支付', value: OrderStatus.PENDING, count: null },
|
||||||
{ label: '已退款', value: OrderStatus.REFUNDED },
|
{ label: '已退款', value: OrderStatus.REFUNDED, count: null },
|
||||||
]
|
]
|
||||||
|
|
||||||
const activeFilter = ref('')
|
const activeFilter = ref('')
|
||||||
@@ -97,6 +165,24 @@ const loading = ref(false)
|
|||||||
const refreshing = ref(false)
|
const refreshing = ref(false)
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const hasMore = ref(false)
|
const hasMore = ref(false)
|
||||||
|
const totalCount = ref<number | null>(null)
|
||||||
|
const paidCount = ref<number | null>(null)
|
||||||
|
const pendingCount = ref<number | null>(null)
|
||||||
|
|
||||||
|
// 每个 tab 单独缓存数据
|
||||||
|
const orderCache: Record<string, { items: OrderWithDetails[]; total: number; page: number; hasMore: boolean }> = {}
|
||||||
|
|
||||||
|
function getCacheKey(filter: string): string {
|
||||||
|
return filter || 'all'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCachedData(filter: string) {
|
||||||
|
return orderCache[getCacheKey(filter)]
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCachedData(filter: string, data: { items: OrderWithDetails[]; total: number; page: number; hasMore: boolean }) {
|
||||||
|
orderCache[getCacheKey(filter)] = data
|
||||||
|
}
|
||||||
|
|
||||||
const LIMIT = 20
|
const LIMIT = 20
|
||||||
|
|
||||||
@@ -116,25 +202,111 @@ function statusBadgeClass(s: string) {
|
|||||||
return 'badge--default'
|
return 'badge--default'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function statusAccentClass(s: string) {
|
||||||
|
if (s === OrderStatus.PAID) return 'accent--paid'
|
||||||
|
if (s === OrderStatus.PENDING) return 'accent--pending'
|
||||||
|
if (s === OrderStatus.REFUNDED) return 'accent--refunded'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
async function loadOrders(reset = false) {
|
async function loadOrders(reset = false) {
|
||||||
if (loading.value) return
|
const filter = activeFilter.value
|
||||||
|
|
||||||
|
// 如果有缓存且是重置(切换tab),直接用缓存数据
|
||||||
if (reset) {
|
if (reset) {
|
||||||
page.value = 1
|
const cached = getCachedData(filter)
|
||||||
orders.value = []
|
if (cached) {
|
||||||
|
orders.value = [...cached.items]
|
||||||
|
hasMore.value = cached.hasMore
|
||||||
|
page.value = cached.page
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始加载或下拉刷新,需要请求接口
|
||||||
|
if (loading.value) return
|
||||||
|
if (reset) page.value = 1
|
||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const result = await adminStore.fetchAdminOrders({
|
const params: { page: number; limit: number; status?: string } = {
|
||||||
page: page.value,
|
page: page.value,
|
||||||
limit: LIMIT,
|
limit: LIMIT,
|
||||||
status: activeFilter.value || undefined,
|
|
||||||
})
|
|
||||||
if (reset) {
|
|
||||||
orders.value = [...result.items]
|
|
||||||
} else {
|
|
||||||
orders.value.push(...result.items)
|
|
||||||
}
|
}
|
||||||
hasMore.value = orders.value.length < result.total
|
if (filter) params.status = filter
|
||||||
|
const result = await adminStore.fetchAdminOrders(params)
|
||||||
|
|
||||||
|
const newItems = reset ? [...result.items] : [...orders.value, ...result.items]
|
||||||
|
const newHasMore = newItems.length < result.total
|
||||||
|
|
||||||
|
// 缓存数据
|
||||||
|
setCachedData(filter, {
|
||||||
|
items: newItems,
|
||||||
|
total: result.total,
|
||||||
|
page: page.value,
|
||||||
|
hasMore: newHasMore,
|
||||||
|
})
|
||||||
|
|
||||||
|
orders.value = newItems
|
||||||
|
hasMore.value = newHasMore
|
||||||
|
} catch {
|
||||||
|
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
refreshing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始加载所有分类的数据
|
||||||
|
async function loadAllFiltersData() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
// 并行请求所有分类(第一页数据)
|
||||||
|
const [allResult, paidResult, pendingResult, refundedResult] = await Promise.all([
|
||||||
|
adminStore.fetchAdminOrders({ page: 1, limit: LIMIT }),
|
||||||
|
adminStore.fetchAdminOrders({ page: 1, limit: LIMIT, status: OrderStatus.PAID }),
|
||||||
|
adminStore.fetchAdminOrders({ page: 1, limit: LIMIT, status: OrderStatus.PENDING }),
|
||||||
|
adminStore.fetchAdminOrders({ page: 1, limit: LIMIT, status: OrderStatus.REFUNDED }),
|
||||||
|
])
|
||||||
|
|
||||||
|
// 缓存全部
|
||||||
|
setCachedData('', {
|
||||||
|
items: [...allResult.items],
|
||||||
|
total: allResult.total,
|
||||||
|
page: 1,
|
||||||
|
hasMore: allResult.items.length < allResult.total,
|
||||||
|
})
|
||||||
|
totalCount.value = allResult.total
|
||||||
|
|
||||||
|
// 缓存已支付
|
||||||
|
setCachedData(OrderStatus.PAID, {
|
||||||
|
items: [...paidResult.items],
|
||||||
|
total: paidResult.total,
|
||||||
|
page: 1,
|
||||||
|
hasMore: paidResult.items.length < paidResult.total,
|
||||||
|
})
|
||||||
|
paidCount.value = paidResult.total
|
||||||
|
|
||||||
|
// 缓存待支付
|
||||||
|
setCachedData(OrderStatus.PENDING, {
|
||||||
|
items: [...pendingResult.items],
|
||||||
|
total: pendingResult.total,
|
||||||
|
page: 1,
|
||||||
|
hasMore: pendingResult.items.length < pendingResult.total,
|
||||||
|
})
|
||||||
|
pendingCount.value = pendingResult.total
|
||||||
|
|
||||||
|
// 缓存已退款
|
||||||
|
setCachedData(OrderStatus.REFUNDED, {
|
||||||
|
items: [...refundedResult.items],
|
||||||
|
total: refundedResult.total,
|
||||||
|
page: 1,
|
||||||
|
hasMore: refundedResult.items.length < refundedResult.total,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 设置当前 tab 的数据
|
||||||
|
orders.value = [...allResult.items]
|
||||||
|
hasMore.value = allResult.items.length < allResult.total
|
||||||
} catch {
|
} catch {
|
||||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||||
} finally {
|
} finally {
|
||||||
@@ -145,12 +317,19 @@ async function loadOrders(reset = false) {
|
|||||||
|
|
||||||
function selectFilter(value: string) {
|
function selectFilter(value: string) {
|
||||||
activeFilter.value = value
|
activeFilter.value = value
|
||||||
loadOrders(true)
|
// 切换 tab 直接从缓存读取
|
||||||
|
const cached = getCachedData(value)
|
||||||
|
if (cached) {
|
||||||
|
orders.value = [...cached.items]
|
||||||
|
hasMore.value = cached.hasMore
|
||||||
|
page.value = cached.page
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onRefresh() {
|
async function onRefresh() {
|
||||||
refreshing.value = true
|
refreshing.value = true
|
||||||
await loadOrders(true)
|
// 下拉刷新重新请求所有分类的数据
|
||||||
|
await loadAllFiltersData()
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadMore() {
|
function loadMore() {
|
||||||
@@ -159,136 +338,333 @@ function loadMore() {
|
|||||||
loadOrders(false)
|
loadOrders(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => loadOrders(true))
|
onMounted(() => {
|
||||||
|
loadAllFiltersData()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
/* ── Page shell ──────────────────────────────── */
|
||||||
.page {
|
.page {
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: #f5f3f0;
|
background: #FAF8F5;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Filter scroll ───────────────────────── */
|
/* ── Stats bar ──────────────────────────────── */
|
||||||
.filter-scroll {
|
.stats-bar {
|
||||||
flex-shrink: 0;
|
position: fixed;
|
||||||
background: #ffffff;
|
top: calc(var(--status-bar) + 44px);
|
||||||
border-bottom: 1rpx solid #eee;
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 96rpx;
|
||||||
|
background: #FFFFFF;
|
||||||
|
padding: 0;
|
||||||
|
border-bottom: 1rpx solid rgba(180, 160, 130, 0.2);
|
||||||
|
box-shadow: 0 2rpx 12rpx rgba(180, 160, 130, 0.08);
|
||||||
|
z-index: 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-num {
|
||||||
|
font-size: 42rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #4A4035;
|
||||||
|
letter-spacing: -1rpx;
|
||||||
|
line-height: 1;
|
||||||
|
font-family: 'DIN Alternate', 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-num.paid { color: #7A9E7E; }
|
||||||
|
.stat-num.pending { color: $warning-color; }
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #A09080;
|
||||||
|
letter-spacing: 1rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-divider {
|
||||||
|
width: 1rpx;
|
||||||
|
height: 48rpx;
|
||||||
|
background: rgba(180, 160, 130, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Filter pills ───────────────────────────── */
|
||||||
|
.filter-wrap {
|
||||||
|
position: fixed;
|
||||||
|
top: calc(var(--status-bar) + 92px);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: #FAF8F5;
|
||||||
|
border-bottom: 1rpx solid rgba(180, 160, 130, 0.15);
|
||||||
|
z-index: 99;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-scroll { overflow: hidden; }
|
||||||
|
|
||||||
.filter-row {
|
.filter-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 16rpx 24rpx;
|
padding: 20rpx 28rpx;
|
||||||
gap: 16rpx;
|
gap: 16rpx;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-chip {
|
.filter-pill {
|
||||||
|
position: relative;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
height: 60rpx;
|
gap: 8rpx;
|
||||||
padding: 0 28rpx;
|
height: 64rpx;
|
||||||
border-radius: 30rpx;
|
padding: 0 32rpx;
|
||||||
background: #f0f0f0;
|
border-radius: 32rpx;
|
||||||
|
background: rgba(180, 160, 130, 0.1);
|
||||||
|
border: 1.5rpx solid rgba(180, 160, 130, 0.2);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
transition: all 0.22s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-chip--active {
|
.filter-pill.active {
|
||||||
background: #1a1a2e;
|
background: #4A4035;
|
||||||
|
border-color: #4A4035;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-chip-text { font-size: 26rpx; color: #888; }
|
.filter-pill-text {
|
||||||
.filter-chip--active .filter-chip-text { color: #c9a87c; font-weight: 600; }
|
font-size: 26rpx;
|
||||||
|
color: #7A6A5A;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: color 0.22s ease;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── List scroll ─────────────────────────── */
|
.filter-pill.active .filter-pill-text {
|
||||||
|
color: #E8D8C0;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-pill-dot {
|
||||||
|
width: 6rpx;
|
||||||
|
height: 6rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: $accent-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── List ───────────────────────────────────── */
|
||||||
.list-scroll {
|
.list-scroll {
|
||||||
flex: 1;
|
position: fixed;
|
||||||
|
top: calc(var(--status-bar) + 144px);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Skeleton ────────────────────────────── */
|
.order-list {
|
||||||
.skeleton-list { padding: 24rpx; }
|
padding: 20rpx 24rpx 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
.skeleton-item {
|
/* ── Skeleton ───────────────────────────────── */
|
||||||
height: 180rpx;
|
.skeleton-card {
|
||||||
border-radius: 16rpx;
|
height: 220rpx;
|
||||||
margin-bottom: 16rpx;
|
border-radius: 20rpx;
|
||||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
background: linear-gradient(90deg, #F0EBE3 25%, #E8E0D5 50%, #F0EBE3 75%);
|
||||||
background-size: 400% 100%;
|
background-size: 400% 100%;
|
||||||
animation: shimmer 1.4s infinite;
|
animation: shimmer 1.6s ease infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes shimmer {
|
/* ── Empty ──────────────────────────────────── */
|
||||||
0% { background-position: 100% 0; }
|
|
||||||
100% { background-position: -100% 0; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Empty ───────────────────────────────── */
|
|
||||||
.empty-state {
|
.empty-state {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 120rpx 0;
|
padding: 120rpx 48rpx;
|
||||||
gap: 20rpx;
|
gap: 12rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-icon { font-size: 80rpx; }
|
.empty-illustration {
|
||||||
.empty-text { font-size: 28rpx; color: #bbb; }
|
width: 120rpx;
|
||||||
|
height: 120rpx;
|
||||||
|
border-radius: 60rpx;
|
||||||
|
background: rgba(180, 160, 130, 0.15);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Order list ──────────────────────────── */
|
.empty-icon { font-size: 56rpx; }
|
||||||
.order-list { padding: 16rpx 24rpx 0; }
|
|
||||||
|
|
||||||
|
.empty-title {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #4A4035;
|
||||||
|
letter-spacing: 0.5rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-sub {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: rgba(74, 64, 53, 0.4);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Order card ─────────────────────────────── */
|
||||||
.order-card {
|
.order-card {
|
||||||
background: #ffffff;
|
position: relative;
|
||||||
border-radius: 16rpx;
|
background: #FFFFFF;
|
||||||
overflow: hidden;
|
|
||||||
margin-bottom: 20rpx;
|
|
||||||
box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
.order-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 20rpx 24rpx;
|
|
||||||
border-bottom: 1rpx solid #f5f5f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.order-card-name { font-size: 28rpx; font-weight: 600; color: #1a1a2e; }
|
|
||||||
|
|
||||||
.order-status-badge {
|
|
||||||
border-radius: 20rpx;
|
border-radius: 20rpx;
|
||||||
padding: 6rpx 20rpx;
|
overflow: hidden;
|
||||||
|
box-shadow: 0 4rpx 20rpx rgba(180, 160, 130, 0.12);
|
||||||
|
animation: cardIn 0.4s ease both;
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge--paid { background: rgba(39,174,96,0.1); }
|
@keyframes cardIn {
|
||||||
.badge--paid .order-status-text { font-size: 22rpx; color: #27ae60; }
|
from { opacity: 0; transform: translateY(12rpx); }
|
||||||
.badge--pending { background: rgba(230,126,34,0.1); }
|
to { opacity: 1; transform: translateY(0); }
|
||||||
.badge--pending .order-status-text { font-size: 22rpx; color: #e67e22; }
|
}
|
||||||
.badge--refunded { background: rgba(0,0,0,0.06); }
|
|
||||||
.badge--refunded .order-status-text { font-size: 22rpx; color: #999; }
|
|
||||||
.badge--default .order-status-text { font-size: 22rpx; color: #888; }
|
|
||||||
|
|
||||||
.order-body { padding: 16rpx 24rpx; }
|
.card-accent {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 6rpx;
|
||||||
|
}
|
||||||
|
|
||||||
.order-row {
|
.accent--paid { background: #8FCB9B; }
|
||||||
|
.accent--pending { background: #F2C94C; }
|
||||||
|
.accent--refunded { background: rgba(43, 43, 43, 0.2); }
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
padding: 24rpx 24rpx 20rpx 30rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 10rpx 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.order-row-label { font-size: 24rpx; color: #999; }
|
.card-plan {
|
||||||
.order-row-value { font-size: 26rpx; color: #333; }
|
font-size: 30rpx;
|
||||||
.order-price { font-size: 28rpx; font-weight: 700; color: #c9a87c; }
|
font-weight: 700;
|
||||||
|
color: #4A4035;
|
||||||
|
letter-spacing: 0.5rpx;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Load more ───────────────────────────── */
|
.card-order-no {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: rgba(74, 64, 53, 0.35);
|
||||||
|
margin-top: 6rpx;
|
||||||
|
display: block;
|
||||||
|
font-family: 'SF Mono', 'Menlo', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-divider {
|
||||||
|
height: 1rpx;
|
||||||
|
background: rgba(180, 160, 130, 0.15);
|
||||||
|
margin: 0 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
padding: 20rpx 24rpx 20rpx 30rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-left,
|
||||||
|
.info-right {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-right {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: rgba(74, 64, 53, 0.4);
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 80rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #4A4035;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value.mono {
|
||||||
|
font-family: 'SF Mono', 'Menlo', monospace;
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value.price {
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $accent-color;
|
||||||
|
font-family: 'DIN Alternate', 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Status badges ─────────────────────────── */
|
||||||
|
.badge {
|
||||||
|
border-radius: 8rpx;
|
||||||
|
padding: 4rpx 14rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge--paid { background: rgba(122, 158, 126, 0.15); }
|
||||||
|
.badge--paid .badge-text { font-size: 22rpx; color: #5A7E5E; font-weight: 600; }
|
||||||
|
|
||||||
|
.badge--pending { background: rgba(196, 149, 106, 0.2); }
|
||||||
|
.badge--pending .badge-text { font-size: 22rpx; color: #A07540; font-weight: 600; }
|
||||||
|
|
||||||
|
.badge--refunded { background: rgba(180, 160, 130, 0.15); }
|
||||||
|
.badge--refunded .badge-text { font-size: 22rpx; color: #8A7A6A; }
|
||||||
|
|
||||||
|
.badge--default .badge-text { font-size: 22rpx; color: #888; }
|
||||||
|
|
||||||
|
/* ── Load more ─────────────────────────────── */
|
||||||
.load-more {
|
.load-more {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 32rpx;
|
padding: 40rpx 0 20rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.load-more-text { font-size: 26rpx; color: #c9a87c; }
|
.load-more-text {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: $accent-color;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-more {
|
||||||
|
text-align: center;
|
||||||
|
padding: 32rpx 0 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-more-text {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: rgba(74, 64, 53, 0.3);
|
||||||
|
letter-spacing: 0.5rpx;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
763
packages/app/src/pages/admin/schedule.vue
Normal file
763
packages/app/src/pages/admin/schedule.vue
Normal file
@@ -0,0 +1,763 @@
|
|||||||
|
<template>
|
||||||
|
<view class="page" :style="{ paddingTop: navBarHeight }">
|
||||||
|
<CustomNavBar title="排课管理" show-back />
|
||||||
|
<!-- Date selector -->
|
||||||
|
<view class="sticky-header">
|
||||||
|
<DateSelector v-model="selectedDate" @select="onDateSelect" />
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Loading skeleton -->
|
||||||
|
<view v-if="loading" class="skeleton-list">
|
||||||
|
<view v-for="i in 4" :key="i" class="skeleton-item" />
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Empty state -->
|
||||||
|
<view v-else-if="editableSlots.length === 0" class="empty-state">
|
||||||
|
<text class="empty-icon">📭</text>
|
||||||
|
<text class="empty-text">当日暂无排课</text>
|
||||||
|
<text class="empty-sub">当日暂无默认时段,请点击下方按钮手动添加</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Slot list -->
|
||||||
|
<view v-else class="slot-list">
|
||||||
|
<view
|
||||||
|
v-for="slot in visibleSlots"
|
||||||
|
:key="slot.key"
|
||||||
|
class="slot-card"
|
||||||
|
:class="slotCardClass(slot)"
|
||||||
|
>
|
||||||
|
<!-- Status badge -->
|
||||||
|
<view class="slot-header">
|
||||||
|
<view class="slot-badge" :class="slotBadgeClass(slot)">
|
||||||
|
<text class="slot-badge-text">{{ slotBadgeText(slot) }}</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="slot.bookedCount > 0" class="booked-info">
|
||||||
|
<text class="booked-text">{{ slot.bookedCount }} 人已预约</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Time display / edit -->
|
||||||
|
<view class="slot-body">
|
||||||
|
<view class="time-section">
|
||||||
|
<picker
|
||||||
|
mode="multiSelector"
|
||||||
|
:range="timePickerRange"
|
||||||
|
:value="timeToPickerIndex(slot.startTime)"
|
||||||
|
@change="(e: any) => updateSlotTime(slot, 'startTime', pickerIndexToTime(e.detail.value))"
|
||||||
|
>
|
||||||
|
<view class="time-display">
|
||||||
|
<text class="time-text">{{ slot.startTime }}</text>
|
||||||
|
</view>
|
||||||
|
</picker>
|
||||||
|
<text class="time-separator">–</text>
|
||||||
|
<picker
|
||||||
|
mode="multiSelector"
|
||||||
|
:range="timePickerRange"
|
||||||
|
:value="timeToPickerIndex(slot.endTime)"
|
||||||
|
@change="(e: any) => updateSlotTime(slot, 'endTime', pickerIndexToTime(e.detail.value))"
|
||||||
|
>
|
||||||
|
<view class="time-display">
|
||||||
|
<text class="time-text">{{ slot.endTime }}</text>
|
||||||
|
</view>
|
||||||
|
</picker>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="capacity-section">
|
||||||
|
<text class="capacity-label">容量</text>
|
||||||
|
<view class="capacity-control">
|
||||||
|
<view class="capacity-btn" @tap="adjustCapacity(slot, -1)">
|
||||||
|
<text class="capacity-btn-text">−</text>
|
||||||
|
</view>
|
||||||
|
<text class="capacity-value">{{ slot.capacity }}</text>
|
||||||
|
<view class="capacity-btn" @tap="adjustCapacity(slot, 1)">
|
||||||
|
<text class="capacity-btn-text">+</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="delete-section">
|
||||||
|
<view
|
||||||
|
class="delete-btn"
|
||||||
|
:class="{ 'delete-btn--warn': slot.bookedCount > 0 }"
|
||||||
|
@tap="removeSlot(slot)"
|
||||||
|
>
|
||||||
|
<text class="delete-btn-text">✕</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Add slot button -->
|
||||||
|
<view class="add-wrap" @tap="openAddModal">
|
||||||
|
<text class="add-text">+ 添加时段</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Bottom action bar -->
|
||||||
|
<view class="action-bar">
|
||||||
|
<view
|
||||||
|
class="publish-btn"
|
||||||
|
:class="{ 'publish-btn--loading': publishing }"
|
||||||
|
@tap="handlePublish"
|
||||||
|
>
|
||||||
|
<text class="publish-btn-text">
|
||||||
|
{{ publishing ? '发布中...' : (hasPublished ? '更新当日排课' : '发布当日排课') }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Add slot modal -->
|
||||||
|
<view v-if="showAddModal" class="modal-mask" @tap="onMaskTap">
|
||||||
|
<view class="modal" @tap.stop>
|
||||||
|
<text class="modal-title">添加时段</text>
|
||||||
|
|
||||||
|
<view class="modal-field">
|
||||||
|
<text class="modal-label">开始时间</text>
|
||||||
|
<picker
|
||||||
|
mode="multiSelector"
|
||||||
|
:range="timePickerRange"
|
||||||
|
:value="timeToPickerIndex(addForm.startTime)"
|
||||||
|
@change="onAddStartTimeChange"
|
||||||
|
>
|
||||||
|
<view class="picker-display">
|
||||||
|
<text class="picker-text">{{ addForm.startTime }}</text>
|
||||||
|
<text class="picker-arrow">›</text>
|
||||||
|
</view>
|
||||||
|
</picker>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="modal-field">
|
||||||
|
<text class="modal-label">结束时间</text>
|
||||||
|
<view class="picker-display picker-display--disabled">
|
||||||
|
<text class="picker-text picker-text--muted">{{ addForm.endTime }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="modal-field modal-field--last">
|
||||||
|
<text class="modal-label">容量</text>
|
||||||
|
<input
|
||||||
|
class="modal-input"
|
||||||
|
type="number"
|
||||||
|
v-model="addForm.capacityStr"
|
||||||
|
placeholder="如:1"
|
||||||
|
placeholder-style="color:#bbb"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="modal-actions">
|
||||||
|
<view class="modal-cancel" @tap="closeAddModal">
|
||||||
|
<text class="modal-cancel-text">取消</text>
|
||||||
|
</view>
|
||||||
|
<view class="modal-confirm" @tap="submitAdd">
|
||||||
|
<text class="modal-confirm-text">确认</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import type { ScheduleSlotPreview } from '@mp-pilates/shared'
|
||||||
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
|
import { getSystemLayout } from '../../utils/system'
|
||||||
|
import { useAdminStore } from '../../stores/admin'
|
||||||
|
import { formatDate } from '../../utils/format'
|
||||||
|
import DateSelector from '../../components/DateSelector.vue'
|
||||||
|
import {
|
||||||
|
SCHEDULE_TIME_PICKER_RANGE,
|
||||||
|
timeToPickerIndex,
|
||||||
|
pickerIndexToTime,
|
||||||
|
addOneHourCapped,
|
||||||
|
} from '../../utils/schedule-time'
|
||||||
|
|
||||||
|
const timePickerRange = SCHEDULE_TIME_PICKER_RANGE
|
||||||
|
|
||||||
|
interface EditableSlot {
|
||||||
|
readonly key: string
|
||||||
|
existingSlotId: string | null
|
||||||
|
startTime: string
|
||||||
|
endTime: string
|
||||||
|
capacity: number
|
||||||
|
bookedCount: number
|
||||||
|
isPublished: boolean
|
||||||
|
isNew: boolean
|
||||||
|
isRemoved: boolean
|
||||||
|
templateId: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminStore = useAdminStore()
|
||||||
|
const navBarHeight = ref('64px')
|
||||||
|
const selectedDate = ref(formatDate(new Date()))
|
||||||
|
const loading = ref(false)
|
||||||
|
const publishing = ref(false)
|
||||||
|
const showAddModal = ref(false)
|
||||||
|
|
||||||
|
const editableSlots = ref<EditableSlot[]>([])
|
||||||
|
|
||||||
|
const addForm = ref({
|
||||||
|
startTime: '09:30',
|
||||||
|
endTime: '10:30',
|
||||||
|
capacityStr: '1',
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Computed ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
const visibleSlots = computed(() =>
|
||||||
|
editableSlots.value.filter((s) => !s.isRemoved),
|
||||||
|
)
|
||||||
|
|
||||||
|
const hasPublished = computed(() =>
|
||||||
|
editableSlots.value.some((s) => s.isPublished),
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Data loading ──────────────────────────────────────────
|
||||||
|
|
||||||
|
function mapPreviewToEditable(previews: readonly ScheduleSlotPreview[]): EditableSlot[] {
|
||||||
|
return previews.map((p) => ({
|
||||||
|
key: p.id ?? `tpl-${p.templateId}-${p.startTime}`,
|
||||||
|
existingSlotId: p.id,
|
||||||
|
startTime: p.startTime,
|
||||||
|
endTime: p.endTime,
|
||||||
|
capacity: p.capacity,
|
||||||
|
bookedCount: p.bookedCount,
|
||||||
|
isPublished: p.isPublished,
|
||||||
|
isNew: false,
|
||||||
|
isRemoved: false,
|
||||||
|
templateId: p.templateId,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPreview(date: string) {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const previews = await adminStore.fetchSchedulePreview(date)
|
||||||
|
editableSlots.value = mapPreviewToEditable(previews)
|
||||||
|
} catch {
|
||||||
|
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||||
|
editableSlots.value = []
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDateSelect(date: string) {
|
||||||
|
selectedDate.value = date
|
||||||
|
loadPreview(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Slot editing ──────────────────────────────────────────
|
||||||
|
|
||||||
|
function updateSlotTime(slot: EditableSlot, field: 'startTime' | 'endTime', value: string) {
|
||||||
|
slot[field] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
function adjustCapacity(slot: EditableSlot, delta: number) {
|
||||||
|
const minCapacity = Math.max(1, slot.bookedCount)
|
||||||
|
const newVal = slot.capacity + delta
|
||||||
|
if (newVal >= minCapacity) {
|
||||||
|
slot.capacity = newVal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeSlot(slot: EditableSlot) {
|
||||||
|
if (slot.bookedCount > 0) {
|
||||||
|
uni.showModal({
|
||||||
|
title: '该时段有预约',
|
||||||
|
content: `已有 ${slot.bookedCount} 人预约此时段,移除后该时段将被关闭(已有预约保留)。确认移除?`,
|
||||||
|
confirmText: '确认移除',
|
||||||
|
confirmColor: '#c0392b',
|
||||||
|
success: (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
slot.isRemoved = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
slot.isRemoved = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Add slot ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
function onAddStartTimeChange(e: any) {
|
||||||
|
const start = pickerIndexToTime(e.detail.value as number[])
|
||||||
|
addForm.value.startTime = start
|
||||||
|
addForm.value.endTime = addOneHourCapped(start)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAddModal() {
|
||||||
|
addForm.value = { startTime: '09:30', endTime: '10:30', capacityStr: '1' }
|
||||||
|
showAddModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAddModal() {
|
||||||
|
showAddModal.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 点击遮罩关闭弹窗 — tap.stop 在 modal 上阻止了内部点击冒泡到此 */
|
||||||
|
function onMaskTap() {
|
||||||
|
closeAddModal()
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitAdd() {
|
||||||
|
const capacity = parseInt(addForm.value.capacityStr, 10)
|
||||||
|
if (!addForm.value.startTime || !addForm.value.endTime) {
|
||||||
|
uni.showToast({ title: '请选择时间', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (addForm.value.startTime >= addForm.value.endTime) {
|
||||||
|
uni.showToast({ title: '结束时间须晚于开始时间', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isNaN(capacity) || capacity < 1) {
|
||||||
|
uni.showToast({ title: '请填写有效容量', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
editableSlots.value.push({
|
||||||
|
key: `new-${Date.now()}`,
|
||||||
|
existingSlotId: null,
|
||||||
|
startTime: addForm.value.startTime,
|
||||||
|
endTime: addForm.value.endTime,
|
||||||
|
capacity,
|
||||||
|
bookedCount: 0,
|
||||||
|
isPublished: false,
|
||||||
|
isNew: true,
|
||||||
|
isRemoved: false,
|
||||||
|
templateId: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
closeAddModal()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Publish ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function handlePublish() {
|
||||||
|
if (publishing.value) return
|
||||||
|
|
||||||
|
const slotsToPublish = visibleSlots.value
|
||||||
|
if (slotsToPublish.length === 0) {
|
||||||
|
uni.showModal({
|
||||||
|
title: '提示',
|
||||||
|
content: '当前没有时段,确认清空当日排课?',
|
||||||
|
success: async (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
await doPublish([])
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate times
|
||||||
|
for (const slot of slotsToPublish) {
|
||||||
|
if (slot.startTime >= slot.endTime) {
|
||||||
|
uni.showToast({ title: `时段 ${slot.startTime}-${slot.endTime} 时间无效`, icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
uni.showModal({
|
||||||
|
title: '确认发布',
|
||||||
|
content: `确认${hasPublished.value ? '更新' : '发布'} ${selectedDate.value} 的排课?共 ${slotsToPublish.length} 个时段`,
|
||||||
|
success: async (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
await doPublish(slotsToPublish)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doPublish(slots: readonly EditableSlot[]) {
|
||||||
|
publishing.value = true
|
||||||
|
try {
|
||||||
|
await adminStore.publishDaySlots({
|
||||||
|
date: selectedDate.value,
|
||||||
|
slots: slots.map((s) => ({
|
||||||
|
existingSlotId: s.existingSlotId ?? undefined,
|
||||||
|
startTime: s.startTime,
|
||||||
|
endTime: s.endTime,
|
||||||
|
capacity: s.capacity,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
uni.showToast({ title: '发布成功', icon: 'success' })
|
||||||
|
// Reload to show fresh state
|
||||||
|
editableSlots.value = mapPreviewToEditable(adminStore.schedulePreview)
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const message = e instanceof Error ? e.message : '发布失败'
|
||||||
|
uni.showToast({ title: message, icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
publishing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Style helpers ─────────────────────────────────────────
|
||||||
|
|
||||||
|
function slotCardClass(slot: EditableSlot): string {
|
||||||
|
if (slot.isNew) return 'slot-card--new'
|
||||||
|
if (slot.isPublished) return 'slot-card--published'
|
||||||
|
return 'slot-card--template'
|
||||||
|
}
|
||||||
|
|
||||||
|
function slotBadgeClass(slot: EditableSlot): string {
|
||||||
|
if (slot.isNew) return 'badge--new'
|
||||||
|
if (slot.isPublished) return 'badge--published'
|
||||||
|
return 'badge--template'
|
||||||
|
}
|
||||||
|
|
||||||
|
function slotBadgeText(slot: EditableSlot): string {
|
||||||
|
if (slot.isNew) return '新增'
|
||||||
|
if (slot.isPublished) return '已发布'
|
||||||
|
return '默认时段'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Lifecycle ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
|
||||||
|
loadPreview(selectedDate.value)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f5f3f0;
|
||||||
|
padding-bottom: 180rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Sticky header ───────────────────────── */
|
||||||
|
.sticky-header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Loading skeleton ────────────────────── */
|
||||||
|
.skeleton-list {
|
||||||
|
padding: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-item {
|
||||||
|
height: 160rpx;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||||||
|
background-size: 400% 100%;
|
||||||
|
animation: shimmer 1.4s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Empty state ─────────────────────────── */
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 100rpx 40rpx;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon { font-size: 80rpx; }
|
||||||
|
.empty-text { font-size: 30rpx; color: #666; font-weight: 600; }
|
||||||
|
.empty-sub { font-size: 24rpx; color: #bbb; text-align: center; }
|
||||||
|
|
||||||
|
/* ── Slot list ───────────────────────────── */
|
||||||
|
.slot-list {
|
||||||
|
padding: 24rpx 24rpx 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Slot card ───────────────────────────── */
|
||||||
|
.slot-card {
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
padding: 24rpx;
|
||||||
|
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
|
||||||
|
border: 2rpx solid transparent;
|
||||||
|
|
||||||
|
&--published {
|
||||||
|
border-color: rgba(39, 174, 96, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--template {
|
||||||
|
border-style: dashed;
|
||||||
|
border-color: $primary-dark;
|
||||||
|
background: rgba(201, 168, 124, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--new {
|
||||||
|
border-style: dashed;
|
||||||
|
border-color: #3498db;
|
||||||
|
background: rgba(52, 152, 219, 0.04);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Slot header ─────────────────────────── */
|
||||||
|
.slot-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12rpx;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-badge {
|
||||||
|
border-radius: 16rpx;
|
||||||
|
padding: 4rpx 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge--published { background: rgba(39, 174, 96, 0.1); }
|
||||||
|
.badge--published .slot-badge-text { font-size: 22rpx; color: #27ae60; font-weight: 600; }
|
||||||
|
.badge--template { background: rgba(201, 168, 124, 0.15); }
|
||||||
|
.badge--template .slot-badge-text { font-size: 22rpx; color: #b8860b; font-weight: 600; }
|
||||||
|
.badge--new { background: rgba(52, 152, 219, 0.1); }
|
||||||
|
.badge--new .slot-badge-text { font-size: 22rpx; color: #3498db; font-weight: 600; }
|
||||||
|
|
||||||
|
.booked-info { }
|
||||||
|
.booked-text { font-size: 22rpx; color: #e67e22; }
|
||||||
|
|
||||||
|
/* ── Slot body ───────────────────────────── */
|
||||||
|
.slot-body {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-section {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8rpx;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-display {
|
||||||
|
background: #f7f4f0;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
padding: 12rpx 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-text {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-separator {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.capacity-section {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.capacity-label {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.capacity-control {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.capacity-btn {
|
||||||
|
width: 48rpx;
|
||||||
|
height: 48rpx;
|
||||||
|
border-radius: 24rpx;
|
||||||
|
background: #f0ece8;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
&:active { opacity: 0.6; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.capacity-btn-text {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1a1a2e;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.capacity-value {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1a1a2e;
|
||||||
|
min-width: 40rpx;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-section {
|
||||||
|
margin-left: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-btn {
|
||||||
|
width: 48rpx;
|
||||||
|
height: 48rpx;
|
||||||
|
border-radius: 24rpx;
|
||||||
|
background: rgba(192, 57, 43, 0.08);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
&--warn {
|
||||||
|
background: rgba(192, 57, 43, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active { opacity: 0.6; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-btn-text {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #c0392b;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Add button ──────────────────────────── */
|
||||||
|
.add-wrap {
|
||||||
|
margin: 24rpx;
|
||||||
|
padding: 24rpx;
|
||||||
|
border: 2rpx dashed $primary-dark;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
&:active { opacity: 0.6; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-text {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $primary-dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Action bar ──────────────────────────── */
|
||||||
|
.action-bar {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
padding: 20rpx 24rpx 48rpx;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.publish-btn {
|
||||||
|
width: 100%;
|
||||||
|
height: 96rpx;
|
||||||
|
border-radius: 48rpx;
|
||||||
|
background: linear-gradient(90deg, #1a1a2e, #2d2d5e);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
&--loading { opacity: 0.6; }
|
||||||
|
|
||||||
|
&:active { opacity: 0.85; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.publish-btn-text {
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $primary-dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Modal ───────────────────────────────── */
|
||||||
|
.modal-mask {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
z-index: 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
width: 100%;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 24rpx 24rpx 0 0;
|
||||||
|
padding: 40rpx 32rpx 60rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1a1a2e;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-field {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 24rpx 0;
|
||||||
|
border-bottom: 1rpx solid #f5f5f5;
|
||||||
|
|
||||||
|
&--last { border-bottom: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-label {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #555;
|
||||||
|
width: 140rpx;
|
||||||
|
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-text--muted { color: #999; }
|
||||||
|
.picker-arrow { font-size: 26rpx; color: #bbb; }
|
||||||
|
.picker-display--disabled { opacity: 0.6; }
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 16rpx;
|
||||||
|
margin-top: 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-cancel {
|
||||||
|
flex: 1;
|
||||||
|
height: 88rpx;
|
||||||
|
background: #f0f0f0;
|
||||||
|
border-radius: 44rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-cancel-text { font-size: 28rpx; color: #555; }
|
||||||
|
|
||||||
|
.modal-confirm {
|
||||||
|
flex: 2;
|
||||||
|
height: 88rpx;
|
||||||
|
background: linear-gradient(90deg, #1a1a2e, #2d2d5e);
|
||||||
|
border-radius: 44rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-confirm-text { font-size: 28rpx; font-weight: 700; color: $primary-dark; }
|
||||||
|
</style>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="page">
|
<view class="page" :style="{ paddingTop: navBarHeight }">
|
||||||
|
<CustomNavBar title="时段调整" show-back />
|
||||||
<!-- Tabs -->
|
<!-- Tabs -->
|
||||||
<view class="tabs">
|
<view class="tabs">
|
||||||
<view
|
<view
|
||||||
@@ -27,7 +28,12 @@
|
|||||||
</view>
|
</view>
|
||||||
<view class="form-row">
|
<view class="form-row">
|
||||||
<text class="form-label">开始时间</text>
|
<text class="form-label">开始时间</text>
|
||||||
<picker mode="time" :value="addForm.startTime" @change="(e: any) => addForm.startTime = e.detail.value">
|
<picker
|
||||||
|
mode="multiSelector"
|
||||||
|
:range="timePickerRange"
|
||||||
|
:value="timeToPickerIndex(addForm.startTime)"
|
||||||
|
@change="(e: any) => addForm.startTime = pickerIndexToTime(e.detail.value)"
|
||||||
|
>
|
||||||
<view class="picker-display">
|
<view class="picker-display">
|
||||||
<text class="picker-text">{{ addForm.startTime || '请选择' }}</text>
|
<text class="picker-text">{{ addForm.startTime || '请选择' }}</text>
|
||||||
<text class="picker-arrow">›</text>
|
<text class="picker-arrow">›</text>
|
||||||
@@ -36,7 +42,12 @@
|
|||||||
</view>
|
</view>
|
||||||
<view class="form-row">
|
<view class="form-row">
|
||||||
<text class="form-label">结束时间</text>
|
<text class="form-label">结束时间</text>
|
||||||
<picker mode="time" :value="addForm.endTime" @change="(e: any) => addForm.endTime = e.detail.value">
|
<picker
|
||||||
|
mode="multiSelector"
|
||||||
|
:range="timePickerRange"
|
||||||
|
:value="timeToPickerIndex(addForm.endTime)"
|
||||||
|
@change="(e: any) => addForm.endTime = pickerIndexToTime(e.detail.value)"
|
||||||
|
>
|
||||||
<view class="picker-display">
|
<view class="picker-display">
|
||||||
<text class="picker-text">{{ addForm.endTime || '请选择' }}</text>
|
<text class="picker-text">{{ addForm.endTime || '请选择' }}</text>
|
||||||
<text class="picker-arrow">›</text>
|
<text class="picker-arrow">›</text>
|
||||||
@@ -127,7 +138,7 @@
|
|||||||
</picker>
|
</picker>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<text class="gen-hint">将根据排课模板,自动生成所选日期范围内的时段</text>
|
<text class="gen-hint">将按默认时间表(8:00-9:00,之后从 9:30 起每小时一节至 21:30)自动生成所选日期范围内的时段</text>
|
||||||
<view class="action-wrap">
|
<view class="action-wrap">
|
||||||
<view class="action-btn" :class="{ 'action-btn--loading': submitting }" @tap="submitGenerate">
|
<view class="action-btn" :class="{ 'action-btn--loading': submitting }" @tap="submitGenerate">
|
||||||
<text class="action-btn-text">{{ submitting ? '生成中...' : '批量生成' }}</text>
|
<text class="action-btn-text">{{ submitting ? '生成中...' : '批量生成' }}</text>
|
||||||
@@ -138,13 +149,23 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
|
import { getSystemLayout } from '../../utils/system'
|
||||||
import { useAdminStore } from '../../stores/admin'
|
import { useAdminStore } from '../../stores/admin'
|
||||||
import { formatDate } from '../../utils/format'
|
import { formatDate } from '../../utils/format'
|
||||||
import type { TimeSlot } from '@mp-pilates/shared'
|
import type { TimeSlot } from '@mp-pilates/shared'
|
||||||
|
import {
|
||||||
|
SCHEDULE_TIME_PICKER_RANGE,
|
||||||
|
timeToPickerIndex,
|
||||||
|
pickerIndexToTime,
|
||||||
|
} from '../../utils/schedule-time'
|
||||||
|
|
||||||
|
const timePickerRange = SCHEDULE_TIME_PICKER_RANGE
|
||||||
|
|
||||||
const adminStore = useAdminStore()
|
const adminStore = useAdminStore()
|
||||||
|
|
||||||
|
const navBarHeight = ref('64px')
|
||||||
const tabs = ['新增时段', '关闭时段', '批量生成']
|
const tabs = ['新增时段', '关闭时段', '批量生成']
|
||||||
const activeTab = ref(0)
|
const activeTab = ref(0)
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
@@ -153,8 +174,8 @@ const slotsLoading = ref(false)
|
|||||||
// ── Add slot form ────────────────────────────────────────────────
|
// ── Add slot form ────────────────────────────────────────────────
|
||||||
const addForm = ref({
|
const addForm = ref({
|
||||||
date: formatDate(new Date()),
|
date: formatDate(new Date()),
|
||||||
startTime: '09:00',
|
startTime: '09:30',
|
||||||
endTime: '10:00',
|
endTime: '10:30',
|
||||||
capacityStr: '10',
|
capacityStr: '10',
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -164,6 +185,10 @@ async function submitAddSlot() {
|
|||||||
uni.showToast({ title: '请填写完整信息', icon: 'none' })
|
uni.showToast({ title: '请填写完整信息', icon: 'none' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (addForm.value.startTime >= addForm.value.endTime) {
|
||||||
|
uni.showToast({ title: '结束时间须晚于开始时间', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
const capacity = parseInt(addForm.value.capacityStr, 10)
|
const capacity = parseInt(addForm.value.capacityStr, 10)
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
@@ -242,6 +267,10 @@ async function submitGenerate() {
|
|||||||
submitting.value = false
|
submitting.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@@ -278,7 +307,7 @@ async function submitGenerate() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tab--active {
|
.tab--active {
|
||||||
border-bottom: 4rpx solid #c9a87c;
|
border-bottom: 4rpx solid $primary-dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Panel ───────────────────────────────── */
|
/* ── Panel ───────────────────────────────── */
|
||||||
@@ -335,11 +364,6 @@ async function submitGenerate() {
|
|||||||
animation: shimmer 1.4s infinite;
|
animation: shimmer 1.4s infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes shimmer {
|
|
||||||
0% { background-position: 100% 0; }
|
|
||||||
100% { background-position: -100% 0; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Empty ───────────────────────────────── */
|
/* ── Empty ───────────────────────────────── */
|
||||||
.empty-state {
|
.empty-state {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -423,5 +447,5 @@ async function submitGenerate() {
|
|||||||
&--loading { opacity: 0.6; }
|
&--loading { opacity: 0.6; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-btn-text { font-size: 30rpx; font-weight: 700; color: #c9a87c; }
|
.action-btn-text { font-size: 30rpx; font-weight: 700; color: $primary-dark; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,499 +0,0 @@
|
|||||||
<template>
|
|
||||||
<view class="page">
|
|
||||||
<!-- Toolbar -->
|
|
||||||
<view class="toolbar">
|
|
||||||
<text class="toolbar-hint">共 {{ templates.length }} 条模板</text>
|
|
||||||
<view class="add-btn" @tap="openAdd">
|
|
||||||
<text class="add-btn-text">+ 新增时段</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- Loading skeleton -->
|
|
||||||
<view v-if="loading" class="skeleton-list">
|
|
||||||
<view v-for="i in 5" :key="i" class="skeleton-item" />
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- Empty -->
|
|
||||||
<view v-else-if="!templates.length" class="empty-state">
|
|
||||||
<text class="empty-icon">📅</text>
|
|
||||||
<text class="empty-text">暂无模板,点击右上角新增</text>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- Template list grouped by weekday -->
|
|
||||||
<view v-else>
|
|
||||||
<view v-for="(group, day) in grouped" :key="day" class="day-group">
|
|
||||||
<view class="day-header">
|
|
||||||
<text class="day-label">{{ WEEKDAY_LABELS[Number(day)] }}</text>
|
|
||||||
<text class="day-count">{{ group.length }} 个时段</text>
|
|
||||||
</view>
|
|
||||||
<view
|
|
||||||
v-for="tpl in group"
|
|
||||||
:key="tpl.id ?? tpl._key"
|
|
||||||
class="tpl-row"
|
|
||||||
:class="{ 'tpl-row--inactive': !tpl.isActive }"
|
|
||||||
>
|
|
||||||
<view class="tpl-time">
|
|
||||||
<text class="tpl-time-text">{{ tpl.startTime }} – {{ tpl.endTime }}</text>
|
|
||||||
<text class="tpl-capacity">{{ tpl.capacity }} 人</text>
|
|
||||||
</view>
|
|
||||||
<view class="tpl-actions">
|
|
||||||
<view
|
|
||||||
class="tpl-toggle"
|
|
||||||
:class="tpl.isActive ? 'toggle--on' : 'toggle--off'"
|
|
||||||
@tap="toggleTemplate(tpl)"
|
|
||||||
>
|
|
||||||
<text class="tpl-toggle-text">{{ tpl.isActive ? '启用' : '停用' }}</text>
|
|
||||||
</view>
|
|
||||||
<view class="tpl-edit" @tap="openEdit(tpl)">
|
|
||||||
<text class="tpl-edit-text">编辑</text>
|
|
||||||
</view>
|
|
||||||
<view class="tpl-delete" @tap="deleteTemplate(tpl)">
|
|
||||||
<text class="tpl-delete-text">删除</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- Save bar -->
|
|
||||||
<view v-if="isDirty" class="save-bar">
|
|
||||||
<view class="save-btn" :class="{ 'save-btn--loading': saving }" @tap="handleSave">
|
|
||||||
<text class="save-btn-text">{{ saving ? '保存中...' : '保存全部更改' }}</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- Add / Edit modal -->
|
|
||||||
<view v-if="showModal" class="modal-mask" @tap.self="closeModal">
|
|
||||||
<view class="modal">
|
|
||||||
<text class="modal-title">{{ editTarget ? '编辑时段' : '新增时段' }}</text>
|
|
||||||
|
|
||||||
<view class="modal-field">
|
|
||||||
<text class="modal-label">星期</text>
|
|
||||||
<picker
|
|
||||||
mode="selector"
|
|
||||||
:range="dayOptions"
|
|
||||||
range-key="label"
|
|
||||||
:value="form.dayIdx"
|
|
||||||
@change="(e: any) => form.dayIdx = Number(e.detail.value)"
|
|
||||||
>
|
|
||||||
<view class="picker-display">
|
|
||||||
<text class="picker-text">{{ dayOptions[form.dayIdx].label }}</text>
|
|
||||||
<text class="picker-arrow">›</text>
|
|
||||||
</view>
|
|
||||||
</picker>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="modal-field">
|
|
||||||
<text class="modal-label">开始时间</text>
|
|
||||||
<picker
|
|
||||||
mode="time"
|
|
||||||
:value="form.startTime"
|
|
||||||
@change="(e: any) => form.startTime = e.detail.value"
|
|
||||||
>
|
|
||||||
<view class="picker-display">
|
|
||||||
<text class="picker-text">{{ form.startTime || '请选择' }}</text>
|
|
||||||
<text class="picker-arrow">›</text>
|
|
||||||
</view>
|
|
||||||
</picker>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="modal-field">
|
|
||||||
<text class="modal-label">结束时间</text>
|
|
||||||
<picker
|
|
||||||
mode="time"
|
|
||||||
:value="form.endTime"
|
|
||||||
@change="(e: any) => form.endTime = e.detail.value"
|
|
||||||
>
|
|
||||||
<view class="picker-display">
|
|
||||||
<text class="picker-text">{{ form.endTime || '请选择' }}</text>
|
|
||||||
<text class="picker-arrow">›</text>
|
|
||||||
</view>
|
|
||||||
</picker>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="modal-field modal-field--last">
|
|
||||||
<text class="modal-label">容量</text>
|
|
||||||
<input
|
|
||||||
class="modal-input"
|
|
||||||
type="number"
|
|
||||||
v-model="form.capacityStr"
|
|
||||||
placeholder="如:10"
|
|
||||||
placeholder-style="color:#bbb"
|
|
||||||
/>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="modal-actions">
|
|
||||||
<view class="modal-cancel" @tap="closeModal">
|
|
||||||
<text class="modal-cancel-text">取消</text>
|
|
||||||
</view>
|
|
||||||
<view class="modal-confirm" @tap="submitForm">
|
|
||||||
<text class="modal-confirm-text">确认</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed, onMounted } from 'vue'
|
|
||||||
import { useAdminStore } from '../../stores/admin'
|
|
||||||
import { WEEKDAY_LABELS } from '@mp-pilates/shared'
|
|
||||||
import type { WeekTemplate } from '@mp-pilates/shared'
|
|
||||||
|
|
||||||
type LocalTemplate = Partial<WeekTemplate> & {
|
|
||||||
_key?: string
|
|
||||||
dayOfWeek: number
|
|
||||||
startTime: string
|
|
||||||
endTime: string
|
|
||||||
capacity: number
|
|
||||||
isActive: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const adminStore = useAdminStore()
|
|
||||||
const loading = ref(false)
|
|
||||||
const saving = ref(false)
|
|
||||||
const isDirty = ref(false)
|
|
||||||
const showModal = ref(false)
|
|
||||||
const editTarget = ref<LocalTemplate | null>(null)
|
|
||||||
|
|
||||||
const templates = ref<LocalTemplate[]>([])
|
|
||||||
|
|
||||||
const dayOptions = [1, 2, 3, 4, 5, 6, 7].map((d) => ({ label: WEEKDAY_LABELS[d], value: d }))
|
|
||||||
|
|
||||||
const form = ref({
|
|
||||||
dayIdx: 0,
|
|
||||||
startTime: '09:00',
|
|
||||||
endTime: '10:00',
|
|
||||||
capacityStr: '10',
|
|
||||||
})
|
|
||||||
|
|
||||||
const grouped = computed(() => {
|
|
||||||
const map: Record<number, LocalTemplate[]> = {}
|
|
||||||
for (const tpl of templates.value) {
|
|
||||||
if (!map[tpl.dayOfWeek]) map[tpl.dayOfWeek] = []
|
|
||||||
map[tpl.dayOfWeek].push(tpl)
|
|
||||||
}
|
|
||||||
// Sort by day
|
|
||||||
return Object.fromEntries(
|
|
||||||
Object.entries(map).sort(([a], [b]) => Number(a) - Number(b)),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
async function fetchTemplates() {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
templates.value = await adminStore.fetchWeekTemplates()
|
|
||||||
isDirty.value = false
|
|
||||||
} catch {
|
|
||||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openAdd() {
|
|
||||||
editTarget.value = null
|
|
||||||
form.value = { dayIdx: 0, startTime: '09:00', endTime: '10:00', capacityStr: '10' }
|
|
||||||
showModal.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function openEdit(tpl: LocalTemplate) {
|
|
||||||
editTarget.value = tpl
|
|
||||||
const dayIdx = dayOptions.findIndex((d) => d.value === tpl.dayOfWeek)
|
|
||||||
form.value = {
|
|
||||||
dayIdx: dayIdx >= 0 ? dayIdx : 0,
|
|
||||||
startTime: tpl.startTime,
|
|
||||||
endTime: tpl.endTime,
|
|
||||||
capacityStr: String(tpl.capacity),
|
|
||||||
}
|
|
||||||
showModal.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeModal() {
|
|
||||||
showModal.value = false
|
|
||||||
editTarget.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
function submitForm() {
|
|
||||||
const capacity = parseInt(form.value.capacityStr, 10)
|
|
||||||
if (!form.value.startTime || !form.value.endTime) {
|
|
||||||
uni.showToast({ title: '请填写时间', icon: 'none' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (isNaN(capacity) || capacity < 1) {
|
|
||||||
uni.showToast({ title: '请填写有效容量', icon: 'none' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const day = dayOptions[form.value.dayIdx].value
|
|
||||||
|
|
||||||
if (editTarget.value) {
|
|
||||||
const tpl = editTarget.value
|
|
||||||
tpl.dayOfWeek = day
|
|
||||||
tpl.startTime = form.value.startTime
|
|
||||||
tpl.endTime = form.value.endTime
|
|
||||||
tpl.capacity = capacity
|
|
||||||
} else {
|
|
||||||
templates.value.push({
|
|
||||||
_key: String(Date.now()),
|
|
||||||
dayOfWeek: day,
|
|
||||||
startTime: form.value.startTime,
|
|
||||||
endTime: form.value.endTime,
|
|
||||||
capacity,
|
|
||||||
isActive: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
isDirty.value = true
|
|
||||||
closeModal()
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleTemplate(tpl: LocalTemplate) {
|
|
||||||
tpl.isActive = !tpl.isActive
|
|
||||||
isDirty.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function deleteTemplate(tpl: LocalTemplate) {
|
|
||||||
uni.showModal({
|
|
||||||
title: '确认删除',
|
|
||||||
content: '删除该时段模板?',
|
|
||||||
success: (res) => {
|
|
||||||
if (res.confirm) {
|
|
||||||
const idx = templates.value.indexOf(tpl)
|
|
||||||
if (idx >= 0) templates.value.splice(idx, 1)
|
|
||||||
isDirty.value = true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSave() {
|
|
||||||
if (saving.value) return
|
|
||||||
saving.value = true
|
|
||||||
try {
|
|
||||||
const payload = templates.value.map((t) => ({
|
|
||||||
id: t.id,
|
|
||||||
dayOfWeek: t.dayOfWeek,
|
|
||||||
startTime: t.startTime,
|
|
||||||
endTime: t.endTime,
|
|
||||||
capacity: t.capacity,
|
|
||||||
isActive: t.isActive,
|
|
||||||
}))
|
|
||||||
await adminStore.saveWeekTemplates(payload as any)
|
|
||||||
isDirty.value = false
|
|
||||||
uni.showToast({ title: '保存成功', icon: 'success' })
|
|
||||||
await fetchTemplates()
|
|
||||||
} catch (e: any) {
|
|
||||||
uni.showToast({ title: e?.message ?? '保存失败', icon: 'none' })
|
|
||||||
} finally {
|
|
||||||
saving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(fetchTemplates)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.page {
|
|
||||||
min-height: 100vh;
|
|
||||||
background: #f5f3f0;
|
|
||||||
padding-bottom: 120rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── 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: #1a1a2e;
|
|
||||||
border-radius: 32rpx;
|
|
||||||
padding: 12rpx 28rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-btn-text { font-size: 26rpx; font-weight: 600; color: #c9a87c; }
|
|
||||||
|
|
||||||
/* ── Skeleton ────────────────────────────── */
|
|
||||||
.skeleton-list { padding: 0 24rpx; }
|
|
||||||
|
|
||||||
.skeleton-item {
|
|
||||||
height: 80rpx;
|
|
||||||
border-radius: 12rpx;
|
|
||||||
margin-bottom: 16rpx;
|
|
||||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
|
||||||
background-size: 400% 100%;
|
|
||||||
animation: shimmer 1.4s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes shimmer {
|
|
||||||
0% { background-position: 100% 0; }
|
|
||||||
100% { background-position: -100% 0; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── 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; }
|
|
||||||
|
|
||||||
/* ── Day group ───────────────────────────── */
|
|
||||||
.day-group { margin: 0 24rpx 24rpx; }
|
|
||||||
|
|
||||||
.day-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 16rpx 8rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.day-label { font-size: 28rpx; font-weight: 700; color: #1a1a2e; }
|
|
||||||
.day-count { font-size: 22rpx; color: #999; }
|
|
||||||
|
|
||||||
/* ── Template row ────────────────────────── */
|
|
||||||
.tpl-row {
|
|
||||||
background: #ffffff;
|
|
||||||
border-radius: 12rpx;
|
|
||||||
padding: 20rpx 24rpx;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 12rpx;
|
|
||||||
box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.06);
|
|
||||||
|
|
||||||
&--inactive { opacity: 0.5; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.tpl-time { display: flex; flex-direction: column; gap: 6rpx; }
|
|
||||||
.tpl-time-text { font-size: 28rpx; font-weight: 600; color: #1a1a2e; }
|
|
||||||
.tpl-capacity { font-size: 22rpx; color: #888; }
|
|
||||||
|
|
||||||
.tpl-actions { display: flex; gap: 12rpx; }
|
|
||||||
|
|
||||||
.tpl-toggle,
|
|
||||||
.tpl-edit,
|
|
||||||
.tpl-delete {
|
|
||||||
border-radius: 20rpx;
|
|
||||||
padding: 8rpx 20rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle--on { background: rgba(39,174,96,0.12); }
|
|
||||||
.toggle--on .tpl-toggle-text { font-size: 24rpx; color: #27ae60; }
|
|
||||||
.toggle--off { background: rgba(230,126,34,0.12); }
|
|
||||||
.toggle--off .tpl-toggle-text { font-size: 24rpx; color: #e67e22; }
|
|
||||||
|
|
||||||
.tpl-edit { background: rgba(26,26,46,0.08); }
|
|
||||||
.tpl-edit-text { font-size: 24rpx; color: #1a1a2e; }
|
|
||||||
|
|
||||||
.tpl-delete { background: rgba(192,57,43,0.08); }
|
|
||||||
.tpl-delete-text { font-size: 24rpx; color: #c0392b; }
|
|
||||||
|
|
||||||
/* ── Save bar ────────────────────────────── */
|
|
||||||
.save-bar {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
padding: 20rpx 24rpx 48rpx;
|
|
||||||
background: #ffffff;
|
|
||||||
box-shadow: 0 -4rpx 20rpx rgba(0,0,0,0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.save-btn {
|
|
||||||
width: 100%;
|
|
||||||
height: 96rpx;
|
|
||||||
border-radius: 48rpx;
|
|
||||||
background: linear-gradient(90deg, #1a1a2e, #2d2d5e);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
&--loading { opacity: 0.6; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.save-btn-text { font-size: 30rpx; font-weight: 700; color: #c9a87c; }
|
|
||||||
|
|
||||||
/* ── Modal ───────────────────────────────── */
|
|
||||||
.modal-mask {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(0,0,0,0.5);
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-end;
|
|
||||||
z-index: 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal {
|
|
||||||
width: 100%;
|
|
||||||
background: #ffffff;
|
|
||||||
border-radius: 24rpx 24rpx 0 0;
|
|
||||||
padding: 40rpx 32rpx 60rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-title {
|
|
||||||
font-size: 32rpx;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #1a1a2e;
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 24rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-field {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 24rpx 0;
|
|
||||||
border-bottom: 1rpx solid #f5f5f5;
|
|
||||||
|
|
||||||
&--last { border-bottom: none; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-label { font-size: 26rpx; color: #555; width: 140rpx; 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; }
|
|
||||||
|
|
||||||
.modal-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 16rpx;
|
|
||||||
margin-top: 32rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-cancel {
|
|
||||||
flex: 1;
|
|
||||||
height: 88rpx;
|
|
||||||
background: #f0f0f0;
|
|
||||||
border-radius: 44rpx;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-cancel-text { font-size: 28rpx; color: #555; }
|
|
||||||
|
|
||||||
.modal-confirm {
|
|
||||||
flex: 2;
|
|
||||||
height: 88rpx;
|
|
||||||
background: linear-gradient(90deg, #1a1a2e, #2d2d5e);
|
|
||||||
border-radius: 44rpx;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-confirm-text { font-size: 28rpx; font-weight: 700; color: #c9a87c; }
|
|
||||||
</style>
|
|
||||||
1108
packages/app/src/pages/booking/detail.vue
Normal file
1108
packages/app/src/pages/booking/detail.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,43 +1,64 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="booking-page">
|
<view class="booking-page" :style="{ height: pageHeight }">
|
||||||
<!-- ──────────── Sticky header area ──────────── -->
|
<!-- ──────────── Status bar spacing ──────────── -->
|
||||||
<view class="sticky-header">
|
<view class="status-bar" :style="{ height: statusBarHeight }" />
|
||||||
<!-- Date selector -->
|
|
||||||
<DateSelector v-model="selectedDate" @select="onDateSelect" />
|
|
||||||
|
|
||||||
<!-- Time period filter -->
|
<!-- ──────────── Page title ──────────── -->
|
||||||
<TimePeriodFilter v-model="selectedPeriod" @change="onPeriodChange" />
|
<view class="page-header">
|
||||||
|
<text class="page-title">课程预约</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- ──────────── Date & period filters ──────────── -->
|
||||||
|
<view class="filter-header">
|
||||||
|
<view class="calendar-heading">
|
||||||
|
<text class="calendar-month">{{ selectedMonthLabel }}</text>
|
||||||
|
<text class="calendar-hint">选择上课日期</text>
|
||||||
|
</view>
|
||||||
|
<DateSelector v-model="selectedDate" variant="soft" @select="onDateSelect" />
|
||||||
|
<TimePeriodFilter v-model="selectedPeriod" variant="soft" @change="onPeriodChange" />
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- ──────────── Slot list ──────────── -->
|
<!-- ──────────── Slot list ──────────── -->
|
||||||
<scroll-view
|
<scroll-view
|
||||||
class="slot-scroll"
|
class="slot-scroll"
|
||||||
scroll-y
|
scroll-y
|
||||||
:style="{ height: scrollHeight }"
|
|
||||||
refresher-enabled
|
refresher-enabled
|
||||||
:refresher-triggered="refreshing"
|
:refresher-triggered="refreshing"
|
||||||
@refresherrefresh="onRefresh"
|
@refresherrefresh="onRefresh"
|
||||||
>
|
>
|
||||||
<!-- Loading skeleton -->
|
<!-- Loading skeleton -->
|
||||||
<view v-if="bookingStore.loadingSlots && !refreshing" class="loading-wrap">
|
<view v-if="bookingStore.loadingSlots && !refreshing" class="loading-wrap">
|
||||||
<view v-for="i in 4" :key="i" class="skeleton-card" />
|
<view v-for="i in 3" :key="i" class="skeleton-card">
|
||||||
|
<view class="skeleton-time" />
|
||||||
|
<view class="skeleton-body">
|
||||||
|
<view class="skeleton-title" />
|
||||||
|
<view class="skeleton-sub" />
|
||||||
|
</view>
|
||||||
|
<view class="skeleton-btn" />
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Empty state -->
|
<!-- Empty state -->
|
||||||
<view v-else-if="filteredSlots.length === 0" class="empty-wrap">
|
<view v-else-if="filteredSlots.length === 0" class="empty-wrap">
|
||||||
<image class="empty-img" src="/static/images/empty-calendar.png" mode="aspectFit" />
|
<text class="empty-text">这个时段还没有课程</text>
|
||||||
<text class="empty-text">当日暂无可约时段</text>
|
<text class="empty-sub">请选择其他日期或时段查看</text>
|
||||||
<text class="empty-sub">请选择其他日期或时段</text>
|
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Slot cards -->
|
<!-- Slot cards -->
|
||||||
<view v-else class="slot-list">
|
<view v-else class="slot-list">
|
||||||
|
<!-- Date summary -->
|
||||||
|
<view class="date-summary">
|
||||||
|
<text class="date-summary-title">{{ selectedDayLabel }}</text>
|
||||||
|
<text class="date-summary-text">{{ filteredSlots.length }} 个时段</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
<SlotCard
|
<SlotCard
|
||||||
v-for="slot in filteredSlots"
|
v-for="item in filteredSlots"
|
||||||
:key="slot.id"
|
:key="item.id"
|
||||||
:slot="slot"
|
:time-slot="item"
|
||||||
@book="onBookTap"
|
@book="onBookTap"
|
||||||
@cancel="onCancelTap"
|
@cancel="onCancelTap"
|
||||||
|
@card-tap="onSlotCardTap"
|
||||||
/>
|
/>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -48,7 +69,7 @@
|
|||||||
<!-- ──────────── Confirm popup ──────────── -->
|
<!-- ──────────── Confirm popup ──────────── -->
|
||||||
<BookingConfirmPopup
|
<BookingConfirmPopup
|
||||||
:visible="showConfirmPopup"
|
:visible="showConfirmPopup"
|
||||||
:slot="pendingSlot"
|
:time-slot="pendingSlot"
|
||||||
:memberships="userStore.activeMemberships as MembershipWithCardType[]"
|
:memberships="userStore.activeMemberships as MembershipWithCardType[]"
|
||||||
@confirm="onConfirmBooking"
|
@confirm="onConfirmBooking"
|
||||||
@cancel="showConfirmPopup = false"
|
@cancel="showConfirmPopup = false"
|
||||||
@@ -57,12 +78,15 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { onResize, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||||
import type { TimeSlotWithBookingStatus, MembershipWithCardType } from '@mp-pilates/shared'
|
import type { TimeSlotWithBookingStatus, MembershipWithCardType } from '@mp-pilates/shared'
|
||||||
import { TIME_PERIODS } from '@mp-pilates/shared'
|
import { BookingStatus, TIME_PERIODS } from '@mp-pilates/shared'
|
||||||
import { useBookingStore } from '../../stores/booking'
|
import { useBookingStore } from '../../stores/booking'
|
||||||
import { useUserStore } from '../../stores/user'
|
import { useUserStore } from '../../stores/user'
|
||||||
import { formatDate, getDateRange } from '../../utils/format'
|
import { getErrorMessage } from '../../utils/auth'
|
||||||
|
import { formatDate } from '../../utils/format'
|
||||||
|
import { getSystemLayout } from '../../utils/system'
|
||||||
import DateSelector from '../../components/DateSelector.vue'
|
import DateSelector from '../../components/DateSelector.vue'
|
||||||
import TimePeriodFilter from '../../components/TimePeriodFilter.vue'
|
import TimePeriodFilter from '../../components/TimePeriodFilter.vue'
|
||||||
import SlotCard from '../../components/SlotCard.vue'
|
import SlotCard from '../../components/SlotCard.vue'
|
||||||
@@ -81,19 +105,49 @@ const showConfirmPopup = ref(false)
|
|||||||
const pendingSlot = ref<TimeSlotWithBookingStatus | null>(null)
|
const pendingSlot = ref<TimeSlotWithBookingStatus | null>(null)
|
||||||
const refreshing = ref(false)
|
const refreshing = ref(false)
|
||||||
|
|
||||||
|
// ─── 微信分享 ───────────────────────────────────────────────
|
||||||
|
onShareAppMessage(() => {
|
||||||
|
return {
|
||||||
|
title: '预约普拉提课程,开启健康新生活',
|
||||||
|
path: '/pages/booking/index',
|
||||||
|
imageUrl: '',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onShareTimeline(() => {
|
||||||
|
return {
|
||||||
|
title: '预约普拉提课程,开启健康新生活',
|
||||||
|
query: '',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// ─── Layout ───────────────────────────────────────────────
|
// ─── Layout ───────────────────────────────────────────────
|
||||||
// Approximate scroll area height (vh minus sticky header ~220rpx + tabbar ~100rpx)
|
const statusBarHeight = ref('20px')
|
||||||
const scrollHeight = computed(() => {
|
const pageHeight = ref('100vh')
|
||||||
const sysInfo = uni.getSystemInfoSync()
|
|
||||||
const headerPx = 220 * (sysInfo.windowWidth / 750)
|
function updateLayout() {
|
||||||
const tabbarPx = 100 * (sysInfo.windowWidth / 750)
|
statusBarHeight.value = `${getSystemLayout().statusBarHeight}px`
|
||||||
return `${sysInfo.windowHeight - headerPx - tabbarPx}px`
|
// The mini-program window already excludes its native tab bar. Flex layout
|
||||||
|
// gives the remaining height to the list as the filters change size.
|
||||||
|
pageHeight.value = `${uni.getWindowInfo().windowHeight}px`
|
||||||
|
}
|
||||||
|
|
||||||
|
updateLayout()
|
||||||
|
onResize(updateLayout)
|
||||||
|
|
||||||
|
const selectedMonthLabel = computed(() => {
|
||||||
|
const [year, month] = selectedDate.value.split('-')
|
||||||
|
return `${year} 年 ${Number(month)} 月`
|
||||||
|
})
|
||||||
|
const selectedDayLabel = computed(() => {
|
||||||
|
const [, month, day] = selectedDate.value.split('-')
|
||||||
|
return `${Number(month)} 月 ${Number(day)} 日的课程`
|
||||||
})
|
})
|
||||||
|
|
||||||
// ─── Filtered slots ───────────────────────────────────────
|
// ─── Filtered slots ───────────────────────────────────────
|
||||||
const filteredSlots = computed<TimeSlotWithBookingStatus[]>(() => {
|
const filteredSlots = computed<TimeSlotWithBookingStatus[]>(() => {
|
||||||
const slots = bookingStore.slots as TimeSlotWithBookingStatus[]
|
const slots = bookingStore.slots as TimeSlotWithBookingStatus[]
|
||||||
if (!selectedPeriod.value) return [...slots]
|
if (!selectedPeriod.value) return slots
|
||||||
|
|
||||||
const period = TIME_PERIODS[selectedPeriod.value]
|
const period = TIME_PERIODS[selectedPeriod.value]
|
||||||
return slots.filter((slot) => {
|
return slots.filter((slot) => {
|
||||||
@@ -120,11 +174,36 @@ function onDateSelect(date: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onPeriodChange(_period: PeriodKey) {
|
function onPeriodChange(_period: PeriodKey) {
|
||||||
// Filtering is done client-side via computed property
|
// No-op: filtering is done client-side via computed property
|
||||||
|
void _period
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Card tap → navigate to detail ───────────────────────
|
||||||
|
function onSlotCardTap(slot: TimeSlotWithBookingStatus) {
|
||||||
|
if (slot.isBookedByMe && slot.myBookingId) {
|
||||||
|
// Already booked → show booking detail
|
||||||
|
uni.navigateTo({ url: `/pages/booking/detail?id=${slot.myBookingId}` })
|
||||||
|
} else {
|
||||||
|
// Not booked → show slot preview with booking action
|
||||||
|
uni.navigateTo({ url: `/pages/booking/detail?slotId=${slot.id}&date=${slot.date}` })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Book flow ────────────────────────────────────────────
|
// ─── Book flow ────────────────────────────────────────────
|
||||||
async function onBookTap(slot: TimeSlotWithBookingStatus) {
|
async function onBookTap(slot: TimeSlotWithBookingStatus) {
|
||||||
|
if (slot.isBookedByMe) {
|
||||||
|
if (slot.myBookingId) {
|
||||||
|
uni.navigateTo({ url: `/pages/booking/detail?id=${slot.myBookingId}` })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = slot.myBookingStatus === BookingStatus.PENDING_CONFIRMATION
|
||||||
|
? '该时段已预约,等待老师确认'
|
||||||
|
: '该时段已预约'
|
||||||
|
uni.showToast({ title, icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Ensure logged in
|
// 1. Ensure logged in
|
||||||
if (!userStore.loggedIn) {
|
if (!userStore.loggedIn) {
|
||||||
uni.showModal({
|
uni.showModal({
|
||||||
@@ -134,12 +213,12 @@ async function onBookTap(slot: TimeSlotWithBookingStatus) {
|
|||||||
success: async (res) => {
|
success: async (res) => {
|
||||||
if (res.confirm) {
|
if (res.confirm) {
|
||||||
try {
|
try {
|
||||||
await userStore.login()
|
const { isNewUser } = await userStore.loginWithSetup()
|
||||||
await userStore.fetchMemberships()
|
if (!isNewUser) {
|
||||||
// Retry booking flow after login
|
|
||||||
onBookTap(slot)
|
onBookTap(slot)
|
||||||
} catch {
|
}
|
||||||
uni.showToast({ title: '登录失败', icon: 'none' })
|
} catch (err: unknown) {
|
||||||
|
uni.showToast({ title: getErrorMessage(err, '登录失败'), icon: 'none' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -156,7 +235,9 @@ async function onBookTap(slot: TimeSlotWithBookingStatus) {
|
|||||||
cancelText: '取消',
|
cancelText: '取消',
|
||||||
success: (res) => {
|
success: (res) => {
|
||||||
if (res.confirm) {
|
if (res.confirm) {
|
||||||
uni.navigateTo({ url: '/pages/store/index' })
|
// Switch to home tab and auto-scroll to card shop
|
||||||
|
uni.$emit('scrollToCardShop')
|
||||||
|
uni.switchTab({ url: '/pages/home/index' })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -224,87 +305,33 @@ onMounted(async () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.booking-page {
|
.booking-page { height: 100vh; background: #fbf9f6; display: flex; flex-direction: column; overflow: hidden; }
|
||||||
min-height: 100vh;
|
.status-bar { flex-shrink: 0; }
|
||||||
background: #f5f3f0;
|
.page-header { flex-shrink: 0; height: 88rpx; display: flex; align-items: center; justify-content: center; }
|
||||||
display: flex;
|
.page-title { font-size: 34rpx; font-weight: 500; color: #514943; }
|
||||||
flex-direction: column;
|
.filter-header { flex-shrink: 0; border-bottom: 1rpx solid #eee8e3; }
|
||||||
}
|
.calendar-heading { display: flex; align-items: baseline; justify-content: space-between; padding: 24rpx 32rpx 16rpx; gap: 16rpx; }
|
||||||
|
.calendar-month { font-size: 28rpx; font-weight: 500; color: #514943; }
|
||||||
/* ── Sticky header ─────────────────────────────────── */
|
.calendar-hint { font-size: 22rpx; color: #8b817b; }
|
||||||
.sticky-header {
|
.slot-scroll { flex: 1; height: 0; min-height: 0; width: 100%; box-sizing: border-box; }
|
||||||
position: sticky;
|
.slot-list { display: flex; flex-direction: column; padding-top: 28rpx; }
|
||||||
top: 0;
|
.date-summary { display: flex; justify-content: space-between; align-items: baseline; gap: 16rpx; padding: 0 32rpx 20rpx; }
|
||||||
z-index: 100;
|
.date-summary-title { font-size: 25rpx; color: #6f655e; }
|
||||||
background: #fff;
|
.date-summary-text { font-size: 22rpx; color: #8b817b; }
|
||||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
|
.loading-wrap { display: flex; flex-direction: column; gap: 20rpx; padding: 28rpx 32rpx; }
|
||||||
}
|
.skeleton-card { height: 200rpx; box-sizing: border-box; border-radius: 28rpx; background: #fff; display: flex; align-items: center; padding: 28rpx; gap: 20rpx; }
|
||||||
|
.skeleton-time, .skeleton-title, .skeleton-sub, .skeleton-btn {
|
||||||
/* ── Scroll container ──────────────────────────────── */
|
border-radius: 10rpx;
|
||||||
.slot-scroll {
|
background: linear-gradient(90deg, #f0eae5 25%, #faf7f3 50%, #f0eae5 75%);
|
||||||
flex: 1;
|
background-size: 400% 100%; animation: shimmer 1.4s infinite;
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Slot list ─────────────────────────────────────── */
|
|
||||||
.slot-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 20rpx;
|
|
||||||
padding: 28rpx 24rpx 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Loading skeleton ──────────────────────────────── */
|
|
||||||
.loading-wrap {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 20rpx;
|
|
||||||
padding: 28rpx 24rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeleton-card {
|
|
||||||
height: 140rpx;
|
|
||||||
border-radius: 20rpx;
|
|
||||||
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
|
|
||||||
background-size: 400% 100%;
|
|
||||||
animation: shimmer 1.4s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes shimmer {
|
|
||||||
0% { background-position: 100% 0; }
|
|
||||||
100% { background-position: -100% 0; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Empty state ───────────────────────────────────── */
|
|
||||||
.empty-wrap {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 120rpx 40rpx;
|
|
||||||
gap: 16rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-img {
|
|
||||||
width: 200rpx;
|
|
||||||
height: 200rpx;
|
|
||||||
opacity: 0.5;
|
|
||||||
margin-bottom: 8rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-text {
|
|
||||||
font-size: 30rpx;
|
|
||||||
color: #666;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-sub {
|
|
||||||
font-size: 26rpx;
|
|
||||||
color: #bbb;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Bottom spacer ─────────────────────────────────── */
|
|
||||||
.scroll-bottom-spacer {
|
|
||||||
height: 48rpx;
|
|
||||||
}
|
}
|
||||||
|
.skeleton-time { width: 90rpx; height: 70rpx; flex-shrink: 0; }
|
||||||
|
.skeleton-body { flex: 1; display: flex; flex-direction: column; gap: 12rpx; }
|
||||||
|
.skeleton-title { width: 80%; height: 28rpx; }
|
||||||
|
.skeleton-sub { width: 60%; height: 20rpx; }
|
||||||
|
.skeleton-btn { width: 110rpx; height: 60rpx; border-radius: 999rpx; flex-shrink: 0; }
|
||||||
|
.empty-wrap { margin: 32rpx; padding: 64rpx 28rpx; border-radius: 28rpx; background: #f3efea; display: flex; flex-direction: column; align-items: center; gap: 14rpx; }
|
||||||
|
.empty-text { font-size: 28rpx; color: #6f655e; font-weight: 400; }
|
||||||
|
.empty-sub { font-size: 23rpx; color: #8b817b; text-align: center; line-height: 1.6; }
|
||||||
|
.scroll-bottom-spacer { height: 28rpx; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
840
packages/app/src/pages/flash-sale/detail.vue
Normal file
840
packages/app/src/pages/flash-sale/detail.vue
Normal file
@@ -0,0 +1,840 @@
|
|||||||
|
<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 class="hero-price">{{ formatPrice(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 - 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(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 { 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(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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm purchase
|
||||||
|
uni.showModal({
|
||||||
|
title: '确认抢购',
|
||||||
|
content: `确认以 ¥${formatPrice(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>
|
||||||
@@ -1,49 +1,39 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="home-page">
|
<view class="home-page" :style="{ height: pageHeight }">
|
||||||
<!-- Pull-to-refresh wrapper -->
|
<view class="banner-fixed">
|
||||||
<scroll-view
|
|
||||||
class="page-scroll"
|
|
||||||
scroll-y
|
|
||||||
:refresher-enabled="true"
|
|
||||||
:refresher-triggered="refreshing"
|
|
||||||
@refresherrefresh="handleRefresh"
|
|
||||||
@refresherrestore="refreshing = false"
|
|
||||||
>
|
|
||||||
<!-- ① Brand Banner (hero with bg image + centered logo) -->
|
|
||||||
<BrandBanner :studio-info="studioStore.studioInfo" />
|
<BrandBanner :studio-info="studioStore.studioInfo" />
|
||||||
|
</view>
|
||||||
<!-- ② Studio Info (photo strip + address/phone) -->
|
<scroll-view class="page-scroll" scroll-y :scroll-into-view="scrollTarget"
|
||||||
<StudioInfo :studio-info="studioStore.studioInfo" />
|
scroll-with-animation refresher-enabled :refresher-triggered="refreshing"
|
||||||
|
@refresherrefresh="handleRefresh" @refresherrestore="refreshing = false">
|
||||||
<!-- Divider -->
|
<view class="banner-spacer" />
|
||||||
<view class="section-divider" />
|
<view class="floating-card">
|
||||||
|
<view class="card-handle"><view class="card-handle-bar" /></view>
|
||||||
<!-- ③ Quick Entry (login / trial / book / renew) -->
|
|
||||||
<QuickEntry @scroll-to-card-shop="scrollToCardShop" />
|
<QuickEntry @scroll-to-card-shop="scrollToCardShop" />
|
||||||
|
|
||||||
<!-- ④ Upcoming Bookings -->
|
|
||||||
<UpcomingBooking />
|
<UpcomingBooking />
|
||||||
|
<StudioInfo :studio-info="studioStore.studioInfo" />
|
||||||
<!-- ⑤ Card Shop (vertical list) -->
|
<FlashSaleSection ref="flashSaleRef" />
|
||||||
<view :id="cardShopAnchorId">
|
<view :id="cardShopAnchorId">
|
||||||
<CardShop ref="cardShopRef" />
|
<CardShop ref="cardShopRef" />
|
||||||
</view>
|
</view>
|
||||||
|
<AboutSection />
|
||||||
<!-- Bottom padding for tab bar -->
|
|
||||||
<view class="bottom-padding" />
|
<view class="bottom-padding" />
|
||||||
|
</view>
|
||||||
</scroll-view>
|
</scroll-view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref, nextTick, onUnmounted } from 'vue'
|
||||||
import { onShow } from '@dcloudio/uni-app'
|
import { onShow, onResize, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||||
|
|
||||||
import BrandBanner from '../../components/BrandBanner.vue'
|
import BrandBanner from '../../components/BrandBanner.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 { useUserStore } from '../../stores/user'
|
import { useUserStore } from '../../stores/user'
|
||||||
import { useStudioStore } from '../../stores/studio'
|
import { useStudioStore } from '../../stores/studio'
|
||||||
@@ -53,13 +43,51 @@ const userStore = useUserStore()
|
|||||||
const studioStore = useStudioStore()
|
const studioStore = useStudioStore()
|
||||||
const bookingStore = useBookingStore()
|
const bookingStore = useBookingStore()
|
||||||
|
|
||||||
|
// ─── 微信分享 ───────────────────────────────────────────────
|
||||||
|
onShareAppMessage(() => {
|
||||||
|
return {
|
||||||
|
title: '专注核心,遇见更好的自己 | Focus Core 普拉提',
|
||||||
|
path: '/pages/home/index',
|
||||||
|
imageUrl: '',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onShareTimeline(() => {
|
||||||
|
return {
|
||||||
|
title: '专注核心,遇见更好的自己 | Focus Core 普拉提',
|
||||||
|
query: '',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── 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 pageHeight = ref(`${uni.getWindowInfo().windowHeight}px`)
|
||||||
|
onResize(() => { pageHeight.value = `${uni.getWindowInfo().windowHeight}px` })
|
||||||
|
const pendingScrollToCardShop = ref(false)
|
||||||
|
|
||||||
|
// Listen for cross-page scroll request (e.g. from booking page "去购买")
|
||||||
|
uni.$on('scrollToCardShop', () => {
|
||||||
|
pendingScrollToCardShop.value = true
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
uni.$off('scrollToCardShop')
|
||||||
|
})
|
||||||
|
|
||||||
// Refresh all data on every show
|
// Refresh all data on every show
|
||||||
onShow(async () => {
|
onShow(async () => {
|
||||||
await refreshData()
|
await refreshData()
|
||||||
|
|
||||||
|
// If another page requested scroll to card shop, execute after data is ready
|
||||||
|
if (pendingScrollToCardShop.value) {
|
||||||
|
pendingScrollToCardShop.value = false
|
||||||
|
await nextTick()
|
||||||
|
scrollToCardShop()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
async function refreshData() {
|
async function refreshData() {
|
||||||
@@ -67,6 +95,7 @@ async function refreshData() {
|
|||||||
|
|
||||||
if (userStore.loggedIn) {
|
if (userStore.loggedIn) {
|
||||||
tasks.push(
|
tasks.push(
|
||||||
|
userStore.fetchProfile(),
|
||||||
userStore.fetchMemberships(),
|
userStore.fetchMemberships(),
|
||||||
bookingStore.fetchUpcomingBookings(),
|
bookingStore.fetchUpcomingBookings(),
|
||||||
)
|
)
|
||||||
@@ -74,8 +103,11 @@ async function refreshData() {
|
|||||||
|
|
||||||
await Promise.allSettled(tasks)
|
await Promise.allSettled(tasks)
|
||||||
|
|
||||||
// Also refresh card shop
|
// Also refresh card shop and flash sales
|
||||||
cardShopRef.value?.fetchCardTypes()
|
await Promise.allSettled([
|
||||||
|
cardShopRef.value?.fetchCardTypes(),
|
||||||
|
flashSaleRef.value?.fetchFlashSales(),
|
||||||
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleRefresh() {
|
async function handleRefresh() {
|
||||||
@@ -87,30 +119,30 @@ async function handleRefresh() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollToCardShop() {
|
async function scrollToCardShop() {
|
||||||
uni.pageScrollTo({
|
scrollTarget.value = ''
|
||||||
selector: `#${cardShopAnchorId}`,
|
await nextTick()
|
||||||
duration: 300,
|
scrollTarget.value = cardShopAnchorId
|
||||||
})
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.home-page {
|
.home-page { position: relative; height: 100vh; background: #fbf9f6; }
|
||||||
|
.banner-fixed { position: fixed; top: 0; left: 0; width: 100%; z-index: 0; }
|
||||||
|
.page-scroll { position: relative; z-index: 1; height: 100%; }
|
||||||
|
.banner-spacer { height: 420rpx; }
|
||||||
|
.floating-card {
|
||||||
|
position: relative;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: #f5f5f5;
|
padding-top: 12rpx;
|
||||||
}
|
border-radius: 32rpx 32rpx 0 0;
|
||||||
|
border-top: 1rpx solid rgba(255, 255, 255, 0.7);
|
||||||
.page-scroll {
|
background: linear-gradient(180deg, rgba(251, 249, 246, 0.86), #fbf9f6 260rpx);
|
||||||
height: 100vh;
|
-webkit-backdrop-filter: blur(16px);
|
||||||
}
|
backdrop-filter: blur(16px);
|
||||||
|
box-shadow: 0 -4rpx 20rpx rgba(66, 54, 45, 0.06);
|
||||||
.section-divider {
|
|
||||||
height: 16rpx;
|
|
||||||
background: #f5f5f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bottom-padding {
|
|
||||||
height: 120rpx;
|
|
||||||
}
|
}
|
||||||
|
.card-handle { display: flex; justify-content: center; padding: 16rpx 0 8rpx; }
|
||||||
|
.card-handle-bar { width: 64rpx; height: 8rpx; border-radius: 4rpx; background: rgba(112, 94, 79, 0.18); }
|
||||||
|
.bottom-padding { height: 48rpx; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,22 +1,20 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="bookings-page">
|
<view class="page" :style="{ paddingTop: navBarHeight }">
|
||||||
<!-- Tab bar -->
|
<CustomNavBar title="我的预约" show-back />
|
||||||
<view class="tab-bar">
|
|
||||||
|
<view class="tabs">
|
||||||
<view
|
<view
|
||||||
v-for="tab in tabs"
|
v-for="tab in tabs"
|
||||||
:key="tab.key"
|
:key="tab.key"
|
||||||
class="tab-item"
|
class="tabs-item"
|
||||||
:class="{ active: activeTab === tab.key }"
|
:class="{ 'tabs-item--on': activeTab === tab.key }"
|
||||||
@tap="selectTab(tab.key)"
|
@tap="selectTab(tab.key)"
|
||||||
>
|
>
|
||||||
<text class="tab-label">{{ tab.label }}</text>
|
<text class="tabs-label">{{ tab.label }}</text>
|
||||||
<view v-if="tab.key === 'upcoming' && upcomingCount > 0" class="tab-badge">
|
<text v-if="tab.key === 'upcoming' && upcomingCount > 0" class="tabs-count">{{ upcomingCount }}</text>
|
||||||
<text class="tab-badge-text">{{ upcomingCount }}</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Upcoming tab content -->
|
|
||||||
<scroll-view
|
<scroll-view
|
||||||
v-show="activeTab === 'upcoming'"
|
v-show="activeTab === 'upcoming'"
|
||||||
class="scroll"
|
class="scroll"
|
||||||
@@ -25,57 +23,55 @@
|
|||||||
:refresher-triggered="refreshingUpcoming"
|
:refresher-triggered="refreshingUpcoming"
|
||||||
@refresherrefresh="onRefreshUpcoming"
|
@refresherrefresh="onRefreshUpcoming"
|
||||||
>
|
>
|
||||||
<!-- Loading -->
|
|
||||||
<view v-if="bookingStore.loadingBookings && !refreshingUpcoming" class="loading-wrap">
|
<view v-if="bookingStore.loadingBookings && !refreshingUpcoming" class="loading-wrap">
|
||||||
<view v-for="i in 3" :key="i" class="skeleton-card" />
|
<view v-for="i in 3" :key="i" class="skeleton-row" />
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Empty -->
|
<view v-else-if="upcomingGroups.length === 0" class="state-wrap">
|
||||||
<view v-else-if="upcomingBookings.length === 0" class="empty-wrap">
|
<text class="state-mark">—</text>
|
||||||
<text class="empty-icon">📅</text>
|
<text class="state-title">暂无即将上课的预约</text>
|
||||||
<text class="empty-title">暂无即将上课的预约</text>
|
<text class="state-copy">去课表选一个时段即可。</text>
|
||||||
<text class="empty-sub">去预约一节课吧</text>
|
<view class="state-btn" @tap="goBooking">
|
||||||
<view class="empty-btn" @tap="goBooking">
|
<text class="state-btn-text">去预约</text>
|
||||||
<text class="empty-btn-text">去预约</text>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Booking list -->
|
|
||||||
<view v-else class="list">
|
<view v-else class="list">
|
||||||
|
<view v-for="group in upcomingGroups" :key="group.key" class="day-group">
|
||||||
|
<view class="day-head">
|
||||||
|
<text class="day-label">{{ group.label }}</text>
|
||||||
|
<text class="day-count">{{ group.items.length }} 节</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
<view
|
<view
|
||||||
v-for="booking in upcomingBookings"
|
v-for="booking in group.items"
|
||||||
:key="booking.id"
|
:key="booking.id"
|
||||||
class="booking-card"
|
class="row"
|
||||||
|
@tap="goDetail(booking)"
|
||||||
>
|
>
|
||||||
<view class="booking-stripe stripe--confirmed" />
|
<view class="row-rail" :class="bookingStatusStripeClass(booking.status)" />
|
||||||
<view class="booking-content">
|
<view class="row-body">
|
||||||
<view class="booking-main">
|
<view class="row-top">
|
||||||
<view class="booking-datetime">
|
<view class="row-time-wrap">
|
||||||
<text class="booking-date">{{ formatDateDisplay(booking.timeSlot.date) }}</text>
|
<text class="row-time">{{ startTime(booking) }}</text>
|
||||||
<text class="booking-time">
|
<text class="row-end">— {{ endTime(booking) }}</text>
|
||||||
{{ booking.timeSlot.startTime.slice(0, 5) }} – {{ booking.timeSlot.endTime.slice(0, 5) }}
|
</view>
|
||||||
|
<text class="row-stamp" :class="stampClass(booking.status)">
|
||||||
|
{{ bookingStatusLabel(booking.status) }}
|
||||||
</text>
|
</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="status-badge badge--confirmed">
|
<view class="row-bottom">
|
||||||
<text class="status-text">已预约</text>
|
<text class="row-meta">{{ cardName(booking) }}</text>
|
||||||
</view>
|
<text class="row-cancel" @tap.stop="handleCancel(booking)">取消</text>
|
||||||
</view>
|
|
||||||
<view class="booking-meta">
|
|
||||||
<text class="meta-text">💳 {{ booking.membership.cardType.name }}</text>
|
|
||||||
</view>
|
|
||||||
<view class="cancel-row">
|
|
||||||
<view class="cancel-btn" @tap="handleCancel(booking)">
|
|
||||||
<text class="cancel-text">取消预约</text>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="scroll-bottom-spacer" />
|
<view class="scroll-bottom" />
|
||||||
</scroll-view>
|
</scroll-view>
|
||||||
|
|
||||||
<!-- History tab content -->
|
|
||||||
<scroll-view
|
<scroll-view
|
||||||
v-show="activeTab === 'history'"
|
v-show="activeTab === 'history'"
|
||||||
class="scroll"
|
class="scroll"
|
||||||
@@ -84,61 +80,95 @@
|
|||||||
:refresher-triggered="refreshingHistory"
|
:refresher-triggered="refreshingHistory"
|
||||||
@refresherrefresh="onRefreshHistory"
|
@refresherrefresh="onRefreshHistory"
|
||||||
>
|
>
|
||||||
<!-- Loading -->
|
<view v-if="supplements.length || supplementsError || supplementsLoading" class="supplement">
|
||||||
|
<text class="section-label">历史补录</text>
|
||||||
|
<text v-if="supplementsLoading" class="supplement-message">正在加载补录记录…</text>
|
||||||
|
<button v-else-if="supplementsError" class="supplement-retry" @tap="fetchSupplements">
|
||||||
|
补录记录加载失败,点击重试
|
||||||
|
</button>
|
||||||
|
<LessonSupplementList v-else :records="supplements" />
|
||||||
|
</view>
|
||||||
|
|
||||||
<view v-if="bookingStore.loadingBookings && !refreshingHistory" class="loading-wrap">
|
<view v-if="bookingStore.loadingBookings && !refreshingHistory" class="loading-wrap">
|
||||||
<view v-for="i in 3" :key="i" class="skeleton-card" />
|
<view v-for="i in 3" :key="i" class="skeleton-row" />
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Empty -->
|
|
||||||
<view v-else-if="historyBookings.length === 0" class="empty-wrap">
|
|
||||||
<text class="empty-icon">📋</text>
|
|
||||||
<text class="empty-title">暂无历史记录</text>
|
|
||||||
<text class="empty-sub">已完成或取消的课程将显示在这里</text>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- Booking list -->
|
|
||||||
<view v-else class="list">
|
|
||||||
<view
|
<view
|
||||||
v-for="booking in historyBookings"
|
v-else-if="historyGroups.length === 0 && !supplements.length && !supplementsLoading && !supplementsError"
|
||||||
:key="booking.id"
|
class="state-wrap"
|
||||||
class="booking-card"
|
|
||||||
>
|
>
|
||||||
<view class="booking-stripe" :class="stripeClass(booking.status)" />
|
<text class="state-mark">—</text>
|
||||||
<view class="booking-content">
|
<text class="state-title">暂无历史记录</text>
|
||||||
<view class="booking-main">
|
<text class="state-copy">上完或取消的课程会显示在这里。</text>
|
||||||
<view class="booking-datetime">
|
</view>
|
||||||
<text class="booking-date">{{ formatDateDisplay(booking.timeSlot.date) }}</text>
|
|
||||||
<text class="booking-time">
|
<view v-else-if="historyGroups.length > 0" class="list">
|
||||||
{{ booking.timeSlot.startTime.slice(0, 5) }} – {{ booking.timeSlot.endTime.slice(0, 5) }}
|
<view v-for="group in historyGroups" :key="group.key" class="day-group">
|
||||||
|
<view class="day-head">
|
||||||
|
<text class="day-label">{{ group.label }}</text>
|
||||||
|
<text class="day-count">{{ group.items.length }} 节</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view
|
||||||
|
v-for="booking in group.items"
|
||||||
|
:key="booking.id"
|
||||||
|
class="row"
|
||||||
|
:class="{ 'row--muted': isMutedStatus(booking.status) }"
|
||||||
|
@tap="goDetail(booking)"
|
||||||
|
>
|
||||||
|
<view class="row-rail" :class="bookingStatusStripeClass(booking.status)" />
|
||||||
|
<view class="row-body">
|
||||||
|
<view class="row-top">
|
||||||
|
<view class="row-time-wrap">
|
||||||
|
<text class="row-time">{{ startTime(booking) }}</text>
|
||||||
|
<text class="row-end">— {{ endTime(booking) }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="row-stamp" :class="stampClass(booking.status)">
|
||||||
|
{{ bookingStatusLabel(booking.status) }}
|
||||||
</text>
|
</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="status-badge" :class="statusBadgeClass(booking.status)">
|
<text class="row-meta">{{ historyDayLabel(booking.timeSlot.date) }} · {{ cardName(booking) }}</text>
|
||||||
<text class="status-text">{{ statusLabel(booking.status) }}</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
<view class="booking-meta">
|
|
||||||
<text class="meta-text">💳 {{ booking.membership.cardType.name }}</text>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="scroll-bottom-spacer" />
|
<view class="scroll-bottom" />
|
||||||
</scroll-view>
|
</scroll-view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import type { BookingWithDetails } from '@mp-pilates/shared'
|
import { onShow } from '@dcloudio/uni-app'
|
||||||
|
import type { BookingWithDetails, LessonSupplementRecord } from '@mp-pilates/shared'
|
||||||
import { BookingStatus } from '@mp-pilates/shared'
|
import { BookingStatus } from '@mp-pilates/shared'
|
||||||
import { useBookingStore } from '../../stores/booking'
|
import { useBookingStore } from '../../stores/booking'
|
||||||
import { formatDate, getWeekdayLabel } from '../../utils/format'
|
import { formatDate } from '../../utils/format'
|
||||||
|
import { getSystemLayout } from '../../utils/system'
|
||||||
|
import { get } from '../../utils/request'
|
||||||
|
import {
|
||||||
|
formatDateDisplay,
|
||||||
|
bookingStatusLabel,
|
||||||
|
bookingStatusStripeClass,
|
||||||
|
} from '../../utils/booking-helpers'
|
||||||
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
|
import LessonSupplementList from '../../components/LessonSupplementList.vue'
|
||||||
|
|
||||||
|
type TabKey = 'upcoming' | 'history'
|
||||||
|
|
||||||
|
interface BookingGroup {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
items: BookingWithDetails[]
|
||||||
|
}
|
||||||
|
|
||||||
const bookingStore = useBookingStore()
|
const bookingStore = useBookingStore()
|
||||||
|
const supplements = ref<LessonSupplementRecord[]>([])
|
||||||
|
const supplementsLoading = ref(false)
|
||||||
|
const supplementsError = ref(false)
|
||||||
|
|
||||||
// ─── Tab state ────────────────────────────────────────────
|
const navBarHeight = ref(`${getSystemLayout().navBarHeight}px`)
|
||||||
type TabKey = 'upcoming' | 'history'
|
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ key: 'upcoming' as TabKey, label: '即将上课' },
|
{ key: 'upcoming' as TabKey, label: '即将上课' },
|
||||||
@@ -148,83 +178,130 @@ const tabs = [
|
|||||||
const activeTab = ref<TabKey>('upcoming')
|
const activeTab = ref<TabKey>('upcoming')
|
||||||
const refreshingUpcoming = ref(false)
|
const refreshingUpcoming = ref(false)
|
||||||
const refreshingHistory = ref(false)
|
const refreshingHistory = ref(false)
|
||||||
|
const hasLoadedOnce = ref(false)
|
||||||
|
|
||||||
|
async function fetchSupplements() {
|
||||||
|
supplementsLoading.value = true
|
||||||
|
supplementsError.value = false
|
||||||
|
try {
|
||||||
|
supplements.value = await get<LessonSupplementRecord[]>('/user/lesson-supplements')
|
||||||
|
} catch {
|
||||||
|
supplementsError.value = true
|
||||||
|
} finally {
|
||||||
|
supplementsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeBookings(): readonly BookingWithDetails[] {
|
||||||
|
const raw = bookingStore.myBookings
|
||||||
|
return Array.isArray(raw) ? raw : []
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDateStr(date: string): string {
|
||||||
|
return date.slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUpcomingBooking(booking: BookingWithDetails, todayStr: string): boolean {
|
||||||
|
const upcomingStatus =
|
||||||
|
booking.status === BookingStatus.PENDING_CONFIRMATION ||
|
||||||
|
booking.status === BookingStatus.CONFIRMED
|
||||||
|
return upcomingStatus && toDateStr(booking.timeSlot.date) >= todayStr
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Filtered bookings ────────────────────────────────────
|
|
||||||
const today = computed(() => formatDate(new Date()))
|
const today = computed(() => formatDate(new Date()))
|
||||||
|
|
||||||
const upcomingBookings = computed<BookingWithDetails[]>(() => {
|
const upcomingBookings = computed<BookingWithDetails[]>(() => {
|
||||||
const all = bookingStore.myBookings as BookingWithDetails[]
|
return safeBookings()
|
||||||
return all
|
.filter((booking) => isUpcomingBooking(booking, today.value))
|
||||||
.filter(
|
|
||||||
(b) => b.status === BookingStatus.CONFIRMED && b.timeSlot.date >= today.value,
|
|
||||||
)
|
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
if (a.timeSlot.date !== b.timeSlot.date) {
|
const dateA = toDateStr(a.timeSlot.date)
|
||||||
return a.timeSlot.date.localeCompare(b.timeSlot.date)
|
const dateB = toDateStr(b.timeSlot.date)
|
||||||
}
|
if (dateA !== dateB) return dateA.localeCompare(dateB)
|
||||||
return a.timeSlot.startTime.localeCompare(b.timeSlot.startTime)
|
return a.timeSlot.startTime.localeCompare(b.timeSlot.startTime)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const historyBookings = computed<BookingWithDetails[]>(() => {
|
const historyBookings = computed<BookingWithDetails[]>(() => {
|
||||||
const all = bookingStore.myBookings as BookingWithDetails[]
|
return safeBookings()
|
||||||
return all
|
.filter((booking) => !isUpcomingBooking(booking, today.value))
|
||||||
.filter(
|
|
||||||
(b) =>
|
|
||||||
b.status !== BookingStatus.CONFIRMED ||
|
|
||||||
b.timeSlot.date < today.value,
|
|
||||||
)
|
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
if (b.timeSlot.date !== a.timeSlot.date) {
|
const dateA = toDateStr(a.timeSlot.date)
|
||||||
return b.timeSlot.date.localeCompare(a.timeSlot.date)
|
const dateB = toDateStr(b.timeSlot.date)
|
||||||
}
|
if (dateB !== dateA) return dateB.localeCompare(dateA)
|
||||||
return b.timeSlot.startTime.localeCompare(a.timeSlot.startTime)
|
return b.timeSlot.startTime.localeCompare(a.timeSlot.startTime)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const upcomingCount = computed(() => upcomingBookings.value.length)
|
const upcomingCount = computed(() => upcomingBookings.value.length)
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────
|
const upcomingGroups = computed(() =>
|
||||||
function statusLabel(status: BookingStatus): string {
|
groupBookings(
|
||||||
const map: Record<BookingStatus, string> = {
|
upcomingBookings.value,
|
||||||
[BookingStatus.CONFIRMED]: '已预约',
|
(booking) => toDateStr(booking.timeSlot.date),
|
||||||
[BookingStatus.CANCELLED]: '已取消',
|
(key) => formatDateDisplay(key),
|
||||||
[BookingStatus.COMPLETED]: '已完成',
|
),
|
||||||
[BookingStatus.NO_SHOW]: '未出席',
|
)
|
||||||
|
|
||||||
|
const historyGroups = computed(() =>
|
||||||
|
groupBookings(
|
||||||
|
historyBookings.value,
|
||||||
|
(booking) => toDateStr(booking.timeSlot.date).slice(0, 7),
|
||||||
|
(key) => {
|
||||||
|
const [year, month] = key.split('-')
|
||||||
|
return `${year}年${Number(month)}月`
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
function groupBookings(
|
||||||
|
bookings: BookingWithDetails[],
|
||||||
|
getKey: (booking: BookingWithDetails) => string,
|
||||||
|
getLabel: (key: string) => string,
|
||||||
|
): BookingGroup[] {
|
||||||
|
const map = new Map<string, BookingWithDetails[]>()
|
||||||
|
for (const booking of bookings) {
|
||||||
|
const key = getKey(booking)
|
||||||
|
const list = map.get(key)
|
||||||
|
if (list) list.push(booking)
|
||||||
|
else map.set(key, [booking])
|
||||||
}
|
}
|
||||||
return map[status] ?? status
|
return Array.from(map, ([key, items]) => ({
|
||||||
|
key,
|
||||||
|
label: getLabel(key),
|
||||||
|
items,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusBadgeClass(status: BookingStatus): string {
|
function startTime(booking: BookingWithDetails): string {
|
||||||
const map: Record<BookingStatus, string> = {
|
return booking.timeSlot.startTime.slice(0, 5)
|
||||||
[BookingStatus.CONFIRMED]: 'badge--confirmed',
|
|
||||||
[BookingStatus.CANCELLED]: 'badge--cancelled',
|
|
||||||
[BookingStatus.COMPLETED]: 'badge--completed',
|
|
||||||
[BookingStatus.NO_SHOW]: 'badge--noshow',
|
|
||||||
}
|
|
||||||
return map[status] ?? ''
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function stripeClass(status: BookingStatus): string {
|
function endTime(booking: BookingWithDetails): string {
|
||||||
const map: Record<BookingStatus, string> = {
|
return booking.timeSlot.endTime.slice(0, 5)
|
||||||
[BookingStatus.CONFIRMED]: 'stripe--confirmed',
|
|
||||||
[BookingStatus.CANCELLED]: 'stripe--cancelled',
|
|
||||||
[BookingStatus.COMPLETED]: 'stripe--completed',
|
|
||||||
[BookingStatus.NO_SHOW]: 'stripe--noshow',
|
|
||||||
}
|
|
||||||
return map[status] ?? ''
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDateDisplay(dateStr: string): string {
|
function cardName(booking: BookingWithDetails): string {
|
||||||
// e.g. "2024-03-15" → "3月15日 周五"
|
return booking.membership?.cardType?.name || '会员卡'
|
||||||
const d = new Date(dateStr)
|
}
|
||||||
const month = d.getMonth() + 1
|
|
||||||
const day = d.getDate()
|
function historyDayLabel(dateStr: string): string {
|
||||||
const weekday = getWeekdayLabel(d)
|
const [year, month, day] = toDateStr(dateStr).split('-').map(Number)
|
||||||
return `${month}月${day}日 ${weekday}`
|
const weekday = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'][new Date(year, month - 1, day).getDay()]
|
||||||
|
return `${day}日 ${weekday}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function stampClass(status: string): string {
|
||||||
|
if (status === BookingStatus.PENDING_CONFIRMATION) return 'stamp--pending'
|
||||||
|
if (status === BookingStatus.CANCELLED) return 'stamp--cancelled'
|
||||||
|
if (status === BookingStatus.NO_SHOW) return 'stamp--noshow'
|
||||||
|
if (status === BookingStatus.COMPLETED) return 'stamp--completed'
|
||||||
|
return 'stamp--confirmed'
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMutedStatus(status: string): boolean {
|
||||||
|
return status === BookingStatus.CANCELLED || status === BookingStatus.NO_SHOW
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Actions ──────────────────────────────────────────────
|
|
||||||
function selectTab(key: TabKey) {
|
function selectTab(key: TabKey) {
|
||||||
activeTab.value = key
|
activeTab.value = key
|
||||||
}
|
}
|
||||||
@@ -237,7 +314,7 @@ async function onRefreshUpcoming() {
|
|||||||
|
|
||||||
async function onRefreshHistory() {
|
async function onRefreshHistory() {
|
||||||
refreshingHistory.value = true
|
refreshingHistory.value = true
|
||||||
await bookingStore.fetchMyBookings()
|
await Promise.all([bookingStore.fetchMyBookings(), fetchSupplements()])
|
||||||
refreshingHistory.value = false
|
refreshingHistory.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,9 +322,13 @@ function goBooking() {
|
|||||||
uni.switchTab({ url: '/pages/booking/index' })
|
uni.switchTab({ url: '/pages/booking/index' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function goDetail(booking: BookingWithDetails) {
|
||||||
|
uni.navigateTo({ url: `/pages/booking/detail?id=${booking.id}` })
|
||||||
|
}
|
||||||
|
|
||||||
async function handleCancel(booking: BookingWithDetails) {
|
async function handleCancel(booking: BookingWithDetails) {
|
||||||
const dateLabel = formatDateDisplay(booking.timeSlot.date)
|
const dateLabel = formatDateDisplay(booking.timeSlot.date)
|
||||||
const timeLabel = booking.timeSlot.startTime.slice(0, 5)
|
const timeLabel = startTime(booking)
|
||||||
|
|
||||||
uni.showModal({
|
uni.showModal({
|
||||||
title: '取消预约',
|
title: '取消预约',
|
||||||
@@ -272,262 +353,322 @@ async function handleCancel(booking: BookingWithDetails) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Lifecycle ────────────────────────────────────────────
|
onMounted(() => {
|
||||||
onMounted(() => bookingStore.fetchMyBookings())
|
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
|
||||||
|
fetchSupplements()
|
||||||
|
bookingStore.fetchMyBookings().then(() => {
|
||||||
|
hasLoadedOnce.value = true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onShow(() => {
|
||||||
|
if (!hasLoadedOnce.value) return
|
||||||
|
fetchSupplements()
|
||||||
|
bookingStore.fetchMyBookings(undefined, { silent: true })
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.bookings-page {
|
.page {
|
||||||
min-height: 100vh;
|
height: 100vh;
|
||||||
background: #f5f3f0;
|
box-sizing: border-box;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #fbf9f6;
|
||||||
|
color: #514943;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Tab bar ─────────────────────────────────────────── */
|
.tabs {
|
||||||
.tab-bar {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
|
||||||
background: #fff;
|
|
||||||
border-bottom: 1rpx solid #f0ece8;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
border-bottom: 1rpx solid #eee8e3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-item {
|
.tabs-item {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
height: 88rpx;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 8rpx;
|
gap: 8rpx;
|
||||||
padding: 28rpx 0;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
&.active {
|
&:active {
|
||||||
.tab-label {
|
opacity: 0.7;
|
||||||
color: #c9a87c;
|
}
|
||||||
font-weight: 600;
|
|
||||||
|
&--on {
|
||||||
|
.tabs-label {
|
||||||
|
color: #3d3833;
|
||||||
}
|
}
|
||||||
|
|
||||||
&::after {
|
&::after {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 0;
|
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
bottom: 0;
|
||||||
width: 48rpx;
|
width: 48rpx;
|
||||||
height: 4rpx;
|
height: 4rpx;
|
||||||
background: #c9a87c;
|
|
||||||
border-radius: 2rpx;
|
border-radius: 2rpx;
|
||||||
|
background: #6b8276;
|
||||||
|
transform: translateX(-50%);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-label {
|
.tabs-label {
|
||||||
font-size: 28rpx;
|
font-size: 28rpx;
|
||||||
color: #999;
|
color: #8b817b;
|
||||||
font-weight: 400;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-badge {
|
.tabs-count {
|
||||||
min-width: 32rpx;
|
font-size: 24rpx;
|
||||||
height: 32rpx;
|
color: #6b8276;
|
||||||
border-radius: 16rpx;
|
font-variant-numeric: tabular-nums;
|
||||||
background: #ef4444;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 0 8rpx;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-badge-text {
|
|
||||||
font-size: 20rpx;
|
|
||||||
color: #fff;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Scroll ──────────────────────────────────────────── */
|
|
||||||
.scroll {
|
.scroll {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: calc(100vh - 88rpx);
|
height: 0;
|
||||||
|
min-height: 0;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Loading ─────────────────────────────────────────── */
|
|
||||||
.loading-wrap {
|
.loading-wrap {
|
||||||
padding: 24rpx;
|
padding: 28rpx 32rpx;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16rpx;
|
gap: 16rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skeleton-card {
|
.skeleton-row {
|
||||||
height: 160rpx;
|
height: 148rpx;
|
||||||
border-radius: 16rpx;
|
border-radius: 24rpx;
|
||||||
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
|
background: linear-gradient(90deg, #eee8e2 25%, #f9f6f2 50%, #eee8e2 75%);
|
||||||
background-size: 400% 100%;
|
background-size: 400% 100%;
|
||||||
animation: shimmer 1.4s infinite;
|
animation: shimmer 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-wrap {
|
||||||
|
min-height: 520rpx;
|
||||||
|
padding: 80rpx 48rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-mark {
|
||||||
|
font-size: 48rpx;
|
||||||
|
color: #c9c0b8;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-title {
|
||||||
|
font-size: 32rpx;
|
||||||
|
color: #514943;
|
||||||
|
margin-bottom: 10rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-copy {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #8b817b;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-btn {
|
||||||
|
margin-top: 32rpx;
|
||||||
|
min-width: 200rpx;
|
||||||
|
height: 80rpx;
|
||||||
|
padding: 0 40rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
background: #6b8276;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
opacity: 0.88;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-btn-text {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
padding: 24rpx 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-group {
|
||||||
|
margin: 0 32rpx 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16rpx;
|
||||||
|
padding: 4rpx 4rpx 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-label {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #6f655e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-count {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #8b817b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
border: 1rpx solid #eee8e3;
|
||||||
|
border-radius: 24rpx;
|
||||||
|
background: #fff;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--muted {
|
||||||
|
background: #f7f5f2;
|
||||||
|
|
||||||
|
.row-time {
|
||||||
|
color: #8b817b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-rail {
|
||||||
|
width: 6rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: #6b8276;
|
||||||
|
|
||||||
|
&.stripe--pending { background: #c4a064; }
|
||||||
|
&.stripe--confirmed { background: #6b8276; }
|
||||||
|
&.stripe--completed { background: #6b8276; }
|
||||||
|
&.stripe--cancelled { background: #d4ccc4; }
|
||||||
|
&.stripe--noshow { background: #c47a74; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 24rpx 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-time-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10rpx;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-time {
|
||||||
|
font-size: 44rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1;
|
||||||
|
color: #3d3833;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-family: "Songti SC", "STSong", "Noto Serif SC", serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-end {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #8b817b;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-stamp {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 22rpx;
|
||||||
|
letter-spacing: 1rpx;
|
||||||
|
color: #5a6f65;
|
||||||
|
|
||||||
|
&--pending { color: #9b6e22; }
|
||||||
|
&--confirmed,
|
||||||
|
&--completed { color: #5a6f65; }
|
||||||
|
&--cancelled { color: #8b817b; }
|
||||||
|
&--noshow { color: #b45a54; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-bottom {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16rpx;
|
||||||
|
margin-top: 14rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-meta {
|
||||||
|
display: block;
|
||||||
|
margin-top: 12rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #8b817b;
|
||||||
|
line-height: 1.4;
|
||||||
|
|
||||||
|
.row-bottom & {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-cancel {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 8rpx 0 0 20rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #8b817b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.supplement {
|
||||||
|
padding: 24rpx 32rpx 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #8b817b;
|
||||||
|
letter-spacing: 2rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.supplement-message,
|
||||||
|
.supplement-retry {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #8b817b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.supplement-retry {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 24rpx;
|
||||||
|
line-height: 1.6;
|
||||||
|
background: #f3eee8;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-bottom {
|
||||||
|
height: 40rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes shimmer {
|
@keyframes shimmer {
|
||||||
0% { background-position: 100% 0; }
|
0% { background-position: 100% 0; }
|
||||||
100% { background-position: -100% 0; }
|
100% { background-position: -100% 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Empty ───────────────────────────────────────────── */
|
|
||||||
.empty-wrap {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 120rpx 40rpx;
|
|
||||||
gap: 20rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-icon {
|
|
||||||
font-size: 80rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-title {
|
|
||||||
font-size: 32rpx;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-sub {
|
|
||||||
font-size: 26rpx;
|
|
||||||
color: #999;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-btn {
|
|
||||||
margin-top: 12rpx;
|
|
||||||
padding: 20rpx 56rpx;
|
|
||||||
border-radius: 44rpx;
|
|
||||||
background: #c9a87c;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-btn-text {
|
|
||||||
font-size: 30rpx;
|
|
||||||
color: #fff;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── List ────────────────────────────────────────────── */
|
|
||||||
.list {
|
|
||||||
padding: 24rpx 24rpx 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Booking card ────────────────────────────────────── */
|
|
||||||
.booking-card {
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 16rpx;
|
|
||||||
overflow: hidden;
|
|
||||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Colored left stripe */
|
|
||||||
.booking-stripe {
|
|
||||||
width: 8rpx;
|
|
||||||
flex-shrink: 0;
|
|
||||||
|
|
||||||
&.stripe--confirmed { background: #c9a87c; }
|
|
||||||
&.stripe--completed { background: #4caf50; }
|
|
||||||
&.stripe--cancelled { background: #e0e0e0; }
|
|
||||||
&.stripe--noshow { background: #ef4444; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.booking-content {
|
|
||||||
flex: 1;
|
|
||||||
padding: 24rpx 24rpx 24rpx 20rpx;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 12rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.booking-main {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.booking-datetime {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.booking-date {
|
|
||||||
font-size: 28rpx;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #1a1a1a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.booking-time {
|
|
||||||
font-size: 24rpx;
|
|
||||||
color: #888;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Status badge */
|
|
||||||
.status-badge {
|
|
||||||
padding: 8rpx 18rpx;
|
|
||||||
border-radius: 20rpx;
|
|
||||||
flex-shrink: 0;
|
|
||||||
|
|
||||||
&.badge--confirmed { background: #fff8ee; }
|
|
||||||
&.badge--completed { background: #f0faf3; }
|
|
||||||
&.badge--cancelled { background: #f5f5f5; }
|
|
||||||
&.badge--noshow { background: #fef0f0; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-text {
|
|
||||||
font-size: 22rpx;
|
|
||||||
font-weight: 600;
|
|
||||||
|
|
||||||
.badge--confirmed & { color: #c9a87c; }
|
|
||||||
.badge--completed & { color: #4caf50; }
|
|
||||||
.badge--cancelled & { color: #bbb; }
|
|
||||||
.badge--noshow & { color: #ef4444; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Meta info */
|
|
||||||
.booking-meta {
|
|
||||||
.meta-text {
|
|
||||||
font-size: 24rpx;
|
|
||||||
color: #999;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Cancel row */
|
|
||||||
.cancel-row {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
margin-top: 4rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cancel-btn {
|
|
||||||
padding: 10rpx 24rpx;
|
|
||||||
border-radius: 24rpx;
|
|
||||||
border: 1rpx solid #ef444430;
|
|
||||||
background: #fef0f0;
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
opacity: 0.75;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.cancel-text {
|
|
||||||
font-size: 24rpx;
|
|
||||||
color: #ef4444;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Spacer ──────────────────────────────────────────── */
|
|
||||||
.scroll-bottom-spacer {
|
|
||||||
height: 48rpx;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,24 +1,25 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="profile-page">
|
<view class="profile-page" :style="{ paddingTop: navBarHeight + 'px' }">
|
||||||
|
<view class="profile-page__nav" :style="{ paddingTop: statusBarHeight + 'px' }">
|
||||||
|
<view class="profile-page__nav-title">我的</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<!-- User card -->
|
<!-- User card -->
|
||||||
<UserCard
|
<UserCard :logged-in="loggedIn" :has-profile="hasProfile" :user="user" :stats="stats" :memberships="memberships"
|
||||||
:logged-in="loggedIn"
|
:loading="loginLoading" @login="handleLogin" @edit="goToInfo" />
|
||||||
:has-profile="hasProfile"
|
|
||||||
:user="user"
|
|
||||||
:stats="stats"
|
|
||||||
:memberships="memberships"
|
|
||||||
:loading="loginLoading"
|
|
||||||
@login="handleLogin"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Menu section: always visible -->
|
<!-- Menu section: always visible -->
|
||||||
<ProfileMenu
|
<ProfileMenu
|
||||||
:is-admin="isAdmin"
|
:is-admin="isAdmin"
|
||||||
:require-auth="loggedIn"
|
:require-auth="loggedIn"
|
||||||
|
:active-membership-count="activeMembershipCount"
|
||||||
|
:upcoming-booking-count="upcomingBookingCount"
|
||||||
|
:invite-share-eligible="!!user?.inviteShareEligible"
|
||||||
@clear-cache="handleClearCache"
|
@clear-cache="handleClearCache"
|
||||||
@about="handleAbout"
|
|
||||||
@require-login="handleLogin"
|
@require-login="handleLogin"
|
||||||
/>
|
>
|
||||||
|
<PracticeActivityCard v-if="loggedIn" :key="userStore.token" :refresh-key="activityRefreshKey" />
|
||||||
|
</ProfileMenu>
|
||||||
|
|
||||||
<!-- Logout button: only when logged in -->
|
<!-- Logout button: only when logged in -->
|
||||||
<view v-if="loggedIn" class="profile-page__logout-wrap">
|
<view v-if="loggedIn" class="profile-page__logout-wrap">
|
||||||
@@ -28,24 +29,63 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { onShow } from '@dcloudio/uni-app'
|
import { onShow, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { useUserStore } from '../../stores/user'
|
import { useUserStore } from '../../stores/user'
|
||||||
|
import { useBookingStore } from '../../stores/booking'
|
||||||
|
import { getSystemLayout } from '../../utils/system'
|
||||||
|
import { getErrorMessage } from '../../utils/auth'
|
||||||
|
import PracticeActivityCard from '../../components/PracticeActivityCard.vue'
|
||||||
import UserCard from '../../components/UserCard.vue'
|
import UserCard from '../../components/UserCard.vue'
|
||||||
import ProfileMenu from '../../components/ProfileMenu.vue'
|
import ProfileMenu from '../../components/ProfileMenu.vue'
|
||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
const bookingStore = useBookingStore()
|
||||||
const { loggedIn, hasProfile, user, stats, memberships, isAdmin } = storeToRefs(userStore)
|
const { loggedIn, hasProfile, user, stats, memberships, isAdmin } = storeToRefs(userStore)
|
||||||
|
const { upcomingBookings } = storeToRefs(bookingStore)
|
||||||
|
|
||||||
|
const activityRefreshKey = ref(0)
|
||||||
const loginLoading = ref(false)
|
const loginLoading = ref(false)
|
||||||
|
const navBarHeight = ref(getSystemLayout().navBarHeight)
|
||||||
|
const statusBarHeight = getSystemLayout().statusBarHeight
|
||||||
|
|
||||||
|
const activeMembershipCount = computed(
|
||||||
|
() => user.value?.activeMembershipCount ?? userStore.activeMemberships.length,
|
||||||
|
)
|
||||||
|
|
||||||
|
const upcomingBookingCount = computed(
|
||||||
|
() => (loggedIn.value ? upcomingBookings.value.length : 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
// ─── 微信分享 ───────────────────────────────────────────────
|
||||||
|
onShareAppMessage(() => {
|
||||||
|
return {
|
||||||
|
title: '我的普拉提会所,记录每一次进步',
|
||||||
|
path: '/pages/profile/index',
|
||||||
|
imageUrl: '',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onShareTimeline(() => {
|
||||||
|
return {
|
||||||
|
title: '我的普拉提会所,记录每一次进步',
|
||||||
|
query: '',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
navBarHeight.value = getSystemLayout().navBarHeight
|
||||||
|
})
|
||||||
|
|
||||||
onShow(async () => {
|
onShow(async () => {
|
||||||
|
activityRefreshKey.value += 1
|
||||||
if (loggedIn.value) {
|
if (loggedIn.value) {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
userStore.fetchProfile(),
|
userStore.fetchProfile(),
|
||||||
userStore.fetchStats(),
|
userStore.fetchStats(),
|
||||||
userStore.fetchMemberships(),
|
userStore.fetchMemberships(),
|
||||||
|
bookingStore.fetchUpcomingBookings(),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -54,25 +94,30 @@ async function handleLogin() {
|
|||||||
if (loginLoading.value) return
|
if (loginLoading.value) return
|
||||||
loginLoading.value = true
|
loginLoading.value = true
|
||||||
try {
|
try {
|
||||||
await userStore.login()
|
const { isNewUser } = await userStore.loginWithSetup()
|
||||||
|
if (!isNewUser) {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
userStore.fetchProfile(),
|
|
||||||
userStore.fetchStats(),
|
userStore.fetchStats(),
|
||||||
userStore.fetchMemberships(),
|
bookingStore.fetchUpcomingBookings(),
|
||||||
])
|
])
|
||||||
} catch {
|
}
|
||||||
uni.showToast({ title: '登录失败,请重试', icon: 'none' })
|
} catch (err: unknown) {
|
||||||
|
uni.showToast({ title: getErrorMessage(err, '登录失败,请重试'), icon: 'none' })
|
||||||
} finally {
|
} finally {
|
||||||
loginLoading.value = false
|
loginLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function goToInfo() {
|
||||||
|
uni.navigateTo({ url: '/pages/profile/info' })
|
||||||
|
}
|
||||||
|
|
||||||
function handleLogout() {
|
function handleLogout() {
|
||||||
uni.showModal({
|
uni.showModal({
|
||||||
title: '退出登录',
|
title: '退出登录',
|
||||||
content: '确定要退出登录吗?',
|
content: '确定要退出登录吗?',
|
||||||
confirmText: '退出',
|
confirmText: '退出',
|
||||||
confirmColor: '#ff4d4f',
|
confirmColor: '#9c7a6e',
|
||||||
success(res) {
|
success(res) {
|
||||||
if (res.confirm) {
|
if (res.confirm) {
|
||||||
userStore.logout()
|
userStore.logout()
|
||||||
@@ -94,39 +139,14 @@ function handleClearCache() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleAbout() {
|
|
||||||
uni.showModal({
|
|
||||||
title: '关于我们',
|
|
||||||
content: 'Focus Core 普拉提工作室\n版本 1.0.0\n\n专注核心,遇见更好的自己',
|
|
||||||
showCancel: false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.profile-page {
|
.profile-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh; box-sizing: border-box; background: #fbf9f6; padding-bottom: 36rpx;
|
||||||
background: $bg-page;
|
&__nav { position: fixed; top: 0; left: 0; right: 0; z-index: 101; background: #fbf9f6; }
|
||||||
|
&__nav-title { height: 88rpx; display: flex; align-items: center; justify-content: center; font-size: 34rpx; font-weight: 500; color: #514943; }
|
||||||
&__logout-wrap {
|
&__logout-wrap { margin: 28rpx 32rpx 0; }
|
||||||
margin: $spacing-xl $spacing-lg $spacing-xl;
|
&__logout-btn { width: 100%; height: 80rpx; line-height: 80rpx; padding: 0; margin: 0; border: none; border-radius: 24rpx; background: #f2ede7; color: #91766a; font-size: 25rpx; font-weight: 400; &::after { border: none; } }
|
||||||
}
|
|
||||||
|
|
||||||
&__logout-btn {
|
|
||||||
width: 100%;
|
|
||||||
height: 88rpx;
|
|
||||||
line-height: 88rpx;
|
|
||||||
background: $bg-card;
|
|
||||||
color: $error-color;
|
|
||||||
font-size: 30rpx;
|
|
||||||
font-weight: 500;
|
|
||||||
border: none;
|
|
||||||
border-radius: $radius-lg;
|
|
||||||
text-align: center;
|
|
||||||
|
|
||||||
&::after {
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,92 +1,82 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="info-page">
|
<view class="info-page" :style="{ paddingTop: navBarHeight }">
|
||||||
<!-- Avatar section -->
|
<CustomNavBar :title="isFromLogin ? '完善个人信息' : '个人信息'" :show-back="!isFromLogin" />
|
||||||
|
<view v-if="isFromLogin" class="welcome-banner">
|
||||||
|
<text class="welcome-title">欢迎来到工作室</text>
|
||||||
|
<text class="welcome-desc">设置头像和昵称,方便老师认识你。</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
<view class="avatar-section">
|
<view class="avatar-section">
|
||||||
<button class="avatar-btn" open-type="chooseAvatar" @chooseavatar="handleChooseAvatar">
|
<button class="avatar-btn" open-type="chooseAvatar" :disabled="busy" @chooseavatar="handleChooseAvatar">
|
||||||
<view class="avatar-wrap">
|
<view class="avatar-wrap">
|
||||||
<image
|
<image v-if="displayAvatarUrl" class="avatar" :src="displayAvatarUrl" mode="aspectFill" @error="avatarFailed = true" />
|
||||||
v-if="avatarUrl"
|
<view v-else class="avatar-placeholder"><text>{{ nicknameInitial }}</text></view>
|
||||||
class="avatar"
|
|
||||||
:src="avatarUrl"
|
|
||||||
mode="aspectFill"
|
|
||||||
/>
|
|
||||||
<view v-else class="avatar-placeholder">
|
|
||||||
<text class="avatar-placeholder-text">{{ nicknameInitial }}</text>
|
|
||||||
</view>
|
</view>
|
||||||
|
<view class="avatar-copy">
|
||||||
|
<text class="avatar-title">头像</text>
|
||||||
|
<text class="avatar-action">{{ uploadingAvatar ? '正在更新…' : '点击更换' }}</text>
|
||||||
</view>
|
</view>
|
||||||
|
<text class="avatar-arrow">›</text>
|
||||||
</button>
|
</button>
|
||||||
<text class="avatar-name">{{ form.nickname || '未设置昵称' }}</text>
|
<text class="avatar-hint">头像更换后自动保存</text>
|
||||||
<text class="avatar-hint">微信头像</text>
|
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Form fields -->
|
<view class="form-section">
|
||||||
|
<text class="section-title">基本资料</text>
|
||||||
<view class="form-card">
|
<view class="form-card">
|
||||||
<!-- Nickname (editable) -->
|
<view class="nickname-field">
|
||||||
<view class="form-row">
|
<view class="field-heading">
|
||||||
<text class="form-label">昵称</text>
|
<text class="form-label">昵称</text>
|
||||||
<input
|
<text class="field-count">{{ form.nickname.length }} / 20</text>
|
||||||
class="form-input"
|
|
||||||
type="nickname"
|
|
||||||
v-model="form.nickname"
|
|
||||||
placeholder="点击右侧按钮获取微信昵称"
|
|
||||||
placeholder-style="color: #ccc"
|
|
||||||
maxlength="20"
|
|
||||||
:disabled="saving"
|
|
||||||
/>
|
|
||||||
<text class="form-arrow">›</text>
|
|
||||||
</view>
|
</view>
|
||||||
|
<input class="form-input" type="nickname" v-model="form.nickname" placeholder="输入昵称,或选择微信昵称"
|
||||||
<!-- Phone -->
|
placeholder-style="color: #a2978e" maxlength="20" :disabled="busy" :cursor-spacing="24" />
|
||||||
<view class="form-row form-row--last">
|
</view>
|
||||||
|
<view v-if="!isFromLogin" class="form-row">
|
||||||
<text class="form-label">手机号</text>
|
<text class="form-label">手机号</text>
|
||||||
|
|
||||||
<!-- Phone set: display masked -->
|
|
||||||
<text v-if="hasPhone" class="form-value">{{ phoneDisplay }}</text>
|
<text v-if="hasPhone" class="form-value">{{ phoneDisplay }}</text>
|
||||||
|
<button v-else class="bind-phone-btn" open-type="getPhoneNumber" :disabled="busy" :loading="bindingPhone" @getphonenumber="handleGetPhone">绑定手机号</button>
|
||||||
<!-- Phone not set: bind button -->
|
</view>
|
||||||
<button
|
|
||||||
v-else
|
|
||||||
class="bind-phone-btn"
|
|
||||||
open-type="getPhoneNumber"
|
|
||||||
@getphonenumber="handleGetPhone"
|
|
||||||
>
|
|
||||||
<text class="bind-phone-text">绑定手机号</text>
|
|
||||||
</button>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Read-only info card -->
|
<view v-if="!isFromLogin" class="info-card">
|
||||||
<view class="info-card">
|
<view class="info-row"><text class="info-label">注册时间</text><text class="info-value">{{ joinDateDisplay }}</text></view>
|
||||||
<view class="info-row">
|
<view class="info-row"><text class="info-label">有效会员卡</text><text class="info-value">{{ activeMembershipCount }} 张</text></view>
|
||||||
<text class="info-label">注册时间</text>
|
|
||||||
<text class="info-value">{{ joinDateDisplay }}</text>
|
|
||||||
</view>
|
|
||||||
<view class="info-row info-row--last">
|
|
||||||
<text class="info-label">会员卡数量</text>
|
|
||||||
<text class="info-value">{{ activeMembershipCount }} 张有效</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Save button -->
|
|
||||||
<view class="save-wrap">
|
<view class="save-wrap">
|
||||||
<view
|
<button class="save-btn" :disabled="saveDisabled" :loading="saving" @tap="handleSave">
|
||||||
class="save-btn"
|
{{ saving ? '保存中…' : isFromLogin ? '保存并进入' : '保存修改' }}
|
||||||
:class="{ 'save-btn--loading': saving, 'save-btn--disabled': !isDirty || saving }"
|
</button>
|
||||||
@tap="handleSave"
|
<text v-if="!isFromLogin" class="save-hint">{{ isDirty ? '昵称修改后,请点击保存' : '修改昵称后即可保存' }}</text>
|
||||||
>
|
<button v-if="isFromLogin" class="skip-btn" :disabled="busy" @tap="handleSkip">稍后再说</button>
|
||||||
<text class="save-btn-text">{{ saving ? '保存中...' : '保存修改' }}</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
|
import { onLoad } from '@dcloudio/uni-app'
|
||||||
import { useUserStore } from '../../stores/user'
|
import { useUserStore } from '../../stores/user'
|
||||||
import { wxBindPhone } from '../../utils/auth'
|
import { wxBindPhone } from '../../utils/auth'
|
||||||
|
import { getSystemLayout } from '../../utils/system'
|
||||||
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
|
|
||||||
|
const TOAST_DISPLAY_MS = 1200
|
||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
|
||||||
|
// ─── Route params ────────────────────────────────────────
|
||||||
|
const isFromLogin = ref(false)
|
||||||
|
|
||||||
|
onLoad((query) => {
|
||||||
|
isFromLogin.value = query?.from === 'login'
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── Nav bar height ──────────────────────────────────────
|
||||||
|
const navBarHeight = ref(`${getSystemLayout().navBarHeight}px`)
|
||||||
|
|
||||||
// ─── Form state ───────────────────────────────────────────
|
// ─── Form state ───────────────────────────────────────────
|
||||||
const form = ref({
|
const form = ref({
|
||||||
nickname: '',
|
nickname: '',
|
||||||
@@ -94,6 +84,11 @@ const form = ref({
|
|||||||
const originalNickname = ref('')
|
const originalNickname = ref('')
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const uploadingAvatar = ref(false)
|
const uploadingAvatar = ref(false)
|
||||||
|
const bindingPhone = ref(false)
|
||||||
|
const initializing = ref(true)
|
||||||
|
const avatarFailed = ref(false)
|
||||||
|
const busy = computed(() => saving.value || uploadingAvatar.value || bindingPhone.value || initializing.value)
|
||||||
|
const saveDisabled = computed(() => busy.value || (!isFromLogin.value && !isDirty.value))
|
||||||
|
|
||||||
// ─── Computed ─────────────────────────────────────────────
|
// ─── Computed ─────────────────────────────────────────────
|
||||||
const isDirty = computed(() => form.value.nickname.trim() !== originalNickname.value)
|
const isDirty = computed(() => form.value.nickname.trim() !== originalNickname.value)
|
||||||
@@ -125,15 +120,19 @@ const activeMembershipCount = computed(
|
|||||||
() => userStore.user?.activeMembershipCount ?? userStore.activeMemberships.length,
|
() => userStore.user?.activeMembershipCount ?? userStore.activeMemberships.length,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Use a local fallback when no avatar is set or the remote image fails.
|
||||||
|
const displayAvatarUrl = computed(() => avatarFailed.value ? '' : avatarUrl.value)
|
||||||
|
watch(avatarUrl, () => { avatarFailed.value = false })
|
||||||
|
|
||||||
// ─── Avatar upload ────────────────────────────────────────
|
// ─── Avatar upload ────────────────────────────────────────
|
||||||
async function handleChooseAvatar(e: { detail: { avatarUrl: string } }) {
|
async function handleChooseAvatar(e: { detail: { avatarUrl: string } }) {
|
||||||
const { avatarUrl } = e.detail
|
const { avatarUrl } = e.detail
|
||||||
if (!avatarUrl) return
|
if (!avatarUrl || busy.value) return
|
||||||
|
|
||||||
uploadingAvatar.value = true
|
uploadingAvatar.value = true
|
||||||
try {
|
try {
|
||||||
await userStore.updateProfile({ avatarUrl })
|
await userStore.updateProfile({ avatarUrl })
|
||||||
await userStore.fetchProfile()
|
avatarFailed.value = false
|
||||||
uni.showToast({ title: '头像更新成功', icon: 'success' })
|
uni.showToast({ title: '头像更新成功', icon: 'success' })
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const msg = err instanceof Error ? err.message : '更新失败,请重试'
|
const msg = err instanceof Error ? err.message : '更新失败,请重试'
|
||||||
@@ -147,32 +146,35 @@ async function handleChooseAvatar(e: { detail: { avatarUrl: string } }) {
|
|||||||
async function handleGetPhone(e: {
|
async function handleGetPhone(e: {
|
||||||
detail: { encryptedData: string; iv: string; errMsg: string }
|
detail: { encryptedData: string; iv: string; errMsg: string }
|
||||||
}) {
|
}) {
|
||||||
|
if (busy.value) return
|
||||||
if (e.detail.errMsg !== 'getPhoneNumber:ok') {
|
if (e.detail.errMsg !== 'getPhoneNumber:ok') {
|
||||||
// User denied or cancelled
|
// User denied or cancelled
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
bindingPhone.value = true
|
||||||
uni.showLoading({ title: '绑定中...' })
|
uni.showLoading({ title: '绑定中...' })
|
||||||
try {
|
try {
|
||||||
const updated = await wxBindPhone(e as Parameters<typeof wxBindPhone>[0])
|
await wxBindPhone(e as Parameters<typeof wxBindPhone>[0])
|
||||||
// Refresh store with updated profile
|
// Refresh store with updated profile
|
||||||
await userStore.fetchProfile()
|
await userStore.fetchProfile()
|
||||||
uni.hideLoading()
|
uni.hideLoading()
|
||||||
uni.showToast({ title: '手机号绑定成功', icon: 'success' })
|
uni.showToast({ title: '手机号绑定成功', icon: 'success' })
|
||||||
// Sync nickname from updated profile
|
// Keep the local nickname draft while refreshing the phone information.
|
||||||
if (updated.nickname) {
|
|
||||||
form.value = { nickname: updated.nickname }
|
|
||||||
originalNickname.value = updated.nickname
|
|
||||||
}
|
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
uni.hideLoading()
|
uni.hideLoading()
|
||||||
const msg = err instanceof Error ? err.message : '绑定失败,请重试'
|
const msg = err instanceof Error ? err.message : '绑定失败,请重试'
|
||||||
uni.showToast({ title: msg, icon: 'none' })
|
uni.showToast({ title: msg, icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
bindingPhone.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Save ─────────────────────────────────────────────────
|
// ─── Save ─────────────────────────────────────────────────
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
if (!isDirty.value || saving.value) return
|
if (busy.value) return
|
||||||
|
|
||||||
|
// In first-login mode, allow saving even if not dirty (user may just want to proceed)
|
||||||
|
if (!isFromLogin.value && !isDirty.value) return
|
||||||
|
|
||||||
const nickname = form.value.nickname.trim()
|
const nickname = form.value.nickname.trim()
|
||||||
if (!nickname) {
|
if (!nickname) {
|
||||||
@@ -189,7 +191,15 @@ async function handleSave() {
|
|||||||
await userStore.updateProfile({ nickname })
|
await userStore.updateProfile({ nickname })
|
||||||
originalNickname.value = nickname
|
originalNickname.value = nickname
|
||||||
form.value = { nickname }
|
form.value = { nickname }
|
||||||
|
|
||||||
|
if (isFromLogin.value) {
|
||||||
|
uni.showToast({ title: '欢迎加入!', icon: 'success' })
|
||||||
|
setTimeout(() => {
|
||||||
|
uni.navigateBack()
|
||||||
|
}, TOAST_DISPLAY_MS)
|
||||||
|
} else {
|
||||||
uni.showToast({ title: '保存成功', icon: 'success' })
|
uni.showToast({ title: '保存成功', icon: 'success' })
|
||||||
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const msg = err instanceof Error ? err.message : '保存失败,请重试'
|
const msg = err instanceof Error ? err.message : '保存失败,请重试'
|
||||||
uni.showToast({ title: msg, icon: 'none' })
|
uni.showToast({ title: msg, icon: 'none' })
|
||||||
@@ -198,228 +208,77 @@ async function handleSave() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Skip (first-login only) ─────────────────────────────
|
||||||
|
function handleSkip() {
|
||||||
|
if (busy.value) return
|
||||||
|
uni.showModal({
|
||||||
|
title: '确认跳过?',
|
||||||
|
content: '完善头像和昵称可以让教练和伙伴更容易认识你',
|
||||||
|
confirmText: '去完善',
|
||||||
|
cancelText: '跳过',
|
||||||
|
success(res) {
|
||||||
|
if (!res.confirm) {
|
||||||
|
uni.navigateBack()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Lifecycle ────────────────────────────────────────────
|
// ─── Lifecycle ────────────────────────────────────────────
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
|
||||||
|
try {
|
||||||
|
if (!isFromLogin.value) {
|
||||||
await userStore.fetchProfile()
|
await userStore.fetchProfile()
|
||||||
|
}
|
||||||
if (userStore.user) {
|
if (userStore.user) {
|
||||||
form.value = { nickname: userStore.user.nickname }
|
form.value = { nickname: userStore.user.nickname }
|
||||||
originalNickname.value = userStore.user.nickname
|
originalNickname.value = userStore.user.nickname
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
initializing.value = false
|
||||||
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.info-page {
|
.info-page { min-height: 100vh; box-sizing: border-box; padding-bottom: calc(32rpx + env(safe-area-inset-bottom)); background: #fbf9f6; color: #514943; }
|
||||||
min-height: 100vh;
|
.welcome-banner { margin: 28rpx 32rpx 0; }
|
||||||
background: #f5f3f0;
|
.welcome-title { display: block; font-size: 32rpx; font-weight: 500; }
|
||||||
}
|
.welcome-desc { display: block; margin-top: 12rpx; font-size: 24rpx; color: #8b817b; line-height: 1.7; }
|
||||||
|
.avatar-section { margin: 28rpx 32rpx 0; padding: 28rpx; border-radius: 28rpx; background: #f2e9e3; }
|
||||||
/* ── Avatar section ──────────────────────────────────── */
|
|
||||||
.avatar-section {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
padding: 56rpx 0 40rpx;
|
|
||||||
background: #fff;
|
|
||||||
margin-bottom: 24rpx;
|
|
||||||
border-bottom: 1rpx solid #f0ece8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatar-btn {
|
.avatar-btn {
|
||||||
background: transparent;
|
display: flex; align-items: center; gap: 24rpx; width: 100%; min-width: 0;
|
||||||
border: none;
|
height: auto; margin: 0; padding: 0; border: none; background: transparent;
|
||||||
padding: 0;
|
border-radius: 0; line-height: 1.4; text-align: left; color: #514943;
|
||||||
margin: 0;
|
&::after { border: none; }
|
||||||
|
&[disabled] { background: transparent; color: #8b817b; }
|
||||||
&::after {
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatar-wrap {
|
|
||||||
position: relative;
|
|
||||||
width: 160rpx;
|
|
||||||
height: 160rpx;
|
|
||||||
margin-bottom: 16rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatar {
|
|
||||||
width: 160rpx;
|
|
||||||
height: 160rpx;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: 4rpx solid #f0f0f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatar-placeholder {
|
|
||||||
width: 160rpx;
|
|
||||||
height: 160rpx;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: linear-gradient(135deg, #c9a87c, #e8c88a);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatar-placeholder-text {
|
|
||||||
font-size: 64rpx;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatar-name {
|
|
||||||
font-size: 34rpx;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #1a1a1a;
|
|
||||||
margin-bottom: 6rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatar-hint {
|
|
||||||
font-size: 22rpx;
|
|
||||||
color: #bbb;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Form card ───────────────────────────────────────── */
|
|
||||||
.form-card {
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 20rpx;
|
|
||||||
margin: 0 24rpx 20rpx;
|
|
||||||
overflow: hidden;
|
|
||||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-row {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
padding: 32rpx 28rpx;
|
|
||||||
border-bottom: 1rpx solid #f5f5f5;
|
|
||||||
min-height: 100rpx;
|
|
||||||
|
|
||||||
&--last {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-label {
|
|
||||||
font-size: 28rpx;
|
|
||||||
color: #555;
|
|
||||||
width: 120rpx;
|
|
||||||
flex-shrink: 0;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-input {
|
|
||||||
flex: 1;
|
|
||||||
font-size: 28rpx;
|
|
||||||
color: #222;
|
|
||||||
text-align: right;
|
|
||||||
background: transparent;
|
|
||||||
min-height: 44rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-value {
|
|
||||||
flex: 1;
|
|
||||||
font-size: 28rpx;
|
|
||||||
color: #888;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-arrow {
|
|
||||||
font-size: 36rpx;
|
|
||||||
color: #ccc;
|
|
||||||
margin-left: 8rpx;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Bind phone button (styled, not default wx button) */
|
|
||||||
.bind-phone-btn {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
align-items: center;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
line-height: normal;
|
|
||||||
|
|
||||||
/* reset uni button default styles */
|
|
||||||
&::after {
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.bind-phone-text {
|
|
||||||
font-size: 26rpx;
|
|
||||||
color: #c9a87c;
|
|
||||||
font-weight: 600;
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Read-only info card ──────────────────────────────── */
|
|
||||||
.info-card {
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 20rpx;
|
|
||||||
margin: 0 24rpx 32rpx;
|
|
||||||
overflow: hidden;
|
|
||||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-row {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 28rpx 28rpx;
|
|
||||||
border-bottom: 1rpx solid #f5f5f5;
|
|
||||||
|
|
||||||
&--last {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-label {
|
|
||||||
font-size: 26rpx;
|
|
||||||
color: #999;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-value {
|
|
||||||
font-size: 26rpx;
|
|
||||||
color: #555;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Save button ─────────────────────────────────────── */
|
|
||||||
.save-wrap {
|
|
||||||
padding: 8rpx 24rpx 48rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.save-btn {
|
|
||||||
width: 100%;
|
|
||||||
height: 96rpx;
|
|
||||||
border-radius: 48rpx;
|
|
||||||
background: linear-gradient(90deg, #1a1a2e, #2d2d5e);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
box-shadow: 0 4rpx 20rpx rgba(26, 26, 46, 0.3);
|
|
||||||
transition: opacity 0.2s;
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
opacity: 0.85;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--loading,
|
|
||||||
&--disabled {
|
|
||||||
opacity: 0.45;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.save-btn-text {
|
|
||||||
font-size: 32rpx;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #c9a87c;
|
|
||||||
letter-spacing: 2rpx;
|
|
||||||
}
|
}
|
||||||
|
.avatar-wrap { width: 120rpx; height: 120rpx; flex-shrink: 0; border-radius: 50%; overflow: hidden; box-sizing: border-box; border: 5rpx solid #fcf8f4; }
|
||||||
|
.avatar, .avatar-placeholder { display: block; width: 100%; height: 100%; }
|
||||||
|
.avatar-placeholder { display: flex; align-items: center; justify-content: center; background: #e0cec4; color: #7b6254; font-size: 44rpx; font-weight: 400; line-height: 1; }
|
||||||
|
.avatar-copy { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 12rpx; }
|
||||||
|
.avatar-title { font-size: 30rpx; font-weight: 500; }
|
||||||
|
.avatar-action { font-size: 24rpx; color: #8b7160; }
|
||||||
|
.avatar-arrow { flex-shrink: 0; font-size: 32rpx; color: #a38d7d; }
|
||||||
|
.avatar-hint { display: block; margin-top: 20rpx; font-size: 21rpx; color: #9a8577; }
|
||||||
|
.form-section { margin: 32rpx 32rpx 0; }
|
||||||
|
.section-title { display: block; margin-bottom: 18rpx; font-size: 28rpx; font-weight: 500; }
|
||||||
|
.form-card { padding: 0 28rpx; background: #fff; border: 1rpx solid #eee8e3; border-radius: 28rpx; }
|
||||||
|
.nickname-field { padding: 26rpx 0 20rpx; }
|
||||||
|
.field-heading { display: flex; align-items: center; justify-content: space-between; gap: 20rpx; }
|
||||||
|
.form-label { flex-shrink: 0; font-size: 26rpx; color: #786b61; }
|
||||||
|
.field-count { font-size: 20rpx; color: #a2978e; }
|
||||||
|
.form-input { display: block; box-sizing: border-box; width: 100%; min-width: 0; height: 76rpx; margin-top: 12rpx; padding: 0 18rpx; border-radius: 14rpx; background: #faf8f5; font-size: 27rpx; color: #514943; line-height: normal; text-align: left; }
|
||||||
|
.form-row { display: flex; align-items: center; gap: 24rpx; min-height: 104rpx; border-top: 1rpx solid #f0ebe6; }
|
||||||
|
.form-value { flex: 1; min-width: 0; text-align: right; font-size: 26rpx; color: #6f655e; }
|
||||||
|
.bind-phone-btn { margin: 0 0 0 auto; padding: 0 20rpx; height: 60rpx; line-height: 60rpx; flex-shrink: 0; border: none; border-radius: 999rpx; background: #edf2ec; color: #617d73; font-size: 24rpx; &::after { border: none; } }
|
||||||
|
.info-card { margin: 24rpx 32rpx 0; padding: 8rpx 28rpx; border-radius: 26rpx; background: #f3f0eb; }
|
||||||
|
.info-row { display: flex; align-items: baseline; justify-content: space-between; gap: 20rpx; padding: 18rpx 0; }
|
||||||
|
.info-label { flex-shrink: 0; font-size: 23rpx; color: #8b817b; }
|
||||||
|
.info-value { font-size: 23rpx; color: #786b61; text-align: right; }
|
||||||
|
.save-wrap { margin: 32rpx 32rpx 0; }
|
||||||
|
.save-btn { display: block; width: 100%; height: 88rpx; line-height: 88rpx; margin: 0; padding: 0; border: none; border-radius: 999rpx; background: #6b8276; font-size: 28rpx; font-weight: 400; color: #fff; &::after { border: none; } &[disabled] { background: #e3e8df; color: #8a9586; } }
|
||||||
|
.save-hint { display: block; margin-top: 18rpx; font-size: 21rpx; color: #9a9086; text-align: center; }
|
||||||
|
.skip-btn { text-align: center; margin: 16rpx auto 0; padding: 8rpx 24rpx; width: auto; border: none; background: transparent; font-size: 24rpx; color: #8b817b; &::after { border: none; } }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
504
packages/app/src/pages/profile/invite.vue
Normal file
504
packages/app/src/pages/profile/invite.vue
Normal file
@@ -0,0 +1,504 @@
|
|||||||
|
<template>
|
||||||
|
<view class="invite-page" :style="{ paddingTop: navBarHeight }">
|
||||||
|
<CustomNavBar title="邀请好友" show-back />
|
||||||
|
|
||||||
|
<scroll-view class="invite-scroll" scroll-y>
|
||||||
|
<view class="hero-card">
|
||||||
|
<view class="hero-glow hero-glow--one" />
|
||||||
|
<view class="hero-glow hero-glow--two" />
|
||||||
|
<text class="hero-badge">会员专享裂变活动</text>
|
||||||
|
<text class="hero-title">邀 3 位好友体验并核销</text>
|
||||||
|
<text class="hero-subtitle">好友购买体验课并完成上课后,会员卡立即奖励 1 节正课次数。</text>
|
||||||
|
|
||||||
|
<view class="hero-stats">
|
||||||
|
<view class="hero-stat">
|
||||||
|
<text class="hero-stat-value">{{ summary?.qualifiedInviteCount ?? 0 }}</text>
|
||||||
|
<text class="hero-stat-label">已完成邀请</text>
|
||||||
|
</view>
|
||||||
|
<view class="hero-stat hero-stat--accent">
|
||||||
|
<text class="hero-stat-value">{{ summary?.rewardedTimes ?? 0 }}</text>
|
||||||
|
<text class="hero-stat-label">已得奖励</text>
|
||||||
|
</view>
|
||||||
|
<view class="hero-stat">
|
||||||
|
<text class="hero-stat-value">{{ summary?.nextRewardRemainingCount ?? 3 }}</text>
|
||||||
|
<text class="hero-stat-label">距下次奖励</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="progress-shell">
|
||||||
|
<view class="progress-track">
|
||||||
|
<view class="progress-fill" :style="{ width: progressWidth }" />
|
||||||
|
</view>
|
||||||
|
<text class="progress-caption">本轮进度 {{ summary?.currentCycleQualifiedCount ?? 0 }}/{{ summary?.rewardRuleInvitesRequired ?? 3 }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<button class="share-btn" open-type="share">
|
||||||
|
立即邀请好友
|
||||||
|
</button>
|
||||||
|
<text class="share-hint">分享后,新用户登录并购买体验课即可自动绑定邀请关系。</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="steps-card">
|
||||||
|
<text class="section-title">活动规则</text>
|
||||||
|
<view v-for="item in ruleSteps" :key="item.title" class="step-item">
|
||||||
|
<view class="step-index">{{ item.index }}</view>
|
||||||
|
<view class="step-body">
|
||||||
|
<text class="step-title">{{ item.title }}</text>
|
||||||
|
<text class="step-desc">{{ item.desc }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="referrals-card">
|
||||||
|
<view class="section-head">
|
||||||
|
<text class="section-title">邀请进度</text>
|
||||||
|
<text class="section-meta">待完成 {{ summary?.pendingInviteCount ?? 0 }} 人</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="summary?.referrals?.length" class="referral-list">
|
||||||
|
<view v-for="item in summary.referrals" :key="item.id" class="referral-item">
|
||||||
|
<image v-if="item.inviteeAvatarUrl" class="referral-avatar" :src="item.inviteeAvatarUrl" mode="aspectFill" />
|
||||||
|
<view v-else class="referral-avatar referral-avatar--placeholder">友</view>
|
||||||
|
<view class="referral-main">
|
||||||
|
<text class="referral-name">{{ item.inviteeNickname || '新好友' }}</text>
|
||||||
|
<text class="referral-time">邀请于 {{ formatDateTime(item.invitedAt) }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="referral-status" :class="statusClass(item.status)">{{ statusLabel(item.status) }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view v-else class="empty-block">
|
||||||
|
<text class="empty-title">还没有邀请记录</text>
|
||||||
|
<text class="empty-desc">先分享给 3 位好友,完成一次体验闭环就会在这里点亮进度。</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="reward-card">
|
||||||
|
<view class="section-head">
|
||||||
|
<text class="section-title">奖励记录</text>
|
||||||
|
<text class="section-meta">累计 {{ summary?.rewardedTimes ?? 0 }} 节</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="summary?.rewardGrants?.length" class="reward-list">
|
||||||
|
<view v-for="item in summary.rewardGrants" :key="item.id" class="reward-item">
|
||||||
|
<text class="reward-item-title">完成 {{ item.qualifiedReferralCount }} 位好友核销</text>
|
||||||
|
<text class="reward-item-time">{{ formatDateTime(item.grantedAt) }}</text>
|
||||||
|
<text class="reward-item-tag">+{{ item.rewardTimes }} 节</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view v-else class="empty-block empty-block--warm">
|
||||||
|
<text class="empty-title">还未获得奖励</text>
|
||||||
|
<text class="empty-desc">每 3 位好友完成体验核销,系统自动增加 1 节真实会员课次。</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="bottom-space" />
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { onLoad, onShow, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||||
|
import { InviteReferralStatus } from '@mp-pilates/shared'
|
||||||
|
import { useInviteStore } from '../../stores/invite'
|
||||||
|
import { useUserStore } from '../../stores/user'
|
||||||
|
import { getSystemLayout } from '../../utils/system'
|
||||||
|
import { formatDateTime } from '../../utils/format'
|
||||||
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
|
|
||||||
|
const inviteStore = useInviteStore()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
|
||||||
|
const navBarHeight = ref('64px')
|
||||||
|
|
||||||
|
const summary = computed(() => inviteStore.activity)
|
||||||
|
const progressWidth = computed(() => {
|
||||||
|
const current = summary.value?.currentCycleQualifiedCount ?? 0
|
||||||
|
const total = summary.value?.rewardRuleInvitesRequired ?? 3
|
||||||
|
return `${Math.min(100, (current / total) * 100)}%`
|
||||||
|
})
|
||||||
|
|
||||||
|
const ruleSteps = [
|
||||||
|
{ index: '01', title: '分享活动页', desc: '会员用户把活动页转发给微信好友或朋友圈。' },
|
||||||
|
{ index: '02', title: '好友购买体验课', desc: '新好友通过你的分享进入,并成功购买体验课。' },
|
||||||
|
{ index: '03', title: '体验课完成核销', desc: '好友到店体验并被老师核销后,这次邀请记为有效。' },
|
||||||
|
{ index: '04', title: '满 3 人自动加课', desc: '每累计 3 位有效邀请,系统自动给你的会员卡增加 1 节。' },
|
||||||
|
]
|
||||||
|
|
||||||
|
onLoad((query) => {
|
||||||
|
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
|
||||||
|
const inviterId = typeof query?.inviterId === 'string' ? query.inviterId : ''
|
||||||
|
if (inviterId) {
|
||||||
|
uni.setStorageSync('invite_inviter_id', inviterId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onShow(async () => {
|
||||||
|
if (!userStore.loggedIn) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await Promise.all([
|
||||||
|
userStore.fetchProfile(),
|
||||||
|
inviteStore.fetchActivity(),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
onShareAppMessage(() => ({
|
||||||
|
title: '邀 3 位好友体验核销,立得 1 节会员正课',
|
||||||
|
path: summary.value?.sharePath || `/pages/profile/invite?inviterId=${userStore.user?.id || ''}`,
|
||||||
|
imageUrl: '',
|
||||||
|
}))
|
||||||
|
|
||||||
|
onShareTimeline(() => ({
|
||||||
|
title: '邀 3 位好友体验核销,立得 1 节会员正课',
|
||||||
|
query: `inviterId=${userStore.user?.id || ''}`,
|
||||||
|
}))
|
||||||
|
|
||||||
|
function statusLabel(status: InviteReferralStatus): string {
|
||||||
|
const map: Record<InviteReferralStatus, string> = {
|
||||||
|
[InviteReferralStatus.REGISTERED]: '已注册',
|
||||||
|
[InviteReferralStatus.TRIAL_PURCHASED]: '已购体验课',
|
||||||
|
[InviteReferralStatus.QUALIFIED]: '已完成核销',
|
||||||
|
}
|
||||||
|
return map[status]
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusClass(status: InviteReferralStatus): string {
|
||||||
|
if (status === InviteReferralStatus.QUALIFIED) return 'referral-status--done'
|
||||||
|
if (status === InviteReferralStatus.TRIAL_PURCHASED) return 'referral-status--paid'
|
||||||
|
return 'referral-status--registered'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.invite-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(255, 142, 83, 0.28), transparent 34%),
|
||||||
|
radial-gradient(circle at top right, rgba(255, 214, 102, 0.34), transparent 26%),
|
||||||
|
linear-gradient(180deg, #fff5db 0%, #ffe7ea 30%, #fef7ff 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-scroll {
|
||||||
|
height: 100vh;
|
||||||
|
padding: 24rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-card,
|
||||||
|
.steps-card,
|
||||||
|
.referrals-card,
|
||||||
|
.reward-card {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 36rpx;
|
||||||
|
padding: 32rpx;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
box-shadow: 0 18rpx 50rpx rgba(157, 70, 42, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-card {
|
||||||
|
background: linear-gradient(135deg, #ff7a45 0%, #ff4d6d 48%, #ffb347 100%);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-glow {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 50%;
|
||||||
|
opacity: 0.28;
|
||||||
|
background: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-glow--one {
|
||||||
|
width: 260rpx;
|
||||||
|
height: 260rpx;
|
||||||
|
top: -90rpx;
|
||||||
|
right: -40rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-glow--two {
|
||||||
|
width: 180rpx;
|
||||||
|
height: 180rpx;
|
||||||
|
bottom: -50rpx;
|
||||||
|
left: -40rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-self: flex-start;
|
||||||
|
padding: 10rpx 18rpx;
|
||||||
|
background: rgba(255, 255, 255, 0.18);
|
||||||
|
border-radius: 999rpx;
|
||||||
|
font-size: 22rpx;
|
||||||
|
margin-bottom: 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-title {
|
||||||
|
display: block;
|
||||||
|
font-size: 52rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.18;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-subtitle {
|
||||||
|
display: block;
|
||||||
|
margin-top: 18rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: rgba(255, 255, 255, 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 18rpx;
|
||||||
|
margin-top: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-stat {
|
||||||
|
padding: 24rpx 18rpx;
|
||||||
|
border-radius: 26rpx;
|
||||||
|
background: rgba(255, 255, 255, 0.14);
|
||||||
|
backdrop-filter: blur(10rpx);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-stat--accent {
|
||||||
|
background: rgba(75, 16, 16, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-stat-value {
|
||||||
|
display: block;
|
||||||
|
font-size: 46rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-stat-label {
|
||||||
|
display: block;
|
||||||
|
margin-top: 8rpx;
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: rgba(255, 255, 255, 0.86);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-shell {
|
||||||
|
margin-top: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-track {
|
||||||
|
height: 20rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
background: rgba(255, 255, 255, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: linear-gradient(90deg, #fff7ad 0%, #ffffff 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-caption {
|
||||||
|
display: block;
|
||||||
|
margin-top: 12rpx;
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: rgba(255, 255, 255, 0.88);
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-btn {
|
||||||
|
margin-top: 28rpx;
|
||||||
|
height: 96rpx;
|
||||||
|
line-height: 96rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ff5a3c;
|
||||||
|
background: linear-gradient(90deg, #fff7e4 0%, #ffffff 100%);
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-btn::after {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-hint {
|
||||||
|
display: block;
|
||||||
|
margin-top: 14rpx;
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: rgba(255, 255, 255, 0.82);
|
||||||
|
}
|
||||||
|
|
||||||
|
.steps-card {
|
||||||
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.94), rgba(255, 247, 234, 0.92));
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
display: block;
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #30201a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 22rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-meta {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #9b6b55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 18rpx;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 22rpx 0;
|
||||||
|
border-bottom: 1rpx solid rgba(214, 171, 134, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-index {
|
||||||
|
width: 64rpx;
|
||||||
|
height: 64rpx;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 64rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(135deg, #ff8f5a 0%, #ff4d6d 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-body {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-title {
|
||||||
|
display: block;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #36231d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-desc {
|
||||||
|
display: block;
|
||||||
|
margin-top: 8rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: #7b5d52;
|
||||||
|
}
|
||||||
|
|
||||||
|
.referrals-card {
|
||||||
|
background: linear-gradient(180deg, #ffffff 0%, #fff7fb 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reward-card {
|
||||||
|
background: linear-gradient(180deg, #fffdf5 0%, #fff2dc 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.referral-item,
|
||||||
|
.reward-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 22rpx 0;
|
||||||
|
border-bottom: 1rpx solid rgba(221, 196, 177, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.referral-item:last-child,
|
||||||
|
.reward-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.referral-avatar {
|
||||||
|
width: 78rpx;
|
||||||
|
height: 78rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-right: 18rpx;
|
||||||
|
background: #ffd9c8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.referral-avatar--placeholder {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ff6f3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.referral-main {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.referral-name,
|
||||||
|
.reward-item-title {
|
||||||
|
display: block;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #31211a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.referral-time,
|
||||||
|
.reward-item-time {
|
||||||
|
display: block;
|
||||||
|
margin-top: 8rpx;
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #8b6d62;
|
||||||
|
}
|
||||||
|
|
||||||
|
.referral-status,
|
||||||
|
.reward-item-tag {
|
||||||
|
padding: 12rpx 18rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.referral-status--registered {
|
||||||
|
color: #9c5e2f;
|
||||||
|
background: #fff0de;
|
||||||
|
}
|
||||||
|
|
||||||
|
.referral-status--paid {
|
||||||
|
color: #c44f1f;
|
||||||
|
background: #ffe0d1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.referral-status--done,
|
||||||
|
.reward-item-tag {
|
||||||
|
color: #0f7a53;
|
||||||
|
background: #dff7ea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-block {
|
||||||
|
padding: 36rpx 0 10rpx;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-block--warm {
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-title {
|
||||||
|
display: block;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #5f4337;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-desc {
|
||||||
|
display: block;
|
||||||
|
margin-top: 12rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
line-height: 1.8;
|
||||||
|
color: #9c7d70;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-space {
|
||||||
|
height: 48rpx;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
@@ -1,159 +1,110 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="membership-page">
|
<view class="membership-page" :style="{ paddingTop: navBarHeight, height: pageHeight }">
|
||||||
<!-- Pull-to-refresh scroll view -->
|
<CustomNavBar title="我的会员卡" show-back />
|
||||||
<scroll-view
|
<scroll-view class="scroll" scroll-y refresher-enabled :refresher-triggered="refreshing" @refresherrefresh="onRefresh">
|
||||||
class="scroll"
|
<view v-if="loading && !refreshing && !allMemberships.length" class="loading-wrap">
|
||||||
scroll-y
|
|
||||||
refresher-enabled
|
|
||||||
:refresher-triggered="refreshing"
|
|
||||||
@refresherrefresh="onRefresh"
|
|
||||||
>
|
|
||||||
<!-- Loading skeleton -->
|
|
||||||
<view v-if="loading && !refreshing" class="loading-wrap">
|
|
||||||
<view v-for="i in 2" :key="i" class="skeleton-card" />
|
<view v-for="i in 2" :key="i" class="skeleton-card" />
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Empty state -->
|
|
||||||
<view v-else-if="allMemberships.length === 0" class="empty-wrap">
|
<view v-else-if="allMemberships.length === 0" class="empty-wrap">
|
||||||
<text class="empty-icon">💳</text>
|
<view class="empty-card">
|
||||||
<text class="empty-title">暂无会员卡</text>
|
<text class="empty-title">还没有会员卡</text>
|
||||||
<text class="empty-sub">购买会员卡后即可预约课程</text>
|
<text class="empty-sub">选一张适合自己的卡,开始第一次练习。</text>
|
||||||
<view class="empty-btn" @tap="goStore">
|
<button class="empty-btn" @tap="goStore">选购会员卡</button>
|
||||||
<text class="empty-btn-text">去购买</text>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Membership list -->
|
|
||||||
<view v-else class="list">
|
<view v-else class="list">
|
||||||
<!-- Active cards -->
|
<view v-if="activeMemberships.length" class="group-section">
|
||||||
<view v-if="activeMemberships.length > 0" class="group-section">
|
|
||||||
<view class="group-header">
|
<view class="group-header">
|
||||||
<view class="group-dot group-dot--active" />
|
<text class="group-title">正在使用</text>
|
||||||
<text class="group-title">有效会员卡</text>
|
<text class="group-count">{{ activeMemberships.length }} 张有效卡</text>
|
||||||
<text class="group-count">{{ activeMemberships.length }} 张</text>
|
|
||||||
</view>
|
</view>
|
||||||
|
<view v-for="m in activeMemberships" :key="m.id" class="mc" :class="cardBgClass(m.cardType.type)">
|
||||||
<view
|
<view class="mc-top">
|
||||||
v-for="m in activeMemberships"
|
<view class="mc-name-area">
|
||||||
:key="m.id"
|
<text class="mc-name">{{ m.cardType.name }}</text>
|
||||||
class="card-item"
|
<text class="mc-type-text">{{ getCardTypeLabel(m.cardType.type) }}</text>
|
||||||
>
|
</view>
|
||||||
<!-- Colored left border strip -->
|
<text class="mc-status mc-status--active">有效</text>
|
||||||
<view class="card-strip" :class="stripClass(m.cardType.type)" />
|
</view>
|
||||||
|
<view class="mc-balance">
|
||||||
<!-- Card header (colored gradient) -->
|
<view class="mc-number-row">
|
||||||
<view class="card-header" :class="headerClass(m.cardType.type)">
|
<text class="mc-big-num">{{ m.remainingTimes !== null ? m.remainingTimes : daysRemaining(m) }}</text>
|
||||||
<view class="card-header-left">
|
<text class="mc-big-unit">{{ m.remainingTimes !== null ? '次可用' : '天剩余' }}</text>
|
||||||
<text class="card-name">{{ m.cardType.name }}</text>
|
</view>
|
||||||
<view class="card-type-badge">
|
<text v-if="m.remainingTimes === null" class="mc-duration-note">有效期内不限次数</text>
|
||||||
<text class="card-type-badge-text">{{ typeLabel(m.cardType.type) }}</text>
|
<view v-else-if="getMembershipTotalTimes(m)" class="mc-progress">
|
||||||
|
<view class="mc-progress-track"><view class="mc-progress-fill" :style="{ width: getMembershipProgressWidth(m) }" /></view>
|
||||||
|
<text class="mc-progress-label">已用 {{ getMembershipUsedTimes(m) }} / {{ getMembershipTotalTimes(m) }} 次</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="status-badge status-badge--active">
|
<view class="mc-bottom">
|
||||||
<text class="status-badge-text">有效</text>
|
<view class="mc-date-item">
|
||||||
|
<text class="mc-date-label">开始日期</text>
|
||||||
|
<text class="mc-date-value">{{ m.startDate.slice(0, 10) }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="mc-date-item mc-date-item--end">
|
||||||
|
<text class="mc-date-label">有效期至</text>
|
||||||
|
<text class="mc-date-value">{{ m.expireDate.slice(0, 10) }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
<view class="mc-actions">
|
||||||
<!-- Card body -->
|
<button v-if="canRenewMembership(m)" class="mc-renew" @tap="goRenew(m)">续卡</button>
|
||||||
<view class="card-body">
|
<button class="mc-book" @tap="goBooking">预约课程</button>
|
||||||
<!-- Times card: remaining times + progress -->
|
|
||||||
<template v-if="m.remainingTimes !== null">
|
|
||||||
<view class="highlight-row">
|
|
||||||
<text class="highlight-label">剩余课时</text>
|
|
||||||
<text class="highlight-value">
|
|
||||||
<text class="highlight-number">{{ m.remainingTimes }}</text>
|
|
||||||
<text class="highlight-unit"> 次</text>
|
|
||||||
</text>
|
|
||||||
</view>
|
|
||||||
<view v-if="m.cardType.totalTimes" class="progress-wrap">
|
|
||||||
<view class="progress-bar">
|
|
||||||
<view
|
|
||||||
class="progress-fill"
|
|
||||||
:style="{ width: progressWidth(m) }"
|
|
||||||
/>
|
|
||||||
</view>
|
|
||||||
<text class="progress-label">
|
|
||||||
已使用 {{ usedTimes(m) }} / {{ m.cardType.totalTimes }} 次
|
|
||||||
</text>
|
|
||||||
</view>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- Duration card: expiry -->
|
|
||||||
<view class="info-row">
|
|
||||||
<text class="info-label">有效期至</text>
|
|
||||||
<text class="info-value">{{ m.expireDate.slice(0, 10) }}</text>
|
|
||||||
</view>
|
|
||||||
<view class="info-row">
|
|
||||||
<text class="info-label">开始日期</text>
|
|
||||||
<text class="info-value">{{ m.startDate.slice(0, 10) }}</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Expired / used up cards -->
|
<view v-if="inactiveMemberships.length" class="group-section">
|
||||||
<view v-if="inactiveMemberships.length > 0" class="group-section">
|
|
||||||
<view class="group-header">
|
<view class="group-header">
|
||||||
<view class="group-dot group-dot--inactive" />
|
|
||||||
<text class="group-title">历史记录</text>
|
<text class="group-title">历史记录</text>
|
||||||
<text class="group-count">{{ inactiveMemberships.length }} 张</text>
|
<text class="group-count">{{ inactiveMemberships.length }} 张</text>
|
||||||
</view>
|
</view>
|
||||||
|
<view v-for="m in inactiveMemberships" :key="m.id" class="mc mc--inactive">
|
||||||
<view
|
<view class="mc-top">
|
||||||
v-for="m in inactiveMemberships"
|
<view class="mc-name-area">
|
||||||
:key="m.id"
|
<text class="mc-name">{{ m.cardType.name }}</text>
|
||||||
class="card-item card-item--inactive"
|
<text class="mc-type-text">{{ getCardTypeLabel(m.cardType.type) }}</text>
|
||||||
>
|
|
||||||
<view class="card-strip card-strip--inactive" />
|
|
||||||
<view class="card-header card-header--inactive">
|
|
||||||
<view class="card-header-left">
|
|
||||||
<text class="card-name card-name--dim">{{ m.cardType.name }}</text>
|
|
||||||
<view class="card-type-badge card-type-badge--dim">
|
|
||||||
<text class="card-type-badge-text">{{ typeLabel(m.cardType.type) }}</text>
|
|
||||||
</view>
|
</view>
|
||||||
|
<text class="mc-status" :class="inactiveStatusClass(m.status)">{{ statusLabel(m.status) }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="status-badge" :class="statusBadgeClass(m.status)">
|
<view class="mc-history-bottom">
|
||||||
<text class="status-badge-text">{{ statusLabel(m.status) }}</text>
|
<view class="mc-history-info">
|
||||||
|
<text v-if="m.remainingTimes !== null" class="mc-date-label">剩余 {{ m.remainingTimes }} 次</text>
|
||||||
|
<text class="mc-date-label">{{ m.expireDate.slice(0, 10) }} 到期</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
<button v-if="canRenewMembership(m)" class="mc-renew mc-renew--inactive" @tap="goRenew(m)">续同款</button>
|
||||||
<view class="card-body">
|
|
||||||
<view v-if="m.remainingTimes !== null" class="info-row">
|
|
||||||
<text class="info-label">剩余课时</text>
|
|
||||||
<text class="info-value">{{ m.remainingTimes }} 次</text>
|
|
||||||
</view>
|
|
||||||
<view class="info-row">
|
|
||||||
<text class="info-label">有效期至</text>
|
|
||||||
<text class="info-value">{{ m.expireDate.slice(0, 10) }}</text>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="scroll-bottom-spacer" />
|
<view class="scroll-bottom-spacer" />
|
||||||
</scroll-view>
|
</scroll-view>
|
||||||
|
<view v-if="allMemberships.length" class="purchase-dock">
|
||||||
<!-- Buy more FAB -->
|
<button class="purchase-btn" @tap="goStore">选购会员卡</button>
|
||||||
<view class="fab" @tap="goStore">
|
|
||||||
<text class="fab-icon">+</text>
|
|
||||||
<text class="fab-text">购买会员卡</text>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
|
import { onShow, onResize } from '@dcloudio/uni-app'
|
||||||
import type { MembershipWithCardType } from '@mp-pilates/shared'
|
import type { MembershipWithCardType } from '@mp-pilates/shared'
|
||||||
import { MembershipStatus, CardTypeCategory } from '@mp-pilates/shared'
|
import { MembershipStatus, CardTypeCategory } from '@mp-pilates/shared'
|
||||||
import { useUserStore } from '../../stores/user'
|
import { useUserStore } from '../../stores/user'
|
||||||
|
import { getSystemLayout } from '../../utils/system'
|
||||||
|
import { getCardTypeLabel, getMembershipProgressWidth, getMembershipUsedTimes, getMembershipTotalTimes } from '../../utils/format'
|
||||||
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
|
||||||
// ─── State ────────────────────────────────────────────────
|
const navBarHeight = `${getSystemLayout().navBarHeight}px`
|
||||||
|
const pageHeight = ref(`${uni.getWindowInfo().windowHeight}px`)
|
||||||
|
onResize(() => { pageHeight.value = `${uni.getWindowInfo().windowHeight}px` })
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const refreshing = ref(false)
|
const refreshing = ref(false)
|
||||||
|
|
||||||
// ─── Computed from store ───────────────────────────────────
|
|
||||||
const allMemberships = computed(() => userStore.memberships as MembershipWithCardType[])
|
const allMemberships = computed(() => userStore.memberships as MembershipWithCardType[])
|
||||||
|
|
||||||
const activeMemberships = computed(() =>
|
const activeMemberships = computed(() =>
|
||||||
@@ -164,16 +115,6 @@ const inactiveMemberships = computed(() =>
|
|||||||
allMemberships.value.filter((m) => m.status !== MembershipStatus.ACTIVE),
|
allMemberships.value.filter((m) => m.status !== MembershipStatus.ACTIVE),
|
||||||
)
|
)
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────
|
|
||||||
function typeLabel(type: CardTypeCategory): string {
|
|
||||||
const map: Record<CardTypeCategory, string> = {
|
|
||||||
[CardTypeCategory.TIMES]: '次卡',
|
|
||||||
[CardTypeCategory.DURATION]: '月卡',
|
|
||||||
[CardTypeCategory.TRIAL]: '体验卡',
|
|
||||||
}
|
|
||||||
return map[type] ?? '会员卡'
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusLabel(status: MembershipStatus): string {
|
function statusLabel(status: MembershipStatus): string {
|
||||||
const map: Record<MembershipStatus, string> = {
|
const map: Record<MembershipStatus, string> = {
|
||||||
[MembershipStatus.ACTIVE]: '有效',
|
[MembershipStatus.ACTIVE]: '有效',
|
||||||
@@ -183,36 +124,22 @@ function statusLabel(status: MembershipStatus): string {
|
|||||||
return map[status] ?? status
|
return map[status] ?? status
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusBadgeClass(status: MembershipStatus): string {
|
function inactiveStatusClass(status: MembershipStatus): string {
|
||||||
if (status === MembershipStatus.EXPIRED) return 'status-badge--expired'
|
if (status === MembershipStatus.USED_UP) return 'mc-status--used'
|
||||||
if (status === MembershipStatus.USED_UP) return 'status-badge--used'
|
return 'mc-status--expired'
|
||||||
return 'status-badge--expired'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function stripClass(type: CardTypeCategory): string {
|
function cardBgClass(type: CardTypeCategory): string {
|
||||||
if (type === CardTypeCategory.TRIAL) return 'card-strip--trial'
|
if (type === CardTypeCategory.TRIAL) return 'mc--trial'
|
||||||
if (type === CardTypeCategory.DURATION) return 'card-strip--duration'
|
if (type === CardTypeCategory.DURATION) return 'mc--duration'
|
||||||
return 'card-strip--times'
|
return 'mc--times'
|
||||||
}
|
}
|
||||||
|
|
||||||
function headerClass(type: CardTypeCategory): string {
|
function daysRemaining(m: MembershipWithCardType): number {
|
||||||
if (type === CardTypeCategory.TRIAL) return 'card-header--trial'
|
const diff = new Date(m.expireDate).getTime() - Date.now()
|
||||||
if (type === CardTypeCategory.DURATION) return 'card-header--duration'
|
return Math.max(0, Math.ceil(diff / 86_400_000))
|
||||||
return 'card-header--times'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function progressWidth(m: MembershipWithCardType): string {
|
|
||||||
if (m.remainingTimes === null || !m.cardType.totalTimes) return '0%'
|
|
||||||
const pct = (m.remainingTimes / m.cardType.totalTimes) * 100
|
|
||||||
return `${Math.max(0, Math.min(100, pct))}%`
|
|
||||||
}
|
|
||||||
|
|
||||||
function usedTimes(m: MembershipWithCardType): number {
|
|
||||||
if (m.remainingTimes === null || !m.cardType.totalTimes) return 0
|
|
||||||
return m.cardType.totalTimes - m.remainingTimes
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Data loading ─────────────────────────────────────────
|
|
||||||
async function loadMemberships() {
|
async function loadMemberships() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -226,333 +153,80 @@ async function loadMemberships() {
|
|||||||
|
|
||||||
async function onRefresh() {
|
async function onRefresh() {
|
||||||
refreshing.value = true
|
refreshing.value = true
|
||||||
|
try {
|
||||||
await userStore.fetchMemberships()
|
await userStore.fetchMemberships()
|
||||||
|
} finally {
|
||||||
refreshing.value = false
|
refreshing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goBooking() {
|
||||||
|
uni.switchTab({ url: '/pages/booking/index' })
|
||||||
}
|
}
|
||||||
|
|
||||||
function goStore() {
|
function goStore() {
|
||||||
|
uni.$emit('scrollToCardShop')
|
||||||
uni.switchTab({ url: '/pages/home/index' })
|
uni.switchTab({ url: '/pages/home/index' })
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Lifecycle ────────────────────────────────────────────
|
function canRenewMembership(m: MembershipWithCardType): boolean {
|
||||||
onMounted(loadMemberships)
|
return m.cardType.type !== CardTypeCategory.TRIAL
|
||||||
|
}
|
||||||
|
|
||||||
|
function goRenew(m: MembershipWithCardType) {
|
||||||
|
uni.navigateTo({ url: `/pages/card/detail?id=${m.cardTypeId}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
onShow(loadMemberships)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.membership-page {
|
.membership-page { box-sizing: border-box; height: 100vh; display: flex; flex-direction: column; background: #fbf9f6; color: #514943; }
|
||||||
min-height: 100vh;
|
.scroll { flex: 1; height: 0; min-height: 0; }
|
||||||
background: #f5f3f0;
|
.loading-wrap { padding: 28rpx 32rpx; display: flex; flex-direction: column; gap: 24rpx; }
|
||||||
}
|
.skeleton-card { height: 420rpx; border-radius: 28rpx; background: linear-gradient(90deg, #f0eae5 25%, #faf7f3 50%, #f0eae5 75%); background-size: 400% 100%; animation: shimmer 1.4s infinite; }
|
||||||
|
.empty-wrap { padding: 40rpx 32rpx; }
|
||||||
.scroll {
|
.empty-card { padding: 48rpx 32rpx; border-radius: 28rpx; background: #f2e9e3; display: flex; flex-direction: column; align-items: flex-start; gap: 16rpx; }
|
||||||
height: 100vh;
|
.empty-title { font-size: 32rpx; font-weight: 500; }
|
||||||
}
|
.empty-sub { font-size: 24rpx; color: #8b7b70; line-height: 1.7; }
|
||||||
|
.empty-btn { margin: 12rpx 0 0; padding: 0 32rpx; height: 76rpx; line-height: 76rpx; border: none; border-radius: 999rpx; background: #6b8276; color: #fff; font-size: 26rpx; &::after { border: none; } }
|
||||||
/* ── Loading ─────────────────────────────────────────── */
|
.list { padding: 28rpx 32rpx 0; }
|
||||||
.loading-wrap {
|
.group-section + .group-section { margin-top: 36rpx; }
|
||||||
padding: 24rpx;
|
.group-header { display: flex; align-items: baseline; justify-content: space-between; gap: 20rpx; margin-bottom: 20rpx; }
|
||||||
display: flex;
|
.group-title { font-size: 28rpx; font-weight: 500; }
|
||||||
flex-direction: column;
|
.group-count { font-size: 22rpx; color: #8b817b; }
|
||||||
gap: 20rpx;
|
.mc { --balance-bg: #f3ebe3; --balance-ink: #8b6c5b; padding: 28rpx; margin-bottom: 20rpx; border: 1rpx solid #eee8e3; border-radius: 28rpx; background: #fff; }
|
||||||
}
|
.mc--duration { --balance-bg: #edf2e9; --balance-ink: #617d63; }
|
||||||
|
.mc--trial { --balance-bg: #f5e9e5; --balance-ink: #9b7768; }
|
||||||
.skeleton-card {
|
.mc-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 20rpx; }
|
||||||
height: 220rpx;
|
.mc-name-area { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8rpx; }
|
||||||
border-radius: 20rpx;
|
.mc-name { font-size: 30rpx; font-weight: 500; line-height: 1.5; overflow-wrap: anywhere; }
|
||||||
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
|
.mc-type-text { font-size: 22rpx; color: #8b817b; }
|
||||||
background-size: 400% 100%;
|
.mc-status { flex-shrink: 0; padding: 6rpx 16rpx; border-radius: 999rpx; background: #ede9e4; color: #8b817b; font-size: 21rpx; line-height: 1.4; }
|
||||||
animation: shimmer 1.4s infinite;
|
.mc-status--active { background: #edf2ec; color: #617d73; }
|
||||||
}
|
.mc-balance { margin-top: 24rpx; padding: 22rpx 24rpx; background: var(--balance-bg); border-radius: 20rpx; }
|
||||||
|
.mc-number-row { display: flex; align-items: baseline; gap: 10rpx; }
|
||||||
@keyframes shimmer {
|
.mc-big-num { font-size: 56rpx; font-weight: 400; line-height: 1.1; color: var(--balance-ink); font-variant-numeric: tabular-nums; }
|
||||||
0% { background-position: 100% 0; }
|
.mc-big-unit { font-size: 23rpx; color: var(--balance-ink); }
|
||||||
100% { background-position: -100% 0; }
|
.mc-duration-note { display: block; font-size: 21rpx; color: #7b8775; margin-top: 12rpx; }
|
||||||
}
|
.mc-progress { margin-top: 18rpx; }
|
||||||
|
.mc-progress-track { height: 6rpx; border-radius: 6rpx; overflow: hidden; background: rgba(255,255,255,0.8); }
|
||||||
/* ── Empty ───────────────────────────────────────────── */
|
.mc-progress-fill { height: 100%; border-radius: 6rpx; background: #b9a38f; }
|
||||||
.empty-wrap {
|
.mc-progress-label { display: block; margin-top: 10rpx; font-size: 21rpx; color: #8b7b70; }
|
||||||
display: flex;
|
.mc-bottom { display: flex; justify-content: space-between; gap: 20rpx; margin-top: 22rpx; }
|
||||||
flex-direction: column;
|
.mc-date-item { min-width: 0; display: flex; flex-direction: column; gap: 8rpx; }
|
||||||
align-items: center;
|
.mc-date-item--end { text-align: right; }
|
||||||
justify-content: center;
|
.mc-date-label { font-size: 21rpx; color: #8b817b; line-height: 1.6; }
|
||||||
padding: 120rpx 40rpx;
|
.mc-date-value { font-size: 24rpx; color: #6f655e; font-variant-numeric: tabular-nums; }
|
||||||
gap: 20rpx;
|
.mc-actions { display: flex; gap: 16rpx; margin-top: 26rpx; }
|
||||||
}
|
.mc-renew, .mc-book { flex: 1; margin: 0; padding: 0 24rpx; height: 72rpx; line-height: 72rpx; border: none; border-radius: 999rpx; font-size: 25rpx; font-weight: 400; &::after { border: none; } }
|
||||||
|
.mc-renew { background: #f3eee8; color: #8b7160; }
|
||||||
.empty-icon {
|
.mc-book { background: #6b8276; color: #fff; }
|
||||||
font-size: 80rpx;
|
.mc--inactive { background: #f4f1ed; border-color: #eee8e3; .mc-name { font-size: 27rpx; font-weight: 400; color: #786d64; } }
|
||||||
}
|
.mc-history-bottom { display: flex; align-items: center; justify-content: space-between; gap: 16rpx; margin-top: 22rpx; }
|
||||||
|
.mc-history-info { display: flex; flex-direction: column; gap: 4rpx; }
|
||||||
.empty-title {
|
.mc-renew--inactive { flex: none; flex-shrink: 0; height: 64rpx; line-height: 64rpx; background: #fffaf5; font-size: 23rpx; }
|
||||||
font-size: 32rpx;
|
.purchase-dock { flex-shrink: 0; padding: 20rpx 32rpx calc(20rpx + env(safe-area-inset-bottom)); border-top: 1rpx solid #eee8e3; background: #fbf9f6; }
|
||||||
font-weight: 600;
|
.purchase-btn { display: block; width: 100%; margin: 0; padding: 0; height: 84rpx; line-height: 84rpx; border: none; border-radius: 999rpx; background: #6b8276; color: #fff; font-size: 28rpx; font-weight: 400; &::after { border: none; } }
|
||||||
color: #333;
|
.scroll-bottom-spacer { height: 20rpx; }
|
||||||
}
|
|
||||||
|
|
||||||
.empty-sub {
|
|
||||||
font-size: 26rpx;
|
|
||||||
color: #999;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-btn {
|
|
||||||
margin-top: 12rpx;
|
|
||||||
padding: 22rpx 60rpx;
|
|
||||||
border-radius: 44rpx;
|
|
||||||
background: #c9a87c;
|
|
||||||
box-shadow: 0 4rpx 16rpx rgba(201, 168, 124, 0.35);
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-btn-text {
|
|
||||||
font-size: 30rpx;
|
|
||||||
color: #fff;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── List ────────────────────────────────────────────── */
|
|
||||||
.list {
|
|
||||||
padding: 24rpx 24rpx 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Group section ───────────────────────────────────── */
|
|
||||||
.group-section {
|
|
||||||
margin-bottom: 8rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.group-header {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10rpx;
|
|
||||||
padding: 8rpx 4rpx 14rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.group-dot {
|
|
||||||
width: 12rpx;
|
|
||||||
height: 12rpx;
|
|
||||||
border-radius: 50%;
|
|
||||||
flex-shrink: 0;
|
|
||||||
|
|
||||||
&--active { background: #4caf50; }
|
|
||||||
&--inactive { background: #bbb; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.group-title {
|
|
||||||
font-size: 26rpx;
|
|
||||||
color: #555;
|
|
||||||
font-weight: 600;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.group-count {
|
|
||||||
font-size: 22rpx;
|
|
||||||
color: #bbb;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Card item ───────────────────────────────────────── */
|
|
||||||
.card-item {
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 20rpx;
|
|
||||||
overflow: hidden;
|
|
||||||
margin-bottom: 16rpx;
|
|
||||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.07);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
&--inactive {
|
|
||||||
opacity: 0.72;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Colored left border strip */
|
|
||||||
.card-strip {
|
|
||||||
height: 6rpx;
|
|
||||||
|
|
||||||
&--times { background: linear-gradient(90deg, #1a1a2e, #2d2d5e); }
|
|
||||||
&--duration { background: linear-gradient(90deg, #6c3483, #9b59b6); }
|
|
||||||
&--trial { background: linear-gradient(90deg, #7d6608, #c9a87c); }
|
|
||||||
&--inactive { background: #ccc; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Card header gradient area */
|
|
||||||
.card-header {
|
|
||||||
padding: 22rpx 28rpx;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
|
|
||||||
&--times { background: linear-gradient(90deg, #1a1a2e, #2d2d5e); }
|
|
||||||
&--duration { background: linear-gradient(90deg, #6c3483, #9b59b6); }
|
|
||||||
&--trial { background: linear-gradient(90deg, #7d6608, #c9a87c); }
|
|
||||||
&--inactive { background: #888; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-header-left {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-name {
|
|
||||||
font-size: 32rpx;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #fff;
|
|
||||||
|
|
||||||
&--dim { color: #ddd; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-type-badge {
|
|
||||||
align-self: flex-start;
|
|
||||||
padding: 4rpx 14rpx;
|
|
||||||
border-radius: 12rpx;
|
|
||||||
background: rgba(255, 255, 255, 0.15);
|
|
||||||
border: 1rpx solid rgba(255, 255, 255, 0.25);
|
|
||||||
|
|
||||||
&--dim {
|
|
||||||
background: rgba(255, 255, 255, 0.08);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-type-badge-text {
|
|
||||||
font-size: 20rpx;
|
|
||||||
color: rgba(255, 255, 255, 0.85);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Status badge */
|
|
||||||
.status-badge {
|
|
||||||
padding: 8rpx 20rpx;
|
|
||||||
border-radius: 20rpx;
|
|
||||||
border: 1rpx solid rgba(255, 255, 255, 0.35);
|
|
||||||
flex-shrink: 0;
|
|
||||||
|
|
||||||
&--active { background: rgba(76, 175, 80, 0.3); }
|
|
||||||
&--expired { background: rgba(0, 0, 0, 0.2); }
|
|
||||||
&--used { background: rgba(0, 0, 0, 0.2); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge-text {
|
|
||||||
font-size: 22rpx;
|
|
||||||
color: #fff;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Card body */
|
|
||||||
.card-body {
|
|
||||||
padding: 20rpx 28rpx 24rpx;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.highlight-row {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: baseline;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 4rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.highlight-label {
|
|
||||||
font-size: 26rpx;
|
|
||||||
color: #999;
|
|
||||||
}
|
|
||||||
|
|
||||||
.highlight-number {
|
|
||||||
font-size: 44rpx;
|
|
||||||
font-weight: 800;
|
|
||||||
color: #c9a87c;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.highlight-unit {
|
|
||||||
font-size: 22rpx;
|
|
||||||
color: #c9a87c;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-row {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-label {
|
|
||||||
font-size: 26rpx;
|
|
||||||
color: #999;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-value {
|
|
||||||
font-size: 26rpx;
|
|
||||||
color: #333;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Progress bar ────────────────────────────────────── */
|
|
||||||
.progress-wrap {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8rpx;
|
|
||||||
margin-bottom: 6rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-bar {
|
|
||||||
height: 8rpx;
|
|
||||||
background: #f0f0f0;
|
|
||||||
border-radius: 4rpx;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-fill {
|
|
||||||
height: 100%;
|
|
||||||
background: linear-gradient(90deg, #c9a87c, #e8c88a);
|
|
||||||
border-radius: 4rpx;
|
|
||||||
transition: width 0.4s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-label {
|
|
||||||
font-size: 22rpx;
|
|
||||||
color: #bbb;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── FAB ─────────────────────────────────────────────── */
|
|
||||||
.fab {
|
|
||||||
position: fixed;
|
|
||||||
bottom: calc(32rpx + env(safe-area-inset-bottom));
|
|
||||||
right: 32rpx;
|
|
||||||
background: #1a1a2e;
|
|
||||||
border-radius: 44rpx;
|
|
||||||
padding: 22rpx 36rpx;
|
|
||||||
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.2);
|
|
||||||
z-index: 100;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8rpx;
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
opacity: 0.85;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.fab-icon {
|
|
||||||
font-size: 36rpx;
|
|
||||||
color: #c9a87c;
|
|
||||||
font-weight: 300;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fab-text {
|
|
||||||
font-size: 28rpx;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #c9a87c;
|
|
||||||
letter-spacing: 1rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Spacer ──────────────────────────────────────────── */
|
|
||||||
.scroll-bottom-spacer {
|
|
||||||
height: 120rpx;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
249
packages/app/src/pages/profile/teaching-schedule.vue
Normal file
249
packages/app/src/pages/profile/teaching-schedule.vue
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
<template>
|
||||||
|
<view class="schedule-page" :style="{ paddingTop: navBarHeight }">
|
||||||
|
<CustomNavBar title="我的课表" show-back />
|
||||||
|
|
||||||
|
<view class="calendar">
|
||||||
|
<view class="calendar__heading">
|
||||||
|
<picker mode="date" :value="selectedDate" @change="handlePickerChange">
|
||||||
|
<view class="calendar__month">{{ monthLabel }}<text class="calendar__chevron">⌄</text></view>
|
||||||
|
</picker>
|
||||||
|
<button v-if="!isToday(selectedDate)" class="text-button" @tap="selectDate(formatDate(new Date()))">回到今天</button>
|
||||||
|
<text v-else class="calendar__today">今天</text>
|
||||||
|
</view>
|
||||||
|
<view class="week-navigation">
|
||||||
|
<button class="week-arrow" aria-label="上一周" @tap="shiftWeek(-7)">‹</button>
|
||||||
|
<text class="week-navigation__label">{{ weekLabel }}</text>
|
||||||
|
<button class="week-arrow" aria-label="下一周" @tap="shiftWeek(7)">›</button>
|
||||||
|
</view>
|
||||||
|
<view class="week">
|
||||||
|
<button v-for="day in weekDays" :key="day.date" class="day"
|
||||||
|
:class="{ 'day--selected': day.date === selectedDate, 'day--today': isToday(day.date) }"
|
||||||
|
:aria-label="`${day.date} ${day.label}${day.date === selectedDate ? ',已选中' : ''}`"
|
||||||
|
@tap="selectDate(day.date)">
|
||||||
|
<text class="day__label">{{ day.label }}</text>
|
||||||
|
<text class="day__number">{{ day.number }}</text>
|
||||||
|
<view class="day__dot" />
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="agenda-heading">
|
||||||
|
<text class="agenda-heading__date">{{ dateLabel }}</text>
|
||||||
|
<text class="agenda-heading__count">{{ loading ? '正在加载' : error ? '加载失败' : `${slots.length} 节课 · ${studentCount} 人次` }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<scroll-view class="schedule-scroll" scroll-y refresher-enabled :refresher-triggered="refreshing"
|
||||||
|
:scroll-top="scrollTop" @scroll="handleScroll" @refresherrefresh="handleRefresh">
|
||||||
|
<view v-if="loading" class="skeleton" aria-label="正在加载课表">
|
||||||
|
<view v-for="i in 3" :key="i" class="skeleton__row"><view class="skeleton__time" /><view class="skeleton__body" /></view>
|
||||||
|
</view>
|
||||||
|
<view v-else-if="error" class="empty">
|
||||||
|
<text class="empty__title">课表暂时未能加载</text>
|
||||||
|
<text class="empty__description">{{ error }}</text>
|
||||||
|
<button class="outline-button" @tap="loadSchedule(selectedDate)">重新加载</button>
|
||||||
|
</view>
|
||||||
|
<view v-else-if="!loggedIn || !isAdmin" class="empty">
|
||||||
|
<text class="empty__title">{{ loggedIn ? '仅管理员可查看课表' : '请先登录' }}</text>
|
||||||
|
<text class="empty__description">返回「我的」查看账号信息</text>
|
||||||
|
</view>
|
||||||
|
<view v-else-if="slots.length === 0" class="empty">
|
||||||
|
<view class="empty__line" />
|
||||||
|
<text class="empty__title">当天暂无预约课程</text>
|
||||||
|
<text class="empty__description">选择其他日期,查看授课安排</text>
|
||||||
|
<button v-if="!isToday(selectedDate)" class="outline-button" @tap="selectDate(formatDate(new Date()))">查看今天</button>
|
||||||
|
</view>
|
||||||
|
<view v-else class="agenda">
|
||||||
|
<view v-for="slot in slots" :key="slot.slotId" class="session">
|
||||||
|
<view class="session__time">
|
||||||
|
<text class="session__start">{{ slot.startTime.slice(0, 5) }}</text>
|
||||||
|
<text class="session__end">{{ slot.endTime.slice(0, 5) }} 结束</text>
|
||||||
|
</view>
|
||||||
|
<view class="session__roster">
|
||||||
|
<view class="session__heading"><text>预约学员</text><text>{{ slot.students.length }} 人</text></view>
|
||||||
|
<view v-for="student in slot.students" :key="student.bookingId" class="student">
|
||||||
|
<view class="student__headline">
|
||||||
|
<text class="student__name">{{ student.nickname || '未命名学员' }}</text>
|
||||||
|
<text class="student__status" :class="`student__status--${student.status.toLowerCase()}`">{{ statusLabel(student.status) }}</text>
|
||||||
|
</view>
|
||||||
|
<button v-if="student.phone" class="student__contact" :aria-label="`联系${student.nickname || '学员'}`" @tap="contactStudent(student.phone)">
|
||||||
|
<text>{{ formatPhone(student.phone) }}</text><text class="student__contact-label">联系 ↗</text>
|
||||||
|
</button>
|
||||||
|
<text v-else class="student__no-phone">未留手机号</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<text class="agenda__end">当天课程已全部显示</text>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, ref } from 'vue'
|
||||||
|
import { onShow } from '@dcloudio/uni-app'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { BookingStatus, type TeachingScheduleSlot } from '@mp-pilates/shared'
|
||||||
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
|
import { useBookingStore } from '../../stores/booking'
|
||||||
|
import { useUserStore } from '../../stores/user'
|
||||||
|
import { formatDate, getWeekdayLabel, isToday } from '../../utils/format'
|
||||||
|
import { getSystemLayout } from '../../utils/system'
|
||||||
|
import { getErrorMessage } from '../../utils/auth'
|
||||||
|
|
||||||
|
const bookingStore = useBookingStore()
|
||||||
|
const { loggedIn, isAdmin } = storeToRefs(useUserStore())
|
||||||
|
const navBarHeight = `${getSystemLayout().navBarHeight}px`
|
||||||
|
const selectedDate = ref(formatDate(new Date()))
|
||||||
|
// Keep the rendered date and request result paired when dates are changed quickly.
|
||||||
|
const slots = ref<TeachingScheduleSlot[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const refreshing = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const scrollTop = ref(0)
|
||||||
|
let currentScrollTop = 0
|
||||||
|
let requestId = 0
|
||||||
|
|
||||||
|
function parseDate(value: string) {
|
||||||
|
const [year, month, day] = value.split('-').map(Number)
|
||||||
|
return new Date(year, month - 1, day)
|
||||||
|
}
|
||||||
|
const monthLabel = computed(() => `${selectedDate.value.slice(0, 4)}年 ${Number(selectedDate.value.slice(5, 7))}月`)
|
||||||
|
const dateLabel = computed(() => `${isToday(selectedDate.value) ? '今天 · ' : ''}${Number(selectedDate.value.slice(5, 7))}月${Number(selectedDate.value.slice(8, 10))}日`)
|
||||||
|
const studentCount = computed(() => slots.value.reduce((sum, slot) => sum + slot.students.length, 0))
|
||||||
|
const weekDays = computed(() => {
|
||||||
|
const start = parseDate(selectedDate.value)
|
||||||
|
start.setDate(start.getDate() - (start.getDay() + 6) % 7)
|
||||||
|
return Array.from({ length: 7 }, (_, index) => {
|
||||||
|
const day = new Date(start)
|
||||||
|
day.setDate(start.getDate() + index)
|
||||||
|
const date = formatDate(day)
|
||||||
|
return { date, number: day.getDate(), label: getWeekdayLabel(date).replace('周', '').replace('星期', '') }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const weekLabel = computed(() => {
|
||||||
|
const first = parseDate(weekDays.value[0].date)
|
||||||
|
const last = parseDate(weekDays.value[6].date)
|
||||||
|
return `${first.getMonth() + 1}月${first.getDate()}日 — ${last.getMonth() + 1}月${last.getDate()}日`
|
||||||
|
})
|
||||||
|
|
||||||
|
onShow(() => { loadSchedule(selectedDate.value) })
|
||||||
|
|
||||||
|
function handlePickerChange(event: { detail: { value: string } }) {
|
||||||
|
selectDate(event.detail.value)
|
||||||
|
}
|
||||||
|
function shiftWeek(days: number) {
|
||||||
|
const date = parseDate(selectedDate.value)
|
||||||
|
date.setDate(date.getDate() + days)
|
||||||
|
selectDate(formatDate(date))
|
||||||
|
}
|
||||||
|
function handleScroll(event: { detail: { scrollTop: number } }) {
|
||||||
|
currentScrollTop = event.detail.scrollTop
|
||||||
|
}
|
||||||
|
async function selectDate(date: string) {
|
||||||
|
if (selectedDate.value === date) return
|
||||||
|
selectedDate.value = date
|
||||||
|
refreshing.value = false
|
||||||
|
scrollTop.value = currentScrollTop
|
||||||
|
await nextTick()
|
||||||
|
scrollTop.value = 0
|
||||||
|
loadSchedule(date)
|
||||||
|
}
|
||||||
|
async function handleRefresh() {
|
||||||
|
if (refreshing.value) return
|
||||||
|
refreshing.value = true
|
||||||
|
try { await loadSchedule(selectedDate.value) }
|
||||||
|
finally { refreshing.value = false }
|
||||||
|
}
|
||||||
|
async function loadSchedule(date: string) {
|
||||||
|
const id = ++requestId
|
||||||
|
error.value = ''
|
||||||
|
slots.value = []
|
||||||
|
if (!loggedIn.value || !isAdmin.value) { loading.value = false; return }
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const result = await bookingStore.fetchTeachingSchedule(date)
|
||||||
|
if (id === requestId) slots.value = [...result].sort((a, b) => a.startTime.localeCompare(b.startTime))
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (id === requestId) error.value = getErrorMessage(err, '请检查网络后重试')
|
||||||
|
} finally {
|
||||||
|
if (id === requestId) loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function formatPhone(phone: string) {
|
||||||
|
return /^\d{11}$/.test(phone) ? `${phone.slice(0, 3)} ${phone.slice(3, 7)} ${phone.slice(7)}` : phone
|
||||||
|
}
|
||||||
|
function contactStudent(phone: string) {
|
||||||
|
uni.makePhoneCall({ phoneNumber: phone })
|
||||||
|
}
|
||||||
|
const STATUS_LABELS: Record<BookingStatus, string> = {
|
||||||
|
[BookingStatus.PENDING_CONFIRMATION]: '待确认',
|
||||||
|
[BookingStatus.CONFIRMED]: '已确认',
|
||||||
|
[BookingStatus.CANCELLED]: '已取消',
|
||||||
|
[BookingStatus.COMPLETED]: '已完成',
|
||||||
|
[BookingStatus.NO_SHOW]: '未出席',
|
||||||
|
}
|
||||||
|
function statusLabel(status: BookingStatus) { return STATUS_LABELS[status] ?? status }
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.schedule-page {
|
||||||
|
height: 100vh;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #fbf9f6;
|
||||||
|
color: #514943;
|
||||||
|
}
|
||||||
|
button { margin: 0; padding: 0; background: transparent; font-weight: 400; border-radius: 0; &::after { border: none; } }
|
||||||
|
.calendar { flex-shrink: 0; padding: 22rpx 32rpx 20rpx; background: #fff; }
|
||||||
|
.calendar__heading { display: flex; align-items: center; justify-content: space-between; min-height: 76rpx; }
|
||||||
|
.calendar__month { padding: 16rpx 0; font-size: 36rpx; font-family: 'Songti SC', 'STSong', serif; }
|
||||||
|
.calendar__chevron { margin-left: 16rpx; font-size: 26rpx; color: #81776f; }
|
||||||
|
.text-button, .calendar__today { font-size: 24rpx; color: #526e62; }
|
||||||
|
.text-button { line-height: 76rpx; padding-left: 24rpx; }
|
||||||
|
.week-navigation { display: flex; align-items: center; justify-content: space-between; margin: 0 -12rpx 8rpx; }
|
||||||
|
.week-navigation__label { font-size: 23rpx; color: #81776f; }
|
||||||
|
.week-arrow { width: 80rpx; height: 76rpx; line-height: 70rpx; font-size: 40rpx; color: #70665e; }
|
||||||
|
.week { display: flex; justify-content: space-between; gap: 6rpx; }
|
||||||
|
.day { flex: 1; min-width: 0; height: 120rpx; display: flex; flex-direction: column; align-items: center; justify-content: center; line-height: 1; border-radius: 40rpx; color: #514943; }
|
||||||
|
.day__label { font-size: 22rpx; color: #81776f; }
|
||||||
|
.day__number { margin-top: 16rpx; font-size: 32rpx; font-variant-numeric: tabular-nums; }
|
||||||
|
.day__dot { margin-top: 10rpx; height: 6rpx; width: 6rpx; border-radius: 50%; background: transparent; }
|
||||||
|
.day--today .day__dot { background: #526e62; }
|
||||||
|
.day--selected { background: #526e62; color: #fff; .day__label { color: #fff; } .day__dot { background: transparent; } }
|
||||||
|
.day--selected.day--today .day__dot { background: #fff; }
|
||||||
|
.agenda-heading { flex-shrink: 0; display: flex; justify-content: space-between; align-items: baseline; gap: 16rpx; padding: 32rpx; border-top: 1rpx solid #eee9e2; }
|
||||||
|
.agenda-heading__date { font-size: 28rpx; font-weight: 500; }
|
||||||
|
.agenda-heading__count { font-size: 23rpx; color: #81776f; }
|
||||||
|
.schedule-scroll { flex: 1; min-height: 0; height: 0; }
|
||||||
|
.agenda { padding: 0 32rpx calc(40rpx + env(safe-area-inset-bottom)); }
|
||||||
|
.session { margin-bottom: 24rpx; padding: 0 28rpx; overflow: hidden; background: #fff; border: 1rpx solid #deded5; border-radius: 20rpx; }
|
||||||
|
.session__time { display: flex; align-items: baseline; gap: 20rpx; margin: 0 -28rpx; padding: 24rpx 28rpx; background: #eef2ed; border-bottom: 1rpx solid #dde4da; }
|
||||||
|
.session__start { display: block; font-size: 36rpx; font-variant-numeric: tabular-nums; font-weight: 500; }
|
||||||
|
.session__end { font-size: 24rpx; color: #687367; }
|
||||||
|
.session__roster { min-width: 0; }
|
||||||
|
.session__heading { display: flex; justify-content: space-between; padding: 24rpx 0 4rpx; font-size: 23rpx; color: #766d64; }
|
||||||
|
.student { padding: 24rpx 0 16rpx; border-bottom: 1rpx solid #e8e2da; }
|
||||||
|
.student:last-child { border-bottom: none; }
|
||||||
|
.student__headline { display: flex; align-items: baseline; justify-content: space-between; gap: 12rpx; }
|
||||||
|
.student__name { min-width: 0; font-size: 30rpx; font-weight: 500; line-height: 1.5; overflow-wrap: anywhere; word-break: break-all; }
|
||||||
|
.student__status { flex-shrink: 0; padding: 6rpx 12rpx; border-radius: 6rpx; background: #f2f0ec; font-size: 22rpx; line-height: 1.4; color: #766d64; }
|
||||||
|
.student__status--confirmed { background: #edf3ed; color: #526e62; }
|
||||||
|
.student__status--pending_confirmation { background: #f8f0e3; color: #956b37; }
|
||||||
|
.student__status--no_show { background: #f8eeea; color: #a06456; }
|
||||||
|
.student__contact { width: 100%; min-height: 76rpx; line-height: 1.4; display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8rpx; text-align: left; font-size: 23rpx; color: #81776f; font-variant-numeric: tabular-nums; }
|
||||||
|
.student__contact-label { color: #526e62; font-size: 22rpx; }
|
||||||
|
.student__no-phone { display: block; padding: 18rpx 0; font-size: 23rpx; color: #81776f; }
|
||||||
|
.agenda__end { display: block; padding: 12rpx 0 24rpx; text-align: center; color: #81776f; font-size: 21rpx; }
|
||||||
|
.empty { padding: 100rpx 48rpx 48rpx; display: flex; flex-direction: column; align-items: center; text-align: center; }
|
||||||
|
.empty__line { height: 48rpx; width: 1rpx; background: #b4c1b7; margin-bottom: 32rpx; }
|
||||||
|
.empty__title { font-family: 'Songti SC', 'STSong', serif; font-size: 34rpx; }
|
||||||
|
.empty__description { margin-top: 20rpx; font-size: 25rpx; color: #81776f; line-height: 1.7; }
|
||||||
|
.outline-button { margin-top: 36rpx; padding: 0 36rpx; min-height: 80rpx; line-height: 80rpx; border: 1rpx solid #bcc7bf; border-radius: 8rpx; color: #526e62; font-size: 25rpx; }
|
||||||
|
.skeleton { padding: 12rpx 32rpx; }
|
||||||
|
.skeleton__row { display: flex; gap: 24rpx; margin-bottom: 40rpx; }
|
||||||
|
.skeleton__time { width: 116rpx; height: 40rpx; background: #eae6df; border-radius: 4rpx; }
|
||||||
|
.skeleton__body { flex: 1; height: 180rpx; background: #eeebe5; border-radius: 4rpx; }
|
||||||
|
button:active { opacity: .7; }
|
||||||
|
</style>
|
||||||
491
packages/app/src/pages/teacher/detail.vue
Normal file
491
packages/app/src/pages/teacher/detail.vue
Normal file
@@ -0,0 +1,491 @@
|
|||||||
|
<template>
|
||||||
|
<view class="teacher-detail-page">
|
||||||
|
<CustomNavBar title="教练详情" show-back />
|
||||||
|
|
||||||
|
<scroll-view class="detail-scroll" scroll-y :style="{ height: scrollHeight }">
|
||||||
|
<view class="nav-spacer" :style="{ height: navBarHeight }" />
|
||||||
|
|
||||||
|
<view class="hero-section">
|
||||||
|
<image class="hero-image" :src="teacher.cover" mode="widthFix" />
|
||||||
|
<view class="hero-overlay" />
|
||||||
|
|
||||||
|
<view class="hero-content">
|
||||||
|
<view class="hero-badges">
|
||||||
|
<text v-for="item in teacher.badges" :key="item" class="hero-badge">{{ item }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="hero-name">{{ teacher.name }}</text>
|
||||||
|
<text class="hero-title">{{ teacher.title }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="content-wrap">
|
||||||
|
<view class="summary-card">
|
||||||
|
<view class="summary-head">
|
||||||
|
<view>
|
||||||
|
<text class="summary-eyebrow">Private Pilates Coach</text>
|
||||||
|
<text class="summary-title">为你定制更细腻的训练节奏</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="specialty-row">
|
||||||
|
<text v-for="item in teacher.specialties" :key="item" class="specialty-pill">{{ item }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<text class="summary-intro">{{ teacher.intro }}</text>
|
||||||
|
|
||||||
|
<view class="stats-grid">
|
||||||
|
<view v-for="item in teacher.stats" :key="item.label" class="stat-item">
|
||||||
|
<text class="stat-value">{{ item.value }}</text>
|
||||||
|
<text class="stat-label">{{ item.label }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="detail-card">
|
||||||
|
<text class="card-title">教练介绍</text>
|
||||||
|
<text class="card-text card-text--intro">
|
||||||
|
我是 Iris,一名注重体态、控制与身体感受的普拉提教练。
|
||||||
|
</text>
|
||||||
|
<text class="card-text">
|
||||||
|
我希望带你在稳定、安心的节奏里,找回核心力量、挺拔线条和更轻松的身体状态。
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="detail-card">
|
||||||
|
<text class="card-title">认证背景</text>
|
||||||
|
<view v-for="item in teacher.certifications" :key="item" class="list-row">
|
||||||
|
<view class="list-dot" />
|
||||||
|
<text class="list-text">{{ item }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="detail-card">
|
||||||
|
<text class="card-title">授课重点</text>
|
||||||
|
<view class="focus-grid">
|
||||||
|
<view v-for="item in teacher.teachingFocus" :key="item.title" class="focus-item">
|
||||||
|
<text class="focus-title">{{ item.title }}</text>
|
||||||
|
<text class="focus-desc">{{ item.desc }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="detail-card cta-card">
|
||||||
|
<text class="card-title">适合这样的你</text>
|
||||||
|
<view class="fit-grid">
|
||||||
|
<view class="fit-pill">久坐肩颈紧张</view>
|
||||||
|
<view class="fit-pill">体态调整</view>
|
||||||
|
<view class="fit-pill">产后恢复</view>
|
||||||
|
<view class="fit-pill">塑形紧致</view>
|
||||||
|
<view class="fit-pill">核心无力</view>
|
||||||
|
<view class="fit-pill">运动入门</view>
|
||||||
|
</view>
|
||||||
|
<text class="card-text">
|
||||||
|
如果你想改善体态、缓解肩颈腰背不适,提升核心稳定、线条感与身体控制力,这里会是一个很好的开始。
|
||||||
|
</text>
|
||||||
|
<view class="cta-inline" @tap="goToBooking">
|
||||||
|
<text class="cta-inline-text">去约 Iris 的课程</text>
|
||||||
|
<text class="cta-inline-arrow">›</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="bottom-space" />
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
|
||||||
|
<view class="bottom-bar">
|
||||||
|
<view class="bottom-note">
|
||||||
|
<text class="bottom-note-title">1 对 1 私教课</text>
|
||||||
|
<text class="bottom-note-sub">60 分钟 · 进入约课页选择时段</text>
|
||||||
|
</view>
|
||||||
|
<view class="booking-btn" @tap="goToBooking">
|
||||||
|
<text class="booking-btn-text">立即预约</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { onLoad, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||||
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
|
import { irisProfile } from '../../utils/teacher'
|
||||||
|
import { getSystemLayout } from '../../utils/system'
|
||||||
|
|
||||||
|
const teacher = irisProfile
|
||||||
|
const navBarHeight = ref('64px')
|
||||||
|
const scrollHeight = ref('500px')
|
||||||
|
|
||||||
|
onShareAppMessage(() => {
|
||||||
|
return {
|
||||||
|
title: `${teacher.name}|${teacher.title}`,
|
||||||
|
path: `/pages/teacher/detail?id=${teacher.id}`,
|
||||||
|
imageUrl: teacher.cover,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onShareTimeline(() => {
|
||||||
|
return {
|
||||||
|
title: `${teacher.name}|${teacher.title}`,
|
||||||
|
query: `id=${teacher.id}`,
|
||||||
|
imageUrl: teacher.cover,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onLoad(() => {
|
||||||
|
updateLayout()
|
||||||
|
})
|
||||||
|
|
||||||
|
function updateLayout() {
|
||||||
|
const { navBarHeight: navBarPx } = getSystemLayout()
|
||||||
|
const { windowHeight } = uni.getWindowInfo()
|
||||||
|
navBarHeight.value = `${navBarPx}px`
|
||||||
|
scrollHeight.value = `${windowHeight}px`
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToBooking() {
|
||||||
|
uni.switchTab({ url: '/pages/booking/index' })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.teacher-detail-page {
|
||||||
|
height: 100vh;
|
||||||
|
background: #f7f2ee;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-spacer {
|
||||||
|
width: 100%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-section {
|
||||||
|
position: relative;
|
||||||
|
height: 580rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-image {
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(33, 24, 22, 0.08) 0%, rgba(33, 24, 22, 0.08) 34%, rgba(33, 24, 22, 0.64) 100%),
|
||||||
|
radial-gradient(circle at top right, rgba(255, 228, 208, 0.26), transparent 28%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-content {
|
||||||
|
position: absolute;
|
||||||
|
left: 32rpx;
|
||||||
|
right: 32rpx;
|
||||||
|
bottom: 92rpx;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-badges {
|
||||||
|
display: flex;
|
||||||
|
gap: 10rpx;
|
||||||
|
margin-bottom: 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-badge {
|
||||||
|
height: 42rpx;
|
||||||
|
padding: 0 16rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: rgba(255, 255, 255, 0.18);
|
||||||
|
border: 1rpx solid rgba(255, 255, 255, 0.24);
|
||||||
|
color: #fff8f3;
|
||||||
|
font-size: 20rpx;
|
||||||
|
backdrop-filter: blur(10rpx);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-name {
|
||||||
|
display: block;
|
||||||
|
font-size: 56rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ffffff;
|
||||||
|
line-height: 1.05;
|
||||||
|
margin-bottom: 10rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-title {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: rgba(255, 247, 241, 0.88);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-scroll {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-wrap {
|
||||||
|
position: relative;
|
||||||
|
margin-top: -8rpx;
|
||||||
|
padding: 0 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card,
|
||||||
|
.detail-card {
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
border-radius: 28rpx;
|
||||||
|
box-shadow: 0 16rpx 36rpx rgba(120, 91, 79, 0.08);
|
||||||
|
backdrop-filter: blur(12rpx);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card {
|
||||||
|
padding: 28rpx;
|
||||||
|
margin-bottom: 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-head {
|
||||||
|
margin-bottom: 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-eyebrow {
|
||||||
|
display: block;
|
||||||
|
font-size: 20rpx;
|
||||||
|
color: #c09a89;
|
||||||
|
letter-spacing: 3rpx;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-title {
|
||||||
|
font-size: 34rpx;
|
||||||
|
line-height: 1.35;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #2f2622;
|
||||||
|
}
|
||||||
|
|
||||||
|
.specialty-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10rpx;
|
||||||
|
margin-bottom: 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.specialty-pill {
|
||||||
|
padding: 10rpx 18rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
background: rgba(92, 126, 151, 0.12);
|
||||||
|
color: #56778a;
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-intro,
|
||||||
|
.card-text,
|
||||||
|
.list-text {
|
||||||
|
font-size: 25rpx;
|
||||||
|
line-height: 1.78;
|
||||||
|
color: #5f534c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 14rpx;
|
||||||
|
margin-top: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
padding: 20rpx 16rpx;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
background: linear-gradient(180deg, #fff8f4 0%, #f7f1ec 100%);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
display: block;
|
||||||
|
font-size: 24rpx;
|
||||||
|
line-height: 1.45;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #3f3029;
|
||||||
|
margin-bottom: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 20rpx;
|
||||||
|
color: #9c8579;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-card {
|
||||||
|
padding: 28rpx;
|
||||||
|
margin-bottom: 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
display: block;
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #342925;
|
||||||
|
margin-bottom: 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-text + .card-text {
|
||||||
|
margin-top: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-text--intro {
|
||||||
|
font-size: 28rpx;
|
||||||
|
line-height: 1.7;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #43352f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 14rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-row + .list-row {
|
||||||
|
margin-top: 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-dot {
|
||||||
|
width: 14rpx;
|
||||||
|
height: 14rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-top: 14rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: linear-gradient(135deg, #ff8b69 0%, #ff593f 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 14rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-item {
|
||||||
|
min-height: 164rpx;
|
||||||
|
border-radius: 22rpx;
|
||||||
|
padding: 22rpx 18rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: flex-start;
|
||||||
|
background: linear-gradient(135deg, #fff8f5 0%, #f6eee9 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-title {
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #5c4a42;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-desc {
|
||||||
|
margin-top: 10rpx;
|
||||||
|
font-size: 21rpx;
|
||||||
|
line-height: 1.65;
|
||||||
|
color: #8a7569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-card {
|
||||||
|
background: linear-gradient(135deg, #fff6f1 0%, #fffdfc 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fit-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12rpx;
|
||||||
|
margin-bottom: 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fit-pill {
|
||||||
|
padding: 10rpx 18rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
background: rgba(255, 126, 92, 0.12);
|
||||||
|
color: #b3573f;
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-inline {
|
||||||
|
margin-top: 22rpx;
|
||||||
|
height: 88rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
padding: 0 28rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
background: linear-gradient(135deg, #ff7f5e 0%, #ff543c 100%);
|
||||||
|
box-shadow: 0 16rpx 28rpx rgba(255, 95, 73, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-inline-text,
|
||||||
|
.cta-inline-arrow {
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-inline-text {
|
||||||
|
font-size: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-inline-arrow {
|
||||||
|
font-size: 38rpx;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-space {
|
||||||
|
height: 160rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-bar {
|
||||||
|
position: fixed;
|
||||||
|
left: 24rpx;
|
||||||
|
right: 24rpx;
|
||||||
|
bottom: 24rpx;
|
||||||
|
z-index: 20;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 18rpx;
|
||||||
|
padding: 18rpx;
|
||||||
|
border-radius: 28rpx;
|
||||||
|
background: rgba(35, 29, 27, 0.9);
|
||||||
|
backdrop-filter: blur(18rpx);
|
||||||
|
box-shadow: 0 20rpx 48rpx rgba(36, 28, 26, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-note {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-note-title {
|
||||||
|
display: block;
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fffaf6;
|
||||||
|
margin-bottom: 6rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-note-sub {
|
||||||
|
font-size: 20rpx;
|
||||||
|
color: rgba(255, 244, 236, 0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
.booking-btn {
|
||||||
|
width: 224rpx;
|
||||||
|
height: 88rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, #ff815e 0%, #ff5136 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.booking-btn-text {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ffffff;
|
||||||
|
letter-spacing: 1rpx;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
BIN
packages/app/src/static/courseBg.png
Normal file
BIN
packages/app/src/static/courseBg.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 KiB |
BIN
packages/app/src/static/default-avatar.jpg
Normal file
BIN
packages/app/src/static/default-avatar.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 135 KiB |
@@ -2,8 +2,6 @@ import { defineStore } from 'pinia'
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { get, post, put, del } from '../utils/request'
|
import { get, post, put, del } from '../utils/request'
|
||||||
import type {
|
import type {
|
||||||
WeekTemplate,
|
|
||||||
WeekTemplateInput,
|
|
||||||
CardType,
|
CardType,
|
||||||
CreateCardTypeDto,
|
CreateCardTypeDto,
|
||||||
UpdateCardTypeDto,
|
UpdateCardTypeDto,
|
||||||
@@ -13,40 +11,78 @@ import type {
|
|||||||
TimeSlot,
|
TimeSlot,
|
||||||
CreateManualSlotDto,
|
CreateManualSlotDto,
|
||||||
PaginatedData,
|
PaginatedData,
|
||||||
|
ScheduleSlotPreview,
|
||||||
|
PublishDaySlotsDto,
|
||||||
|
FlashSaleAdminItem,
|
||||||
|
CreateFlashSaleDto,
|
||||||
|
UpdateFlashSaleDto,
|
||||||
|
CreateStudioUploadCredentialDto,
|
||||||
|
StudioUploadCredential,
|
||||||
|
AdminMemberSummary,
|
||||||
|
AdminMemberDetail,
|
||||||
|
LessonSupplementRecord,
|
||||||
|
CreateLessonSupplementDto,
|
||||||
|
UpdateAdminMemberProfileDto,
|
||||||
|
AdminArrangeBookingDto,
|
||||||
|
MembershipWithCardType,
|
||||||
|
BookingWithDetails,
|
||||||
} from '@mp-pilates/shared'
|
} from '@mp-pilates/shared'
|
||||||
|
|
||||||
|
interface LegacyPaginatedData<T> {
|
||||||
|
readonly data: readonly T[]
|
||||||
|
readonly total: number
|
||||||
|
readonly page: number
|
||||||
|
readonly limit: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePaginatedData<T>(
|
||||||
|
result: PaginatedData<T> | LegacyPaginatedData<T>,
|
||||||
|
): PaginatedData<T> {
|
||||||
|
if ('items' in result) {
|
||||||
|
return {
|
||||||
|
items: [...result.items],
|
||||||
|
total: result.total,
|
||||||
|
page: result.page,
|
||||||
|
limit: result.limit,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: [...result.data],
|
||||||
|
total: result.total,
|
||||||
|
page: result.page,
|
||||||
|
limit: result.limit,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface AdminStats {
|
export interface AdminStats {
|
||||||
todayBookings: number
|
todayBookings: number
|
||||||
totalOrders: number
|
totalOrders: number
|
||||||
totalBookings: number
|
totalBookings: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MemberSummary {
|
export type MemberSummary = AdminMemberSummary
|
||||||
|
|
||||||
|
export interface UserMembership {
|
||||||
userId: string
|
userId: string
|
||||||
nickname: string
|
membership: {
|
||||||
phone: string | null
|
id: string
|
||||||
avatarUrl: string | null
|
cardTypeId: string
|
||||||
totalBookings: number
|
remainingTimes: number | null
|
||||||
completedBookings: number
|
startDate: string
|
||||||
cancelledBookings: number
|
expireDate: string
|
||||||
|
status: string
|
||||||
|
cardType: {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
type: string
|
||||||
|
totalTimes: number | null
|
||||||
|
durationDays: number
|
||||||
|
}
|
||||||
|
} | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAdminStore = defineStore('admin', () => {
|
export const useAdminStore = defineStore('admin', () => {
|
||||||
// ── Week templates ───────────────────────────────────────────────
|
|
||||||
const weekTemplates = ref<WeekTemplate[]>([])
|
|
||||||
|
|
||||||
async function fetchWeekTemplates(): Promise<WeekTemplate[]> {
|
|
||||||
const data = await get<WeekTemplate[]>('/admin/week-template')
|
|
||||||
weekTemplates.value = data
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveWeekTemplates(templates: WeekTemplateInput[]): Promise<WeekTemplate[]> {
|
|
||||||
const data = await put<WeekTemplate[]>('/admin/week-template', templates)
|
|
||||||
weekTemplates.value = data
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Card types ───────────────────────────────────────────────────
|
// ── Card types ───────────────────────────────────────────────────
|
||||||
const cardTypes = ref<CardType[]>([])
|
const cardTypes = ref<CardType[]>([])
|
||||||
|
|
||||||
@@ -57,20 +93,21 @@ export const useAdminStore = defineStore('admin', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function createCardType(dto: CreateCardTypeDto): Promise<CardType> {
|
async function createCardType(dto: CreateCardTypeDto): Promise<CardType> {
|
||||||
const data = await post<CardType>('/admin/card-types', dto)
|
const data = await post<CardType>('/admin/card-types', dto as unknown as Record<string, unknown>)
|
||||||
await fetchCardTypes()
|
await fetchCardTypes()
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateCardType(id: string, dto: UpdateCardTypeDto): Promise<CardType> {
|
async function updateCardType(id: string, dto: UpdateCardTypeDto): Promise<CardType> {
|
||||||
const data = await put<CardType>(`/admin/card-types/${id}`, dto)
|
const data = await put<CardType>(`/admin/card-types/${id}`, dto as unknown as Record<string, unknown>)
|
||||||
await fetchCardTypes()
|
await fetchCardTypes()
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteCardType(id: string): Promise<void> {
|
async function deleteCardType(id: string): Promise<{ deleted: boolean; deactivated: boolean }> {
|
||||||
await del(`/admin/card-types/${id}`)
|
const result = await del<{ deleted: boolean; deactivated: boolean }>(`/admin/card-types/${id}`)
|
||||||
await fetchCardTypes()
|
await fetchCardTypes()
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Studio config ────────────────────────────────────────────────
|
// ── Studio config ────────────────────────────────────────────────
|
||||||
@@ -83,18 +120,31 @@ export const useAdminStore = defineStore('admin', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function saveStudioConfig(dto: UpdateStudioConfigDto): Promise<StudioConfig> {
|
async function saveStudioConfig(dto: UpdateStudioConfigDto): Promise<StudioConfig> {
|
||||||
const data = await put<StudioConfig>('/admin/studio/info', dto)
|
const data = await put<StudioConfig>('/admin/studio/info', dto as unknown as Record<string, unknown>)
|
||||||
studioConfig.value = data
|
studioConfig.value = data
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function createStudioUploadCredential(
|
||||||
|
dto: CreateStudioUploadCredentialDto,
|
||||||
|
): Promise<StudioUploadCredential> {
|
||||||
|
return post<StudioUploadCredential>(
|
||||||
|
'/admin/studio/upload-credentials',
|
||||||
|
dto as unknown as Record<string, unknown>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Orders ───────────────────────────────────────────────────────
|
// ── Orders ───────────────────────────────────────────────────────
|
||||||
async function fetchAdminOrders(params: {
|
async function fetchAdminOrders(params: {
|
||||||
page?: number
|
page?: number
|
||||||
limit?: number
|
limit?: number
|
||||||
status?: string
|
status?: string
|
||||||
}): Promise<PaginatedData<OrderWithDetails>> {
|
}): Promise<PaginatedData<OrderWithDetails>> {
|
||||||
return get<PaginatedData<OrderWithDetails>>('/admin/orders', params)
|
const result = await get<PaginatedData<OrderWithDetails> | LegacyPaginatedData<OrderWithDetails>>(
|
||||||
|
'/admin/orders',
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
return normalizePaginatedData(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Bookings ─────────────────────────────────────────────────────
|
// ── Bookings ─────────────────────────────────────────────────────
|
||||||
@@ -111,8 +161,64 @@ export const useAdminStore = defineStore('admin', () => {
|
|||||||
page?: number
|
page?: number
|
||||||
limit?: number
|
limit?: number
|
||||||
search?: string
|
search?: string
|
||||||
|
cardType?: string
|
||||||
}): Promise<PaginatedData<MemberSummary>> {
|
}): Promise<PaginatedData<MemberSummary>> {
|
||||||
return get<PaginatedData<MemberSummary>>('/admin/members', params)
|
const cleanParams: Record<string, unknown> = {}
|
||||||
|
if (params?.page != null) cleanParams.page = params.page
|
||||||
|
if (params?.limit != null) cleanParams.limit = params.limit
|
||||||
|
if (params?.search) cleanParams.search = params.search
|
||||||
|
if (params?.cardType) cleanParams.cardType = params.cardType
|
||||||
|
return get<PaginatedData<MemberSummary>>('/admin/members', cleanParams)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getUserMembership(userId: string): Promise<UserMembership> {
|
||||||
|
return get<UserMembership>(`/admin/members/${userId}/membership`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchLessonSupplements(userId: string): Promise<LessonSupplementRecord[]> {
|
||||||
|
return get<LessonSupplementRecord[]>(`/admin/members/${userId}/lesson-supplements`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createLessonSupplement(userId: string, dto: CreateLessonSupplementDto): Promise<LessonSupplementRecord> {
|
||||||
|
return post<LessonSupplementRecord>(`/admin/members/${userId}/lesson-supplements`, { ...dto })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revokeLessonSupplement(userId: string, id: string): Promise<void> {
|
||||||
|
await post(`/admin/members/${userId}/lesson-supplements/${id}/revoke`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchMemberDetail(userId: string): Promise<AdminMemberDetail> {
|
||||||
|
return get<AdminMemberDetail>(`/admin/members/${userId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateMemberProfile(
|
||||||
|
userId: string,
|
||||||
|
dto: UpdateAdminMemberProfileDto,
|
||||||
|
): Promise<AdminMemberDetail> {
|
||||||
|
return put<AdminMemberDetail>(`/admin/members/${userId}`, dto as unknown as Record<string, unknown>)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function arrangeMemberBooking(dto: AdminArrangeBookingDto): Promise<BookingWithDetails> {
|
||||||
|
return post<BookingWithDetails>('/admin/bookings', dto as unknown as Record<string, unknown>)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateUserMembership(
|
||||||
|
userId: string,
|
||||||
|
dto: {
|
||||||
|
membershipId?: string
|
||||||
|
cardTypeId: string
|
||||||
|
remainingTimes?: number | null
|
||||||
|
startDate: string
|
||||||
|
expireDate: string
|
||||||
|
},
|
||||||
|
): Promise<MembershipWithCardType> {
|
||||||
|
return put<MembershipWithCardType>(`/admin/members/${userId}/membership`, dto)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteUserMembership(userId: string, membershipId: string): Promise<void> {
|
||||||
|
return del<void>(
|
||||||
|
`/admin/members/${userId}/membership?membershipId=${encodeURIComponent(membershipId)}`,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Time slots ───────────────────────────────────────────────────
|
// ── Time slots ───────────────────────────────────────────────────
|
||||||
@@ -121,7 +227,7 @@ export const useAdminStore = defineStore('admin', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function createManualSlot(dto: CreateManualSlotDto): Promise<TimeSlot> {
|
async function createManualSlot(dto: CreateManualSlotDto): Promise<TimeSlot> {
|
||||||
return post<TimeSlot>('/admin/time-slot/manual', dto)
|
return post<TimeSlot>('/admin/time-slot/manual', dto as unknown as Record<string, unknown>)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function closeSlot(id: string): Promise<TimeSlot> {
|
async function closeSlot(id: string): Promise<TimeSlot> {
|
||||||
@@ -132,19 +238,61 @@ export const useAdminStore = defineStore('admin', () => {
|
|||||||
return post<{ count: number }>('/admin/generate-slots', { startDate, endDate })
|
return post<{ count: number }>('/admin/generate-slots', { startDate, endDate })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Schedule management ─────────────────────────────────────────
|
||||||
|
const schedulePreview = ref<ScheduleSlotPreview[]>([])
|
||||||
|
const scheduleLoading = ref(false)
|
||||||
|
|
||||||
|
async function fetchSchedulePreview(date: string): Promise<ScheduleSlotPreview[]> {
|
||||||
|
scheduleLoading.value = true
|
||||||
|
try {
|
||||||
|
const data = await previewScheduleByDate(date)
|
||||||
|
schedulePreview.value = data
|
||||||
|
return data
|
||||||
|
} finally {
|
||||||
|
scheduleLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function previewScheduleByDate(date: string): Promise<ScheduleSlotPreview[]> {
|
||||||
|
return get<ScheduleSlotPreview[]>('/admin/schedule/preview', { date })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function publishDaySlots(dto: PublishDaySlotsDto): Promise<void> {
|
||||||
|
await post('/admin/schedule/publish', dto as unknown as Record<string, unknown>)
|
||||||
|
await fetchSchedulePreview(dto.date)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Dashboard stats ──────────────────────────────────────────────
|
// ── Dashboard stats ──────────────────────────────────────────────
|
||||||
async function fetchDashboardStats(): Promise<AdminStats> {
|
async function fetchDashboardStats(): Promise<AdminStats> {
|
||||||
return get<AdminStats>('/admin/stats')
|
return get<AdminStats>('/admin/stats')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Flash sales ─────────────────────────────────────────────────
|
||||||
|
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}`)
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// State
|
// State
|
||||||
weekTemplates,
|
|
||||||
cardTypes,
|
cardTypes,
|
||||||
studioConfig,
|
studioConfig,
|
||||||
// Week templates
|
schedulePreview,
|
||||||
fetchWeekTemplates,
|
scheduleLoading,
|
||||||
saveWeekTemplates,
|
|
||||||
// Card types
|
// Card types
|
||||||
fetchCardTypes,
|
fetchCardTypes,
|
||||||
createCardType,
|
createCardType,
|
||||||
@@ -153,18 +301,37 @@ export const useAdminStore = defineStore('admin', () => {
|
|||||||
// Studio
|
// Studio
|
||||||
fetchStudioConfig,
|
fetchStudioConfig,
|
||||||
saveStudioConfig,
|
saveStudioConfig,
|
||||||
|
createStudioUploadCredential,
|
||||||
// Orders
|
// Orders
|
||||||
fetchAdminOrders,
|
fetchAdminOrders,
|
||||||
// Bookings
|
// Bookings
|
||||||
fetchAdminBookings,
|
fetchAdminBookings,
|
||||||
// Members
|
// Members
|
||||||
fetchMembers,
|
fetchMembers,
|
||||||
|
fetchMemberDetail,
|
||||||
|
fetchLessonSupplements,
|
||||||
|
createLessonSupplement,
|
||||||
|
revokeLessonSupplement,
|
||||||
|
updateMemberProfile,
|
||||||
|
arrangeMemberBooking,
|
||||||
|
getUserMembership,
|
||||||
|
updateUserMembership,
|
||||||
|
deleteUserMembership,
|
||||||
// Time slots
|
// Time slots
|
||||||
fetchSlotsByDate,
|
fetchSlotsByDate,
|
||||||
createManualSlot,
|
createManualSlot,
|
||||||
closeSlot,
|
closeSlot,
|
||||||
generateSlots,
|
generateSlots,
|
||||||
|
// Schedule
|
||||||
|
fetchSchedulePreview,
|
||||||
|
previewScheduleByDate,
|
||||||
|
publishDaySlots,
|
||||||
// Stats
|
// Stats
|
||||||
fetchDashboardStats,
|
fetchDashboardStats,
|
||||||
|
// Flash sales
|
||||||
|
fetchFlashSales,
|
||||||
|
createFlashSale,
|
||||||
|
updateFlashSale,
|
||||||
|
deleteFlashSale,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,16 +3,29 @@ import { ref } from 'vue'
|
|||||||
import type {
|
import type {
|
||||||
TimeSlotWithBookingStatus,
|
TimeSlotWithBookingStatus,
|
||||||
BookingWithDetails,
|
BookingWithDetails,
|
||||||
|
BookingWithUser,
|
||||||
|
BookingStatusHistory,
|
||||||
CreateBookingDto,
|
CreateBookingDto,
|
||||||
|
TeachingScheduleSlot,
|
||||||
} from '@mp-pilates/shared'
|
} from '@mp-pilates/shared'
|
||||||
import { get, post, put } from '../utils/request'
|
import { get, post, put } from '../utils/request'
|
||||||
|
|
||||||
|
/** Server paginated responses use `data` field, not `items` from the shared type */
|
||||||
|
interface ServerPaginatedResult<T> {
|
||||||
|
readonly data: readonly T[]
|
||||||
|
readonly total: number
|
||||||
|
readonly page: number
|
||||||
|
readonly limit: number
|
||||||
|
}
|
||||||
|
|
||||||
export const useBookingStore = defineStore('booking', () => {
|
export const useBookingStore = defineStore('booking', () => {
|
||||||
const slots = ref<readonly TimeSlotWithBookingStatus[]>([])
|
const slots = ref<readonly TimeSlotWithBookingStatus[]>([])
|
||||||
const myBookings = ref<readonly BookingWithDetails[]>([])
|
const myBookings = ref<readonly BookingWithDetails[]>([])
|
||||||
const upcomingBookings = ref<readonly BookingWithDetails[]>([])
|
const upcomingBookings = ref<readonly BookingWithDetails[]>([])
|
||||||
|
const teachingSchedule = ref<readonly TeachingScheduleSlot[]>([])
|
||||||
const loadingSlots = ref(false)
|
const loadingSlots = ref(false)
|
||||||
const loadingBookings = ref(false)
|
const loadingBookings = ref(false)
|
||||||
|
const loadingTeachingSchedule = ref(false)
|
||||||
|
|
||||||
async function fetchSlots(date: string) {
|
async function fetchSlots(date: string) {
|
||||||
loadingSlots.value = true
|
loadingSlots.value = true
|
||||||
@@ -31,41 +44,138 @@ export const useBookingStore = defineStore('booking', () => {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace a booking in `myBookings` by id. Preserves immutability: always
|
||||||
|
* returns a new array reference so Vue's computed/watchers pick up the change.
|
||||||
|
* If the booking isn't in the list (e.g. paginated out), leaves state untouched.
|
||||||
|
*/
|
||||||
|
function replaceBooking(updated: BookingWithDetails) {
|
||||||
|
const idx = myBookings.value.findIndex((b) => b.id === updated.id)
|
||||||
|
if (idx === -1) return
|
||||||
|
const next = myBookings.value.slice()
|
||||||
|
next[idx] = updated
|
||||||
|
myBookings.value = next
|
||||||
|
}
|
||||||
|
|
||||||
async function cancelBooking(bookingId: string) {
|
async function cancelBooking(bookingId: string) {
|
||||||
const result = await put<BookingWithDetails>(`/booking/${bookingId}/cancel`)
|
const result = await put<BookingWithDetails>(`/booking/${bookingId}/cancel`)
|
||||||
|
replaceBooking(result)
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchMyBookings(status?: string) {
|
async function fetchMyBookings(status?: string, opts: { silent?: boolean } = {}) {
|
||||||
loadingBookings.value = true
|
if (!opts.silent) loadingBookings.value = true
|
||||||
try {
|
try {
|
||||||
const params = status ? { status } : {}
|
const params: Record<string, unknown> = status ? { status } : {}
|
||||||
myBookings.value = await get<BookingWithDetails[]>('/booking/my', params)
|
const paginated = await get<ServerPaginatedResult<BookingWithDetails>>('/booking/my', params)
|
||||||
|
myBookings.value = Array.isArray(paginated.data) ? paginated.data : []
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Fetch bookings failed:', err)
|
console.error('Fetch bookings failed:', err)
|
||||||
|
myBookings.value = []
|
||||||
} finally {
|
} finally {
|
||||||
loadingBookings.value = false
|
if (!opts.silent) loadingBookings.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchUpcomingBookings() {
|
async function fetchUpcomingBookings() {
|
||||||
try {
|
try {
|
||||||
upcomingBookings.value = await get<BookingWithDetails[]>('/booking/my/upcoming')
|
const result = await get<BookingWithDetails[]>('/booking/my/upcoming')
|
||||||
|
upcomingBookings.value = Array.isArray(result) ? result : []
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Fetch upcoming bookings failed:', err)
|
console.error('Fetch upcoming bookings failed:', err)
|
||||||
|
upcomingBookings.value = []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchTeachingSchedule(date: string) {
|
||||||
|
loadingTeachingSchedule.value = true
|
||||||
|
try {
|
||||||
|
const result = await get<TeachingScheduleSlot[]>('/admin/teaching-schedule', { date })
|
||||||
|
teachingSchedule.value = Array.isArray(result) ? result : []
|
||||||
|
return teachingSchedule.value
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Fetch teaching schedule failed:', err)
|
||||||
|
teachingSchedule.value = []
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
loadingTeachingSchedule.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Admin methods ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function fetchAllAdminBookings(
|
||||||
|
page = 1,
|
||||||
|
limit = 20,
|
||||||
|
status?: string,
|
||||||
|
): Promise<ServerPaginatedResult<BookingWithUser>> {
|
||||||
|
const params: Record<string, unknown> = { page, limit }
|
||||||
|
if (status) params.status = status
|
||||||
|
|
||||||
|
const paginated = await get<ServerPaginatedResult<BookingWithUser>>('/admin/bookings', params)
|
||||||
|
return paginated
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmBooking(bookingId: string, remark?: string) {
|
||||||
|
const result = await put<BookingWithDetails>(`/booking/${bookingId}/confirm`, {
|
||||||
|
remark,
|
||||||
|
})
|
||||||
|
replaceBooking(result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
async function completeBooking(bookingId: string, remark?: string) {
|
||||||
|
const result = await put<BookingWithDetails>(`/booking/${bookingId}/complete`, {
|
||||||
|
remark,
|
||||||
|
})
|
||||||
|
replaceBooking(result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markNoShow(bookingId: string, remark?: string) {
|
||||||
|
const result = await put<BookingWithDetails>(`/booking/${bookingId}/noshow`, {
|
||||||
|
remark,
|
||||||
|
})
|
||||||
|
replaceBooking(result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchBookingHistory(bookingId: string): Promise<BookingStatusHistory[]> {
|
||||||
|
const result = await get<BookingStatusHistory[]>(`/booking/${bookingId}/history`)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchBookingById(bookingId: string) {
|
||||||
|
const result = await get<BookingWithDetails | BookingWithUser>(`/booking/${bookingId}`)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchSlotById(slotId: string) {
|
||||||
|
const result = await get<TimeSlotWithBookingStatus>(`/time-slot/${slotId}`)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
slots,
|
slots,
|
||||||
myBookings,
|
myBookings,
|
||||||
upcomingBookings,
|
upcomingBookings,
|
||||||
|
teachingSchedule,
|
||||||
loadingSlots,
|
loadingSlots,
|
||||||
loadingBookings,
|
loadingBookings,
|
||||||
|
loadingTeachingSchedule,
|
||||||
fetchSlots,
|
fetchSlots,
|
||||||
createBooking,
|
createBooking,
|
||||||
cancelBooking,
|
cancelBooking,
|
||||||
fetchMyBookings,
|
fetchMyBookings,
|
||||||
fetchUpcomingBookings,
|
fetchUpcomingBookings,
|
||||||
|
fetchTeachingSchedule,
|
||||||
|
fetchAllAdminBookings,
|
||||||
|
confirmBooking,
|
||||||
|
completeBooking,
|
||||||
|
markNoShow,
|
||||||
|
fetchBookingHistory,
|
||||||
|
fetchSlotById,
|
||||||
|
fetchBookingById,
|
||||||
|
replaceBooking,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
43
packages/app/src/stores/flash-sale.ts
Normal file
43
packages/app/src/stores/flash-sale.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
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,
|
||||||
|
}
|
||||||
|
})
|
||||||
26
packages/app/src/stores/invite.ts
Normal file
26
packages/app/src/stores/invite.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import type { InviteActivitySummary } from '@mp-pilates/shared'
|
||||||
|
import { get } from '../utils/request'
|
||||||
|
|
||||||
|
export const useInviteStore = defineStore('invite', () => {
|
||||||
|
const activity = ref<InviteActivitySummary | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
async function fetchActivity() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
activity.value = await get<InviteActivitySummary>('/invite/activity')
|
||||||
|
return activity.value
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
activity,
|
||||||
|
loading,
|
||||||
|
fetchActivity,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
@@ -5,9 +5,20 @@ import type {
|
|||||||
UserStatsResponse,
|
UserStatsResponse,
|
||||||
MembershipWithCardType,
|
MembershipWithCardType,
|
||||||
} from '@mp-pilates/shared'
|
} from '@mp-pilates/shared'
|
||||||
import { UserRole, MembershipStatus } from '@mp-pilates/shared'
|
import {
|
||||||
|
UserRole,
|
||||||
|
MembershipStatus,
|
||||||
|
getMembershipRenewalHint,
|
||||||
|
} from '@mp-pilates/shared'
|
||||||
import { wxLogin, isLoggedIn, logout as authLogout } from '../utils/auth'
|
import { wxLogin, isLoggedIn, logout as authLogout } from '../utils/auth'
|
||||||
|
import { setUnauthorizedHandler } from '../utils/session'
|
||||||
import { get, put } from '../utils/request'
|
import { get, put } from '../utils/request'
|
||||||
|
import { ROUTES } from '../utils/routes'
|
||||||
|
import { cacheSubscriptionMessageTemplateConfig, resetSubscriptionMessageTemplateCache } from '../utils/wechat-subscription'
|
||||||
|
|
||||||
|
function syncSubscriptionTemplates(profile?: Pick<UserProfileResponse, 'subscriptionMessageTemplates'> | null) {
|
||||||
|
cacheSubscriptionMessageTemplateConfig(profile?.subscriptionMessageTemplates)
|
||||||
|
}
|
||||||
|
|
||||||
export const useUserStore = defineStore('user', () => {
|
export const useUserStore = defineStore('user', () => {
|
||||||
// State
|
// State
|
||||||
@@ -26,6 +37,8 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
memberships.value.filter((m) => m.status === MembershipStatus.ACTIVE),
|
memberships.value.filter((m) => m.status === MembershipStatus.ACTIVE),
|
||||||
)
|
)
|
||||||
const hasValidMembership = computed(() => activeMemberships.value.length > 0)
|
const hasValidMembership = computed(() => activeMemberships.value.length > 0)
|
||||||
|
const renewalHint = computed(() => getMembershipRenewalHint(memberships.value))
|
||||||
|
const inviteShareEligible = computed(() => !!user.value?.inviteShareEligible)
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
async function login() {
|
async function login() {
|
||||||
@@ -33,17 +46,34 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
const result = await wxLogin()
|
const result = await wxLogin()
|
||||||
token.value = result.token
|
token.value = result.token
|
||||||
user.value = result.user
|
user.value = result.user
|
||||||
return result.user
|
syncSubscriptionTemplates(result.user)
|
||||||
|
return { user: result.user, isNewUser: result.isNewUser }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Login failed:', err)
|
console.error('Login failed:', err)
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login, redirect new users to complete profile, and fetch post-login data.
|
||||||
|
* Returns isNewUser so callers know whether navigation was intercepted.
|
||||||
|
*/
|
||||||
|
async function loginWithSetup(): Promise<{ isNewUser: boolean }> {
|
||||||
|
const { isNewUser } = await login()
|
||||||
|
if (isNewUser) {
|
||||||
|
uni.navigateTo({ url: ROUTES.PROFILE_INFO_FIRST_LOGIN })
|
||||||
|
return { isNewUser: true }
|
||||||
|
}
|
||||||
|
await Promise.all([fetchProfile(), fetchMemberships()])
|
||||||
|
return { isNewUser: false }
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchProfile() {
|
async function fetchProfile() {
|
||||||
if (!isLoggedIn()) return
|
if (!isLoggedIn()) return
|
||||||
try {
|
try {
|
||||||
user.value = await get<UserProfileResponse>('/user/profile')
|
user.value = await get<UserProfileResponse>('/user/profile')
|
||||||
|
syncSubscriptionTemplates(user.value)
|
||||||
|
return user.value
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Fetch profile failed:', err)
|
console.error('Fetch profile failed:', err)
|
||||||
}
|
}
|
||||||
@@ -58,21 +88,29 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchMemberships() {
|
async function fetchMemberships(): Promise<boolean> {
|
||||||
if (!isLoggedIn()) return
|
if (!isLoggedIn()) return false
|
||||||
try {
|
try {
|
||||||
memberships.value = await get<MembershipWithCardType[]>('/membership/my')
|
memberships.value = await get<MembershipWithCardType[]>('/membership/my')
|
||||||
|
return true
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Fetch memberships failed:', err)
|
console.error('Fetch memberships failed:', err)
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateProfile(data: { nickname?: string; avatarUrl?: string }) {
|
async function updateProfile(data: { nickname?: string; avatarUrl?: string }) {
|
||||||
const updated = await put<UserProfileResponse>('/user/profile', data)
|
const updated = await put<UserProfileResponse>('/user/profile', data)
|
||||||
user.value = updated
|
user.value = updated
|
||||||
|
syncSubscriptionTemplates(updated)
|
||||||
return updated
|
return updated
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setProfile(profile: UserProfileResponse) {
|
||||||
|
user.value = profile
|
||||||
|
syncSubscriptionTemplates(profile)
|
||||||
|
}
|
||||||
|
|
||||||
function checkAuth() {
|
function checkAuth() {
|
||||||
if (isLoggedIn()) {
|
if (isLoggedIn()) {
|
||||||
fetchProfile()
|
fetchProfile()
|
||||||
@@ -80,14 +118,21 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function logout() {
|
function clearSession() {
|
||||||
authLogout()
|
|
||||||
token.value = ''
|
token.value = ''
|
||||||
user.value = null
|
user.value = null
|
||||||
stats.value = null
|
stats.value = null
|
||||||
memberships.value = []
|
memberships.value = []
|
||||||
|
resetSubscriptionMessageTemplateCache()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
authLogout()
|
||||||
|
clearSession()
|
||||||
|
}
|
||||||
|
|
||||||
|
setUnauthorizedHandler(clearSession)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user,
|
user,
|
||||||
stats,
|
stats,
|
||||||
@@ -98,11 +143,15 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
isAdmin,
|
isAdmin,
|
||||||
activeMemberships,
|
activeMemberships,
|
||||||
hasValidMembership,
|
hasValidMembership,
|
||||||
|
renewalHint,
|
||||||
|
inviteShareEligible,
|
||||||
login,
|
login,
|
||||||
|
loginWithSetup,
|
||||||
fetchProfile,
|
fetchProfile,
|
||||||
fetchStats,
|
fetchStats,
|
||||||
fetchMemberships,
|
fetchMemberships,
|
||||||
updateProfile,
|
updateProfile,
|
||||||
|
setProfile,
|
||||||
checkAuth,
|
checkAuth,
|
||||||
logout,
|
logout,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,26 @@
|
|||||||
/* uni.scss - 全局样式变量 */
|
/* uni.scss - 全局样式变量 */
|
||||||
$brand-color: #1a1a2e;
|
|
||||||
$brand-light: #e2d1c3;
|
/* ── 主题色系 ───────────────────────────────────────────── */
|
||||||
$accent-color: #c9a87c;
|
$primary-color: #a9bfcc;
|
||||||
$text-primary: #333333;
|
$primary-dark: #7ba5be;
|
||||||
$text-secondary: #666666;
|
$primary-light: #c8d8e4;
|
||||||
$text-hint: #999999;
|
$primary-bg: #f0f6f9;
|
||||||
$bg-page: #f5f5f5;
|
$primary-border: #d8eaf4;
|
||||||
|
$primary-selected-bg: #EFF6F9;
|
||||||
|
|
||||||
|
/* ── 通用 ─────────────────────────────────────────────── */
|
||||||
|
$brand-color: #4A4035;
|
||||||
|
$brand-light: #c8d8e4;
|
||||||
|
$accent-color: #7ba5be;
|
||||||
|
$text-primary: #4A4035;
|
||||||
|
$text-secondary: #7A6A5A;
|
||||||
|
$text-hint: #A09080;
|
||||||
|
$bg-page: #FAF8F5;
|
||||||
$bg-card: #ffffff;
|
$bg-card: #ffffff;
|
||||||
$border-color: #eeeeee;
|
$border-color: rgba(180, 160, 130, 0.2);
|
||||||
$success-color: #52c41a;
|
$success-color: #7A9E7E;
|
||||||
$warning-color: #faad14;
|
$warning-color: #e8a87c;
|
||||||
$error-color: #ff4d4f;
|
$error-color: #C47A7A;
|
||||||
$radius-sm: 8rpx;
|
$radius-sm: 8rpx;
|
||||||
$radius-md: 16rpx;
|
$radius-md: 16rpx;
|
||||||
$radius-lg: 24rpx;
|
$radius-lg: 24rpx;
|
||||||
@@ -19,3 +29,9 @@ $spacing-sm: 16rpx;
|
|||||||
$spacing-md: 24rpx;
|
$spacing-md: 24rpx;
|
||||||
$spacing-lg: 32rpx;
|
$spacing-lg: 32rpx;
|
||||||
$spacing-xl: 48rpx;
|
$spacing-xl: 48rpx;
|
||||||
|
|
||||||
|
/* ── Shimmer animation ──────────────────────────────────── */
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { background-position: 100% 0; }
|
||||||
|
100% { background-position: -100% 0; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,40 +4,82 @@ import type { UserProfileResponse } from '@mp-pilates/shared'
|
|||||||
interface LoginResponse {
|
interface LoginResponse {
|
||||||
readonly token: string
|
readonly token: string
|
||||||
readonly user: UserProfileResponse
|
readonly user: UserProfileResponse
|
||||||
|
readonly isNewUser: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UniErrorLike {
|
||||||
|
readonly errMsg?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WechatPrivacyApi {
|
||||||
|
requirePrivacyAuthorize?: (options: {
|
||||||
|
success: () => void
|
||||||
|
fail: (err: UniErrorLike) => void
|
||||||
|
}) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPrivacyError(err?: UniErrorLike): Error {
|
||||||
|
const errMsg = err?.errMsg || ''
|
||||||
|
|
||||||
|
if (errMsg.includes('cancel') || errMsg.includes('deny') || errMsg.includes('disagree')) {
|
||||||
|
return new Error('请先同意隐私保护指引')
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Error('隐私授权失败,请重试')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensurePrivacyAuthorization(): Promise<void> {
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
const wechat = (globalThis as typeof globalThis & { wx?: WechatPrivacyApi }).wx
|
||||||
|
if (!wechat || typeof wechat.requirePrivacyAuthorize !== 'function') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const requirePrivacyAuthorize = wechat.requirePrivacyAuthorize
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
requirePrivacyAuthorize({
|
||||||
|
success: () => resolve(),
|
||||||
|
fail: (err: UniErrorLike) => reject(buildPrivacyError(err)),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
// #endif
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getErrorMessage(err: unknown, fallback: string): string {
|
||||||
|
if (err instanceof Error && err.message) {
|
||||||
|
return err.message
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function wxLogin(): Promise<LoginResponse> {
|
export async function wxLogin(): Promise<LoginResponse> {
|
||||||
|
const inviterId = uni.getStorageSync('invite_inviter_id') as string
|
||||||
|
|
||||||
|
await ensurePrivacyAuthorization()
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
// Step 1:静默登录,获取 code
|
// Step 1:静默登录,获取 code
|
||||||
uni.login({
|
uni.login({
|
||||||
provider: 'weixin',
|
provider: 'weixin',
|
||||||
success: async (loginRes) => {
|
success: async (loginRes) => {
|
||||||
try {
|
try {
|
||||||
// Step 2: 获取用户微信头像和昵称
|
if (!loginRes.code) {
|
||||||
let nickname: string | undefined
|
reject(new Error('微信登录失败,请重试'))
|
||||||
let avatarUrl: string | undefined
|
return
|
||||||
await new Promise<void>((res) => {
|
}
|
||||||
uni.getUserProfile({
|
|
||||||
desc: '用于完善个人资料',
|
|
||||||
success: (profileRes) => {
|
|
||||||
nickname = profileRes.userInfo.nickName
|
|
||||||
avatarUrl = profileRes.userInfo.avatarUrl
|
|
||||||
res()
|
|
||||||
},
|
|
||||||
fail: () => {
|
|
||||||
// 用户拒绝授权,仍可继续登录
|
|
||||||
res()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// Step 3: 发送登录请求
|
// Step 2: 发送登录请求
|
||||||
|
// 注:uni.getUserProfile 已被微信废弃(基础库 2.27.1+),
|
||||||
|
// 新用户的昵称/头像由后端生成默认值,用户可在个人资料页修改
|
||||||
const result = await post<LoginResponse>('/auth/login', {
|
const result = await post<LoginResponse>('/auth/login', {
|
||||||
code: loginRes.code,
|
code: loginRes.code,
|
||||||
nickname,
|
inviterId: inviterId || undefined,
|
||||||
avatarUrl,
|
|
||||||
})
|
})
|
||||||
uni.setStorageSync('token', result.token)
|
uni.setStorageSync('token', result.token)
|
||||||
|
if (result.isNewUser && inviterId) {
|
||||||
|
uni.removeStorageSync('invite_inviter_id')
|
||||||
|
}
|
||||||
resolve(result)
|
resolve(result)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
reject(err)
|
reject(err)
|
||||||
|
|||||||
81
packages/app/src/utils/booking-helpers.ts
Normal file
81
packages/app/src/utils/booking-helpers.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { BookingStatus } from '@mp-pilates/shared'
|
||||||
|
|
||||||
|
/** 格式化日期显示:今天/明天/M月D日 星期X */
|
||||||
|
export function formatDateDisplay(dateStr: string): string {
|
||||||
|
const normalized = dateStr.slice(0, 10)
|
||||||
|
const [y, m, d] = normalized.split('-').map(Number)
|
||||||
|
const localDate = new Date(y, m - 1, d)
|
||||||
|
|
||||||
|
const today = new Date()
|
||||||
|
const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`
|
||||||
|
|
||||||
|
const tomorrow = new Date(today.getTime() + 86400000)
|
||||||
|
const tomorrowStr = `${tomorrow.getFullYear()}-${String(tomorrow.getMonth() + 1).padStart(2, '0')}-${String(tomorrow.getDate()).padStart(2, '0')}`
|
||||||
|
|
||||||
|
if (normalized === todayStr) return `今天 ${m}月${d}日`
|
||||||
|
if (normalized === tomorrowStr) return `明天 ${m}月${d}日`
|
||||||
|
|
||||||
|
const weekdayLabels = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
|
||||||
|
return `${m}月${d}日 ${weekdayLabels[localDate.getDay()]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Booking status helpers ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const BOOKING_STATUS_LABELS: Record<string, string> = {
|
||||||
|
[BookingStatus.PENDING_CONFIRMATION]: '待确认',
|
||||||
|
[BookingStatus.CONFIRMED]: '已确认',
|
||||||
|
[BookingStatus.CANCELLED]: '已取消',
|
||||||
|
[BookingStatus.COMPLETED]: '已完成',
|
||||||
|
[BookingStatus.NO_SHOW]: '未出席',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BOOKING_STATUS_BADGE_CLASSES: Record<string, string> = {
|
||||||
|
[BookingStatus.PENDING_CONFIRMATION]: 'badge--pending',
|
||||||
|
[BookingStatus.CONFIRMED]: 'badge--confirmed',
|
||||||
|
[BookingStatus.CANCELLED]: 'badge--cancelled',
|
||||||
|
[BookingStatus.COMPLETED]: 'badge--completed',
|
||||||
|
[BookingStatus.NO_SHOW]: 'badge--noshow',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BOOKING_STATUS_STRIPE_CLASSES: Record<string, string> = {
|
||||||
|
[BookingStatus.PENDING_CONFIRMATION]: 'stripe--pending',
|
||||||
|
[BookingStatus.CONFIRMED]: 'stripe--confirmed',
|
||||||
|
[BookingStatus.CANCELLED]: 'stripe--cancelled',
|
||||||
|
[BookingStatus.COMPLETED]: 'stripe--completed',
|
||||||
|
[BookingStatus.NO_SHOW]: 'stripe--noshow',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BOOKING_STATUS_BANNER_CLASSES: Record<string, string> = {
|
||||||
|
[BookingStatus.PENDING_CONFIRMATION]: 'banner--pending',
|
||||||
|
[BookingStatus.CONFIRMED]: 'banner--confirmed',
|
||||||
|
[BookingStatus.CANCELLED]: 'banner--cancelled',
|
||||||
|
[BookingStatus.COMPLETED]: 'banner--completed',
|
||||||
|
[BookingStatus.NO_SHOW]: 'banner--noshow',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bookingStatusLabel(status: string): string {
|
||||||
|
return BOOKING_STATUS_LABELS[status] ?? status
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bookingStatusBadgeClass(status: string): string {
|
||||||
|
return BOOKING_STATUS_BADGE_CLASSES[status] ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bookingStatusStripeClass(status: string): string {
|
||||||
|
return BOOKING_STATUS_STRIPE_CLASSES[status] ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bookingStatusBannerClass(status: string): string {
|
||||||
|
return BOOKING_STATUS_BANNER_CLASSES[status] ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bookingTimelineDotClass(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case BookingStatus.PENDING_CONFIRMATION: return 'dot--pending'
|
||||||
|
case BookingStatus.CONFIRMED: return 'dot--confirmed'
|
||||||
|
case BookingStatus.COMPLETED: return 'dot--completed'
|
||||||
|
case BookingStatus.CANCELLED: return 'dot--cancelled'
|
||||||
|
case BookingStatus.NO_SHOW: return 'dot--noshow'
|
||||||
|
default: return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,13 @@
|
|||||||
|
import { CardTypeCategory } from '@mp-pilates/shared'
|
||||||
|
import { FlashSalePhase } from '@mp-pilates/shared'
|
||||||
|
|
||||||
|
/** Minimal membership shape needed by progress/usage helpers. */
|
||||||
|
interface MembershipLike {
|
||||||
|
readonly remainingTimes: number | null
|
||||||
|
readonly totalTimes?: number | null
|
||||||
|
readonly cardType: { readonly totalTimes: number | null }
|
||||||
|
}
|
||||||
|
|
||||||
/** 格式化金额:分 → 元 */
|
/** 格式化金额:分 → 元 */
|
||||||
export function formatPrice(cents: number): string {
|
export function formatPrice(cents: number): string {
|
||||||
return (cents / 100).toFixed(2)
|
return (cents / 100).toFixed(2)
|
||||||
@@ -44,3 +54,124 @@ export function getDateRange(days: number): ReadonlyArray<{ readonly date: strin
|
|||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 会员卡类型标签 */
|
||||||
|
export function getCardTypeLabel(type: CardTypeCategory | string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
[CardTypeCategory.TIMES]: '次卡',
|
||||||
|
[CardTypeCategory.DURATION]: '月卡',
|
||||||
|
[CardTypeCategory.TRIAL]: '体验卡',
|
||||||
|
}
|
||||||
|
return map[type] ?? '会员卡'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 会员卡封面 CSS 类名 */
|
||||||
|
export function getCardCoverClass(type: CardTypeCategory): string {
|
||||||
|
if (type === CardTypeCategory.TRIAL) return 'cover--trial'
|
||||||
|
if (type === CardTypeCategory.DURATION) return 'cover--duration'
|
||||||
|
return 'cover--times'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 判断课程时段是否已过期(当前时间 > 课程开始时间) */
|
||||||
|
export function isSlotPast(date: string, startTime: string): boolean {
|
||||||
|
const slotDateTime = new Date(`${date}T${startTime}:00`)
|
||||||
|
return new Date() > slotDateTime
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 会员卡渐变 CSS 类名前缀 */
|
||||||
|
export function getCardGradientClass(type: CardTypeCategory | string): string {
|
||||||
|
if (type === CardTypeCategory.DURATION) return 'gradient--duration'
|
||||||
|
if (type === CardTypeCategory.TRIAL) return 'gradient--trial'
|
||||||
|
return 'gradient--times'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 会员卡累计购入次数(续卡后优先用卡上的快照) */
|
||||||
|
export function getMembershipTotalTimes(membership: MembershipLike): number | null {
|
||||||
|
return membership.totalTimes ?? membership.cardType.totalTimes ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 会员卡进度百分比(剩余 / 总次数,clamp 到 0~100%) */
|
||||||
|
export function getMembershipProgressWidth(membership: MembershipLike): string {
|
||||||
|
const totalTimes = getMembershipTotalTimes(membership)
|
||||||
|
if (membership.remainingTimes === null || !totalTimes) return '0%'
|
||||||
|
const pct = (membership.remainingTimes / totalTimes) * 100
|
||||||
|
return `${Math.max(0, Math.min(100, pct))}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 已使用次数(不低于 0,防止管理员调高剩余次数导致负值) */
|
||||||
|
export function getMembershipUsedTimes(membership: MembershipLike): number {
|
||||||
|
const totalTimes = getMembershipTotalTimes(membership)
|
||||||
|
if (membership.remainingTimes === null || !totalTimes) return 0
|
||||||
|
return Math.max(0, totalTimes - membership.remainingTimes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 格式化倒计时:HH:MM:SS */
|
||||||
|
export function formatCountdown(targetTime: string): string {
|
||||||
|
const { h, m, s } = getCountdownParts(targetTime)
|
||||||
|
return `${h}:${m}:${s}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取倒计时各部分(h/m/s 已补零) */
|
||||||
|
export function getCountdownParts(targetTime: string): { readonly h: string; readonly m: string; readonly s: string } {
|
||||||
|
const diff = Math.max(0, new Date(targetTime).getTime() - Date.now())
|
||||||
|
return {
|
||||||
|
h: String(Math.floor(diff / 3_600_000)).padStart(2, '0'),
|
||||||
|
m: String(Math.floor((diff % 3_600_000) / 60_000)).padStart(2, '0'),
|
||||||
|
s: String(Math.floor((diff % 60_000) / 1000)).padStart(2, '0'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 秒杀阶段中文标签 */
|
||||||
|
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 {
|
||||||
|
if (totalStock === 0) return 0
|
||||||
|
return soldCount / totalStock
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 库存已售百分比字符串 */
|
||||||
|
export function getStockPercent(soldCount: number, totalStock: number): string {
|
||||||
|
return `${Math.min(100, getStockRatio(soldCount, totalStock) * 100)}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 格式化日期时间为 YYYY-MM-DD HH:mm */
|
||||||
|
export function formatDateTimeFull(dateStr: string): string {
|
||||||
|
const d = new Date(dateStr)
|
||||||
|
if (Number.isNaN(d.getTime())) return '-'
|
||||||
|
const year = d.getFullYear()
|
||||||
|
const month = String(d.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(d.getDate()).padStart(2, '0')
|
||||||
|
const hour = String(d.getHours()).padStart(2, '0')
|
||||||
|
const min = String(d.getMinutes()).padStart(2, '0')
|
||||||
|
return `${year}-${month}-${day} ${hour}:${min}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 格式化日期时间为 MM-DD HH:mm:ss */
|
||||||
|
export function formatDateTime(dateStr: string): string {
|
||||||
|
const d = new Date(dateStr)
|
||||||
|
const month = String(d.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(d.getDate()).padStart(2, '0')
|
||||||
|
const hour = String(d.getHours()).padStart(2, '0')
|
||||||
|
const min = String(d.getMinutes()).padStart(2, '0')
|
||||||
|
const sec = String(d.getSeconds()).padStart(2, '0')
|
||||||
|
return `${month}-${day} ${hour}:${min}:${sec}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 格式化 Date 为 YYYY-MM-DD(本地时间,用于 picker) */
|
||||||
|
export function formatDateLocal(d: Date): string {
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 格式化 Date 为 HH:MM(本地时间,用于 picker) */
|
||||||
|
export function formatTimeLocal(d: Date): string {
|
||||||
|
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,16 +1,8 @@
|
|||||||
import type { ApiResponse, PaginatedData } from '@mp-pilates/shared'
|
import type { ApiResponse, PaginatedData } from '@mp-pilates/shared'
|
||||||
|
import { notifyUnauthorized } from './session'
|
||||||
|
|
||||||
const BASE_URL = (() => {
|
// 统一使用线上服务地址
|
||||||
try {
|
const BASE_URL = 'https://focus.richarjiang.com/api'
|
||||||
const { miniProgram } = uni.getAccountInfoSync()
|
|
||||||
if (miniProgram.envVersion !== 'develop') {
|
|
||||||
return 'https://focus.richarjiang.com/api'
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// 非小程序环境,使用开发地址
|
|
||||||
}
|
|
||||||
return 'http://localhost:3000/api'
|
|
||||||
})()
|
|
||||||
|
|
||||||
interface RequestOptions {
|
interface RequestOptions {
|
||||||
readonly url: string
|
readonly url: string
|
||||||
@@ -19,6 +11,13 @@ interface RequestOptions {
|
|||||||
readonly header?: Record<string, string>
|
readonly header?: Record<string, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class HttpRequestError extends Error {
|
||||||
|
constructor(message: string, readonly statusCode: number) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'HttpRequestError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function request<T>(options: RequestOptions): Promise<T> {
|
export function request<T>(options: RequestOptions): Promise<T> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const token = uni.getStorageSync('token') as string
|
const token = uni.getStorageSync('token') as string
|
||||||
@@ -34,14 +33,15 @@ export function request<T>(options: RequestOptions): Promise<T> {
|
|||||||
},
|
},
|
||||||
success: (res) => {
|
success: (res) => {
|
||||||
if (res.statusCode === 401) {
|
if (res.statusCode === 401) {
|
||||||
uni.removeStorageSync('token')
|
if (!options.url.startsWith('/auth/login')) {
|
||||||
uni.showToast({ title: '请重新登录', icon: 'none' })
|
notifyUnauthorized()
|
||||||
|
}
|
||||||
reject(new Error('Unauthorized'))
|
reject(new Error('Unauthorized'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (res.statusCode >= 400) {
|
if (res.statusCode >= 400) {
|
||||||
const body = res.data as ApiResponse<unknown>
|
const body = res.data as ApiResponse<unknown>
|
||||||
reject(new Error(body?.message || `请求失败 (${res.statusCode})`))
|
reject(new HttpRequestError(body?.message || `请求失败 (${res.statusCode})`, res.statusCode))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const body = res.data as ApiResponse<T>
|
const body = res.data as ApiResponse<T>
|
||||||
|
|||||||
4
packages/app/src/utils/routes.ts
Normal file
4
packages/app/src/utils/routes.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export const ROUTES = {
|
||||||
|
PROFILE_INFO: '/pages/profile/info',
|
||||||
|
PROFILE_INFO_FIRST_LOGIN: '/pages/profile/info?from=login',
|
||||||
|
} as const
|
||||||
67
packages/app/src/utils/schedule-time.ts
Normal file
67
packages/app/src/utils/schedule-time.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import {
|
||||||
|
DEFAULT_SCHEDULE_START_TIME,
|
||||||
|
DEFAULT_SCHEDULE_END_TIME,
|
||||||
|
SCHEDULE_MINUTE_STEP,
|
||||||
|
} from '@mp-pilates/shared'
|
||||||
|
|
||||||
|
function buildHourOptions(): string[] {
|
||||||
|
const startHour = parseInt(DEFAULT_SCHEDULE_START_TIME.slice(0, 2), 10)
|
||||||
|
const endHour = parseInt(DEFAULT_SCHEDULE_END_TIME.slice(0, 2), 10)
|
||||||
|
const list: string[] = []
|
||||||
|
for (let h = startHour; h <= endHour; h++) {
|
||||||
|
list.push(String(h).padStart(2, '0'))
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMinuteOptions(): string[] {
|
||||||
|
const list: string[] = []
|
||||||
|
for (let m = 0; m < 60; m += SCHEDULE_MINUTE_STEP) {
|
||||||
|
list.push(String(m).padStart(2, '0'))
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SCHEDULE_HOUR_OPTIONS = buildHourOptions()
|
||||||
|
export const SCHEDULE_MINUTE_OPTIONS = buildMinuteOptions()
|
||||||
|
export const SCHEDULE_TIME_PICKER_RANGE = [SCHEDULE_HOUR_OPTIONS, SCHEDULE_MINUTE_OPTIONS]
|
||||||
|
|
||||||
|
export function timeToMinutes(time: string): number {
|
||||||
|
const [h, m] = time.split(':').map(Number)
|
||||||
|
return h * 60 + (m || 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function minutesToTime(total: number): string {
|
||||||
|
const h = Math.floor(total / 60)
|
||||||
|
const m = total % 60
|
||||||
|
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function timeToPickerIndex(time: string): number[] {
|
||||||
|
let total = timeToMinutes(time || DEFAULT_SCHEDULE_START_TIME)
|
||||||
|
const minTotal = timeToMinutes(DEFAULT_SCHEDULE_START_TIME)
|
||||||
|
const maxTotal = timeToMinutes(DEFAULT_SCHEDULE_END_TIME)
|
||||||
|
total = Math.max(minTotal, Math.min(maxTotal, total))
|
||||||
|
total = Math.round(total / SCHEDULE_MINUTE_STEP) * SCHEDULE_MINUTE_STEP
|
||||||
|
total = Math.max(minTotal, Math.min(maxTotal, total))
|
||||||
|
|
||||||
|
const hour = String(Math.floor(total / 60)).padStart(2, '0')
|
||||||
|
const minute = String(total % 60).padStart(2, '0')
|
||||||
|
return [
|
||||||
|
Math.max(0, SCHEDULE_HOUR_OPTIONS.indexOf(hour)),
|
||||||
|
Math.max(0, SCHEDULE_MINUTE_OPTIONS.indexOf(minute)),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickerIndexToTime(value: number[]): string {
|
||||||
|
const hour = SCHEDULE_HOUR_OPTIONS[value[0]] ?? SCHEDULE_HOUR_OPTIONS[0]
|
||||||
|
const minute = SCHEDULE_MINUTE_OPTIONS[value[1]] ?? SCHEDULE_MINUTE_OPTIONS[0]
|
||||||
|
return `${hour}:${minute}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将 "HH:mm" 加一小时,不超过当日最晚结束时间 21:30 */
|
||||||
|
export function addOneHourCapped(time: string): string {
|
||||||
|
const next = timeToMinutes(time) + 60
|
||||||
|
const cap = timeToMinutes(DEFAULT_SCHEDULE_END_TIME)
|
||||||
|
return minutesToTime(Math.min(next, cap))
|
||||||
|
}
|
||||||
22
packages/app/src/utils/session.ts
Normal file
22
packages/app/src/utils/session.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
const UNAUTHORIZED_TOAST_GAP_MS = 2000
|
||||||
|
|
||||||
|
let unauthorizedHandler: (() => void) | null = null
|
||||||
|
let lastUnauthorizedAt = 0
|
||||||
|
|
||||||
|
export function setUnauthorizedHandler(handler: () => void): void {
|
||||||
|
unauthorizedHandler = handler
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear persisted token and in-memory session after the server rejects auth. */
|
||||||
|
export function notifyUnauthorized(): void {
|
||||||
|
const now = Date.now()
|
||||||
|
const shouldToast = now - lastUnauthorizedAt > UNAUTHORIZED_TOAST_GAP_MS
|
||||||
|
lastUnauthorizedAt = now
|
||||||
|
|
||||||
|
uni.removeStorageSync('token')
|
||||||
|
unauthorizedHandler?.()
|
||||||
|
|
||||||
|
if (shouldToast) {
|
||||||
|
uni.showToast({ title: '登录已过期,请重新登录', icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
72
packages/app/src/utils/studio-upload.ts
Normal file
72
packages/app/src/utils/studio-upload.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import type {
|
||||||
|
CreateStudioUploadCredentialDto,
|
||||||
|
StudioAssetType,
|
||||||
|
StudioUploadCredential,
|
||||||
|
} from '@mp-pilates/shared'
|
||||||
|
import type { useAdminStore } from '../stores/admin'
|
||||||
|
|
||||||
|
type AdminStore = ReturnType<typeof useAdminStore>
|
||||||
|
|
||||||
|
function inferContentType(fileName: string): string | undefined {
|
||||||
|
const extension = fileName.split('.').pop()?.toLowerCase()
|
||||||
|
|
||||||
|
if (extension === 'jpg' || extension === 'jpeg') {
|
||||||
|
return 'image/jpeg'
|
||||||
|
}
|
||||||
|
if (extension === 'png') {
|
||||||
|
return 'image/png'
|
||||||
|
}
|
||||||
|
if (extension === 'webp') {
|
||||||
|
return 'image/webp'
|
||||||
|
}
|
||||||
|
if (extension === 'heic') {
|
||||||
|
return 'image/heic'
|
||||||
|
}
|
||||||
|
if (extension === 'heif') {
|
||||||
|
return 'image/heif'
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function uploadToCos(filePath: string, credential: StudioUploadCredential): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
uni.uploadFile({
|
||||||
|
url: credential.uploadUrl,
|
||||||
|
filePath,
|
||||||
|
name: 'file',
|
||||||
|
formData: credential.formData as unknown as Record<string, string>,
|
||||||
|
success: (result) => {
|
||||||
|
if (result.statusCode >= 200 && result.statusCode < 300) {
|
||||||
|
resolve()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = typeof result.data === 'string' ? result.data : JSON.stringify(result.data)
|
||||||
|
const code = body.match(/<Code>([^<]+)<\/Code>/)?.[1]
|
||||||
|
const message = body.match(/<Message>([^<]+)<\/Message>/)?.[1]
|
||||||
|
const detail = code || message ? `${code ?? 'COS'}: ${message ?? body}` : body
|
||||||
|
reject(new Error(`COS 上传失败 (${result.statusCode}) ${detail}`))
|
||||||
|
},
|
||||||
|
fail: (error) => {
|
||||||
|
reject(new Error(error.errMsg || 'COS 上传失败'))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadStudioAsset(params: {
|
||||||
|
adminStore: AdminStore
|
||||||
|
filePath: string
|
||||||
|
fileName: string
|
||||||
|
assetType: StudioAssetType
|
||||||
|
}): Promise<string> {
|
||||||
|
const payload: CreateStudioUploadCredentialDto = {
|
||||||
|
fileName: params.fileName,
|
||||||
|
contentType: inferContentType(params.fileName),
|
||||||
|
assetType: params.assetType,
|
||||||
|
}
|
||||||
|
const credential = await params.adminStore.createStudioUploadCredential(payload)
|
||||||
|
await uploadToCos(params.filePath, credential)
|
||||||
|
return credential.fileUrl
|
||||||
|
}
|
||||||
54
packages/app/src/utils/system.ts
Normal file
54
packages/app/src/utils/system.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* System info utilities — replaces deprecated uni.getSystemInfoSync()
|
||||||
|
* with the recommended granular APIs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface SystemLayout {
|
||||||
|
/** Status bar height in px */
|
||||||
|
readonly statusBarHeight: number
|
||||||
|
/** Window width in px */
|
||||||
|
readonly windowWidth: number
|
||||||
|
/** Custom nav bar height in px (status bar + title bar) */
|
||||||
|
readonly navBarHeight: number
|
||||||
|
}
|
||||||
|
|
||||||
|
let cached: SystemLayout | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns layout dimensions using the new granular APIs.
|
||||||
|
* Falls back to getSystemInfoSync only if the new APIs are unavailable.
|
||||||
|
* Results are cached since these values never change during a session.
|
||||||
|
*/
|
||||||
|
export function getSystemLayout(): SystemLayout {
|
||||||
|
if (cached) return cached
|
||||||
|
|
||||||
|
let statusBarHeight = 20
|
||||||
|
let windowWidth = 375
|
||||||
|
|
||||||
|
try {
|
||||||
|
// New recommended APIs (WeChat base lib >= 2.25.3)
|
||||||
|
const windowInfo = uni.getWindowInfo()
|
||||||
|
const deviceInfo = uni.getDeviceInfo()
|
||||||
|
|
||||||
|
statusBarHeight = windowInfo.statusBarHeight ?? 20
|
||||||
|
windowWidth = windowInfo.windowWidth ?? 375
|
||||||
|
|
||||||
|
// Silence unused var — deviceInfo is here for future use
|
||||||
|
void deviceInfo
|
||||||
|
} catch {
|
||||||
|
// Fallback for older base lib versions
|
||||||
|
try {
|
||||||
|
const sysInfo = uni.getSystemInfoSync()
|
||||||
|
statusBarHeight = sysInfo.statusBarHeight ?? 20
|
||||||
|
windowWidth = sysInfo.windowWidth ?? 375
|
||||||
|
} catch {
|
||||||
|
// Use defaults
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const navTitlePx = 88 * (windowWidth / 750)
|
||||||
|
const navBarHeight = Math.round(statusBarHeight + navTitlePx)
|
||||||
|
|
||||||
|
cached = { statusBarHeight, windowWidth, navBarHeight }
|
||||||
|
return cached
|
||||||
|
}
|
||||||
62
packages/app/src/utils/teacher.ts
Normal file
62
packages/app/src/utils/teacher.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
export interface TeacherProfile {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
title: string
|
||||||
|
avatar: string
|
||||||
|
cover: string
|
||||||
|
badges: string[]
|
||||||
|
specialties: string[]
|
||||||
|
intro: string
|
||||||
|
certifications: string[]
|
||||||
|
teachingFocus: Array<{ title: string; desc: string }>
|
||||||
|
stats: Array<{ label: string; value: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const irisProfile: TeacherProfile = {
|
||||||
|
id: 'iris',
|
||||||
|
name: 'Iris',
|
||||||
|
title: '高级普拉提教练',
|
||||||
|
avatar: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/mp/images/teacher_avatar.jpg',
|
||||||
|
cover: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/mp/images/teacher_avatar.jpg',
|
||||||
|
badges: ['高级', 'STOTT PILATES'],
|
||||||
|
specialties: ['塑性训练', '体态调整', '产后恢复'],
|
||||||
|
intro: '我擅长把专业训练拆解成身体真正听得懂的节奏,让你在安全、稳定与被看见的陪伴里,一点点找回力量、线条与自信。',
|
||||||
|
certifications: [
|
||||||
|
'斯多特塑身机初&中级认证教练',
|
||||||
|
'斯多特全场馆认证教练',
|
||||||
|
'曼丽丘斯多特塑身机弹跳网芭杆工作坊',
|
||||||
|
'塑身机上的高尔夫与旋转运动调理工作坊',
|
||||||
|
'四维人体运动解剖认证',
|
||||||
|
],
|
||||||
|
teachingFocus: [
|
||||||
|
{
|
||||||
|
title: '核心激活',
|
||||||
|
desc: '从骨盆与深层腹部控制入手,建立稳定发力基础。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '体态调整',
|
||||||
|
desc: '针对圆肩、头前伸、骨盆前倾等常见问题做精细训练。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '产后恢复',
|
||||||
|
desc: '循序渐进关注核心重建、呼吸配合与身体觉知恢复。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '塑形训练',
|
||||||
|
desc: '兼顾力量与线条,让臀腿、背部和核心更有支撑感。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '呼吸控制',
|
||||||
|
desc: '把呼吸节奏融入动作,提高训练效率与身体连接感。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '一对一进阶',
|
||||||
|
desc: '根据训练基础与目标安排节奏,适合长期稳定提升。',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
stats: [
|
||||||
|
{ label: '擅长方向', value: '塑形 / 体态' },
|
||||||
|
{ label: '授课类型', value: '私教为主' },
|
||||||
|
{ label: '课程时长', value: '60 分钟' },
|
||||||
|
],
|
||||||
|
}
|
||||||
256
packages/app/src/utils/wechat-subscription.ts
Normal file
256
packages/app/src/utils/wechat-subscription.ts
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
import {
|
||||||
|
SubscriptionMessageScene,
|
||||||
|
} from '@mp-pilates/shared'
|
||||||
|
import type {
|
||||||
|
ReportSubscriptionMessageRequestDto,
|
||||||
|
SubscriptionMessageRequestItem,
|
||||||
|
SubscriptionMessageTemplate,
|
||||||
|
SubscriptionMessageTemplateConfig,
|
||||||
|
UserProfileResponse,
|
||||||
|
} from '@mp-pilates/shared'
|
||||||
|
import { post } from './request'
|
||||||
|
|
||||||
|
type TemplateResult = SubscriptionMessageRequestItem['result'] | 'tmplIds empty' | 'err' | 'undefined'
|
||||||
|
|
||||||
|
type RequestSubscribeMessageSuccess = Record<string, TemplateResult | undefined> & {
|
||||||
|
errMsg?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RequestSubscribeMessageFail {
|
||||||
|
errMsg?: string
|
||||||
|
errCode?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringifyDebugPayload(payload: unknown): string {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(payload)
|
||||||
|
} catch {
|
||||||
|
return String(payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSubscribeDebugContext() {
|
||||||
|
try {
|
||||||
|
const windowInfo = typeof uni.getWindowInfo === 'function' ? uni.getWindowInfo() : null
|
||||||
|
const appBaseInfo = typeof uni.getAppBaseInfo === 'function' ? uni.getAppBaseInfo() : null
|
||||||
|
const deviceInfo = typeof uni.getDeviceInfo === 'function' ? uni.getDeviceInfo() : null
|
||||||
|
const host = appBaseInfo?.host as { env?: string } | string | undefined
|
||||||
|
|
||||||
|
return {
|
||||||
|
platform: deviceInfo?.platform ?? 'unknown',
|
||||||
|
hostEnv: typeof host === 'object' && host ? host.env : undefined,
|
||||||
|
app: appBaseInfo?.appName,
|
||||||
|
system: deviceInfo?.system,
|
||||||
|
language: appBaseInfo?.language,
|
||||||
|
version: appBaseInfo?.version,
|
||||||
|
SDKVersion: appBaseInfo?.SDKVersion,
|
||||||
|
windowWidth: windowInfo?.windowWidth,
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
platform: 'unknown',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSubscribeError(err: RequestSubscribeMessageFail, scene: SubscriptionMessageScene, templateIds: string[]): Error {
|
||||||
|
const debugContext = getSubscribeDebugContext()
|
||||||
|
const rawMessage = (err.errMsg || '').trim()
|
||||||
|
|
||||||
|
if (!rawMessage && debugContext.platform === 'devtools') {
|
||||||
|
return new Error(
|
||||||
|
`开发者工具当前环境不支持订阅消息唤起。请退出游客模式,使用已登录的微信开发者工具并在真机中重试。调试信息: ${stringifyDebugPayload({ scene, templateIds, err, debugContext })}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Error(
|
||||||
|
`订阅消息授权失败: ${stringifyDebugPayload({ scene, templateIds, err, debugContext })}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const TEMPLATE_CONFIG_STORAGE_KEY = 'subscriptionMessageTemplateConfig'
|
||||||
|
|
||||||
|
let cachedConfig: SubscriptionMessageTemplateConfig | null = null
|
||||||
|
|
||||||
|
function isMpWeixin(): boolean {
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
return true
|
||||||
|
// #endif
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeResult(result?: TemplateResult): SubscriptionMessageRequestItem['result'] | null {
|
||||||
|
if (result === 'accept' || result === 'reject' || result === 'ban' || result === 'filter') {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchTemplateConfig(): Promise<SubscriptionMessageTemplateConfig> {
|
||||||
|
if (cachedConfig) {
|
||||||
|
return cachedConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
const stored = uni.getStorageSync(TEMPLATE_CONFIG_STORAGE_KEY) as SubscriptionMessageTemplateConfig | ''
|
||||||
|
if (!stored || !Array.isArray(stored.templates)) {
|
||||||
|
throw new Error('订阅消息模板尚未初始化,请重新进入页面后重试')
|
||||||
|
}
|
||||||
|
|
||||||
|
const config: SubscriptionMessageTemplateConfig = {
|
||||||
|
templates: stored.templates.filter((item) => item.templateId),
|
||||||
|
}
|
||||||
|
cachedConfig = config
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTemplateConfig(config?: Partial<SubscriptionMessageTemplateConfig> | null): SubscriptionMessageTemplateConfig {
|
||||||
|
const templates = Array.isArray(config?.templates) ? config.templates : []
|
||||||
|
|
||||||
|
return {
|
||||||
|
templates: templates.filter((item): item is SubscriptionMessageTemplate => !!item?.templateId),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cacheSubscriptionMessageTemplateConfig(
|
||||||
|
config?: Partial<SubscriptionMessageTemplateConfig> | null,
|
||||||
|
): SubscriptionMessageTemplateConfig {
|
||||||
|
const normalized = normalizeTemplateConfig(config)
|
||||||
|
cachedConfig = normalized
|
||||||
|
uni.setStorageSync(TEMPLATE_CONFIG_STORAGE_KEY, normalized)
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTemplatesByScene(
|
||||||
|
config: SubscriptionMessageTemplateConfig,
|
||||||
|
scene: SubscriptionMessageScene,
|
||||||
|
): SubscriptionMessageTemplate[] {
|
||||||
|
return config.templates.filter((item) => item.scene === scene && item.templateId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reportResults(requests: SubscriptionMessageRequestItem[]): Promise<void> {
|
||||||
|
if (requests.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: ReportSubscriptionMessageRequestDto = { requests }
|
||||||
|
await post('/user/subscription-messages/report', payload as unknown as Record<string, unknown>)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requestSubscriptionMessage(scene: SubscriptionMessageScene): Promise<SubscriptionMessageRequestItem[]> {
|
||||||
|
if (!isMpWeixin()) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await fetchTemplateConfig()
|
||||||
|
const templates = getTemplatesByScene(config, scene)
|
||||||
|
if (templates.length === 0) {
|
||||||
|
console.error('[subscribe] no templates matched scene', stringifyDebugPayload({ scene, config, debugContext: getSubscribeDebugContext() }))
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const templateIds = templates.map((item) => item.templateId)
|
||||||
|
const debugContext = getSubscribeDebugContext()
|
||||||
|
console.log('[subscribe] requestSubscribeMessage:start', stringifyDebugPayload({ scene, templateIds, templates, debugContext }))
|
||||||
|
|
||||||
|
const result = await new Promise<RequestSubscribeMessageSuccess>((resolve, reject) => {
|
||||||
|
uni.requestSubscribeMessage({
|
||||||
|
tmplIds: templateIds,
|
||||||
|
success: (res) => {
|
||||||
|
console.log('[subscribe] requestSubscribeMessage:success', stringifyDebugPayload({ scene, response: res, templateIds, debugContext }))
|
||||||
|
resolve(res as RequestSubscribeMessageSuccess)
|
||||||
|
},
|
||||||
|
fail: (err) => {
|
||||||
|
console.error('[subscribe] requestSubscribeMessage:fail', stringifyDebugPayload({ scene, error: err, templateIds, debugContext }))
|
||||||
|
reject(buildSubscribeError(err as RequestSubscribeMessageFail, scene, templateIds))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const requests = templates
|
||||||
|
.map<SubscriptionMessageRequestItem | null>((item) => {
|
||||||
|
const normalized = normalizeResult(result[item.templateId])
|
||||||
|
if (!normalized) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
templateId: item.templateId,
|
||||||
|
scene: item.scene,
|
||||||
|
result: normalized,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((item): item is SubscriptionMessageRequestItem => item !== null)
|
||||||
|
|
||||||
|
console.log('[subscribe] requestSubscribeMessage:normalized', stringifyDebugPayload({ scene, result, requests, templateIds, debugContext }))
|
||||||
|
|
||||||
|
await reportResults(requests)
|
||||||
|
return requests
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requestOrderPaidSubscriptionMessage(): Promise<SubscriptionMessageRequestItem[]> {
|
||||||
|
return requestSubscriptionMessage(SubscriptionMessageScene.BOOKING_CREATED)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requestBookingCreatedSubscriptionMessage(): Promise<SubscriptionMessageRequestItem[]> {
|
||||||
|
return requestSubscriptionMessage(SubscriptionMessageScene.BOOKING_CREATED)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requestAdminBookingSubscriptionCount(): Promise<UserProfileResponse | null> {
|
||||||
|
if (!isMpWeixin()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await fetchTemplateConfig()
|
||||||
|
const templates = getTemplatesByScene(config, SubscriptionMessageScene.ADMIN_BOOKING_CREATED)
|
||||||
|
.filter((item) => item.usageTarget === 'counter')
|
||||||
|
|
||||||
|
if (templates.length === 0) {
|
||||||
|
console.error('[subscribe] no admin counter template configured', stringifyDebugPayload({
|
||||||
|
scene: SubscriptionMessageScene.ADMIN_BOOKING_CREATED,
|
||||||
|
config,
|
||||||
|
debugContext: getSubscribeDebugContext(),
|
||||||
|
}))
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const templateId = templates[0].templateId
|
||||||
|
const debugContext = getSubscribeDebugContext()
|
||||||
|
|
||||||
|
console.log('[subscribe] requestSubscribeMessage:adminCounter:start', stringifyDebugPayload({
|
||||||
|
templateId,
|
||||||
|
debugContext,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const result = await new Promise<RequestSubscribeMessageSuccess>((resolve, reject) => {
|
||||||
|
uni.requestSubscribeMessage({
|
||||||
|
tmplIds: [templateId],
|
||||||
|
success: (res) => {
|
||||||
|
console.log('[subscribe] requestSubscribeMessage:adminCounter:success', stringifyDebugPayload({ response: res, templateId, debugContext }))
|
||||||
|
resolve(res as RequestSubscribeMessageSuccess)
|
||||||
|
},
|
||||||
|
fail: (err) => {
|
||||||
|
console.error('[subscribe] requestSubscribeMessage:adminCounter:fail', stringifyDebugPayload({ error: err, templateId, debugContext }))
|
||||||
|
reject(buildSubscribeError(err as RequestSubscribeMessageFail, SubscriptionMessageScene.ADMIN_BOOKING_CREATED, [templateId]))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const normalized = normalizeResult(result[templateId])
|
||||||
|
console.log('[subscribe] requestSubscribeMessage:adminCounter:normalized', stringifyDebugPayload({
|
||||||
|
templateId,
|
||||||
|
normalized,
|
||||||
|
rawResult: result,
|
||||||
|
debugContext,
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (normalized !== 'accept') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return post<UserProfileResponse>('/user/subscription-messages/admin-booking-count')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetSubscriptionMessageTemplateCache(): void {
|
||||||
|
cachedConfig = null
|
||||||
|
uni.removeStorageSync(TEMPLATE_CONFIG_STORAGE_KEY)
|
||||||
|
}
|
||||||
@@ -9,4 +9,11 @@ export default defineConfig({
|
|||||||
'@': resolve(__dirname, 'src'),
|
'@': resolve(__dirname, 'src'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
css: {
|
||||||
|
preprocessorOptions: {
|
||||||
|
scss: {
|
||||||
|
api: 'modern',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
35
packages/server/.env
Normal file
35
packages/server/.env
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# Database
|
||||||
|
DATABASE_URL=mysql://root:AK8jyLfsfMA5wNdC@129.204.155.94:13306/db_mp_focus
|
||||||
|
|
||||||
|
# JWT
|
||||||
|
JWT_SECRET=change-me-to-a-secure-random-string
|
||||||
|
|
||||||
|
# WeChat Mini Program
|
||||||
|
WX_APPID=wx3e7a133d2305fa2c
|
||||||
|
WX_SECRET=92f4f91af72ca0705d65e39e605cb98b
|
||||||
|
|
||||||
|
# WeChat Pay
|
||||||
|
WX_MCH_ID=1110530023
|
||||||
|
WX_MCH_KEY=ACbGcH3FNLBacmvmIVR4uWXjNf9h8jQ2
|
||||||
|
WX_MCH_SERIAL_NO=7A90D96A7ED1A129E98DB5FD5F3A84EDC34B2AC6
|
||||||
|
WX_MCH_KEY_PATH=./certs/apiclient_key.pem
|
||||||
|
|
||||||
|
# API Base URL (used for WeChat Pay callback notification)
|
||||||
|
API_BASE_URL=https://focus.richarjiang.com/
|
||||||
|
|
||||||
|
# Server
|
||||||
|
PORT=3000
|
||||||
|
|
||||||
|
WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED=antYfc85gvwImFZ9kM4UiqMOywJxbqFVgKHLH3NikII
|
||||||
|
|
||||||
|
# COS upload
|
||||||
|
COS_SECRET_ID=AKIDwwulT3ub9f9bxFVdihcP4Z1S6qivMxmu
|
||||||
|
COS_SECRET_KEY=S1rrw0CY1fRQj7X7fCpjryAMwgel6drG
|
||||||
|
COS_BUCKET=plates-1251306435
|
||||||
|
COS_REGION=ap-guangzhou
|
||||||
|
COS_UPLOAD_ROLE_ARN=qcs::cam::uin/649581473:roleName/MpPilatesCosUploadRole
|
||||||
|
COS_APP_ID=1251306435
|
||||||
|
COS_PUBLIC_BASE_URL=https://plates-1251306435.cos.ap-guangzhou.myqcloud.com
|
||||||
|
COS_UPLOAD_PREFIX=mp/studio
|
||||||
|
COS_UPLOAD_DURATION_SECONDS=1800
|
||||||
|
COS_UPLOAD_ROLE_SESSION_NAME=mp-pilates-studio-upload
|
||||||
13
packages/server/.env.example
Normal file
13
packages/server/.env.example
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
DATABASE_URL=mysql://user:password@127.0.0.1:3306/mp_pilates
|
||||||
|
JWT_SECRET=change-me
|
||||||
|
WX_APPID=your-wechat-appid
|
||||||
|
WX_SECRET=your-wechat-secret
|
||||||
|
|
||||||
|
# COS upload
|
||||||
|
COS_SECRET_ID=your-cos-secret-id
|
||||||
|
COS_SECRET_KEY=your-cos-secret-key
|
||||||
|
COS_BUCKET=plates-1251306435
|
||||||
|
COS_REGION=ap-guangzhou
|
||||||
|
COS_PUBLIC_BASE_URL=https://plates-1251306435.cos.ap-guangzhou.myqcloud.com
|
||||||
|
COS_UPLOAD_PREFIX=mp/studio
|
||||||
|
COS_UPLOAD_DURATION_SECONDS=1800
|
||||||
BIN
packages/server/certs/apiclient_cert.p12
Normal file
BIN
packages/server/certs/apiclient_cert.p12
Normal file
Binary file not shown.
25
packages/server/certs/apiclient_cert.pem
Normal file
25
packages/server/certs/apiclient_cert.pem
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIIETDCCAzSgAwIBAgIUepDZan7RoSnpjbX9XzqE7cNLKsYwDQYJKoZIhvcNAQEL
|
||||||
|
BQAwXjELMAkGA1UEBhMCQ04xEzARBgNVBAoTClRlbnBheS5jb20xHTAbBgNVBAsT
|
||||||
|
FFRlbnBheS5jb20gQ0EgQ2VudGVyMRswGQYDVQQDExJUZW5wYXkuY29tIFJvb3Qg
|
||||||
|
Q0EwHhcNMjYwNDA1MDYwMjA1WhcNMzEwNDA0MDYwMjA1WjCBpTETMBEGA1UEAwwK
|
||||||
|
MTExMDUzMDAyMzEbMBkGA1UECgwS5b6u5L+h5ZWG5oi357O757ufMVEwTwYDVQQL
|
||||||
|
DEjmt7HlnLPluILlrp3lronljLropb/kuaHooZfpgZPogZrnhKblgaXouqvlt6Xk
|
||||||
|
vZzlrqTvvIjkuKrkvZPlt6XllYbmiLfvvIkxCzAJBgNVBAYTAkNOMREwDwYDVQQH
|
||||||
|
DAhTaGVuWmhlbjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAPAJ7FVi
|
||||||
|
shMDXjsI4bjWxRq1FT3J3K1tSernV3Ql/ZYaEs/dSay5a3ITuipcDsLnmMPrP8qf
|
||||||
|
CIfBT5h6HikfZ2xSiGcnRm5LNZsurSevpTgkSFf14ez3Eh3kMd/moRBwMZBZwftC
|
||||||
|
cx+HokiyqCGmR8OQRIurC/ZY7mSrBlSVDg4ohM7a0QPyJazEpxs1IKg58UadSP6D
|
||||||
|
gLqh/zDPn1+GBXIenCxYf2Sni5uommXdDh1/L8bga3DeZDcb1s57PX4cPGV131MO
|
||||||
|
uJfug/hzdHX7FuihXPobtUqb9e+IN4SDNJ/fgG+lcumg6G68dCcE3nZovtwFlqiB
|
||||||
|
EHs1gwUPRb7Cgo8CAwEAAaOBuTCBtjAJBgNVHRMEAjAAMAsGA1UdDwQEAwID+DCB
|
||||||
|
mwYDVR0fBIGTMIGQMIGNoIGKoIGHhoGEaHR0cDovL2V2Y2EuaXRydXMuY29tLmNu
|
||||||
|
L3B1YmxpYy9pdHJ1c2NybD9DQT0xQkQ0MjIwRTUwREJDMDRCMDZBRDM5NzU0OTg0
|
||||||
|
NkMwMUMzRThFQkQyJnNnPUhBQ0M0NzFCNjU0MjJFMTJCMjdBOUQzM0E4N0FEMUNE
|
||||||
|
RjU5MjZFMTQwMzcxMA0GCSqGSIb3DQEBCwUAA4IBAQApAXaaagCrm9kkUf6Po2AL
|
||||||
|
Hm2oE5a99PgQS6O1R3i9pxsDVOxo/Ftt6NzjE58y48yBU/g/hp6HIQyz9FyzFuz7
|
||||||
|
0QTOcmXHePfwNpLl6IPntxyk7XhKYx9Ebj4ZGSbby7L1E+9h/OwlnAJ60W1023CE
|
||||||
|
qGQWLZD7WgmceD5a6YUYaamwJ2q3sICIozzTkeaT/mn1Z89ML4ns6KWXo9q62FPo
|
||||||
|
TP5Fm9aJyu/50xLQKANDYu0qL0PcL/4HCU1/OrR9xYt7IsYT4Sa4f0y5HU4vkbVs
|
||||||
|
Q+MfBVusvvutRHIPXfzFa0+1wLDuCr990FLlNcsLSVvMaQx5DQJhiUFJCQbwbwf+
|
||||||
|
-----END CERTIFICATE-----
|
||||||
28
packages/server/certs/apiclient_key.pem
Normal file
28
packages/server/certs/apiclient_key.pem
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
-----BEGIN PRIVATE KEY-----
|
||||||
|
MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDwCexVYrITA147
|
||||||
|
COG41sUatRU9ydytbUnq51d0Jf2WGhLP3UmsuWtyE7oqXA7C55jD6z/KnwiHwU+Y
|
||||||
|
eh4pH2dsUohnJ0ZuSzWbLq0nr6U4JEhX9eHs9xId5DHf5qEQcDGQWcH7QnMfh6JI
|
||||||
|
sqghpkfDkESLqwv2WO5kqwZUlQ4OKITO2tED8iWsxKcbNSCoOfFGnUj+g4C6of8w
|
||||||
|
z59fhgVyHpwsWH9kp4ubqJpl3Q4dfy/G4Gtw3mQ3G9bOez1+HDxldd9TDriX7oP4
|
||||||
|
c3R1+xbooVz6G7VKm/XviDeEgzSf34BvpXLpoOhuvHQnBN52aL7cBZaogRB7NYMF
|
||||||
|
D0W+woKPAgMBAAECggEBAKKpZtDZ5+iAgMuqkiPKzpjxm2par8OKauvXR2k7EWQ1
|
||||||
|
WQgpYfK9V/VfLunjplEn1lr1wS3SpVoxgnnGT0f4swIxz6NvdwfoyXPWpppdKa4o
|
||||||
|
0CljQ21sZIeDCtU6mWzlSoESgiR9fDwikrOG9e6PmtQIoJqxF5Mh4rKvPsP0mii2
|
||||||
|
tnoCy8vltaSLcchWnkCRe3jWn+OZfI8qOE8gYw3jFbFMcKPXf47S88TkiV/Fi/VA
|
||||||
|
Vbn8S2my74OollqOZpy3ss4SuBzxmsT7CEL1obW3wPPbMlqyaJX7nGlCrOXd9c+s
|
||||||
|
9zx0X7n2iPpFhi39kHPZOyoYjBJ7Xpg9N3rHRjIMj7kCgYEA/HfyEU1JHUBk/zGA
|
||||||
|
cwSxW5OewixIlXCQ5eIQixaK+z3xG54Z31n8Tb+KMhH0FkMGFmzuv0IQbEJPERnc
|
||||||
|
qKLrc9oDZzEwXpypnrGgxsxEALxRnS1aHGH0gKs8FyjLLmcX49cZdqisTaeEjthz
|
||||||
|
FKos52fYyQGDbk5enF4VdRY5V3UCgYEA82V349iddfSwLovI/Qeq2QZaDeswBI/r
|
||||||
|
mV80kSIfVx71XReBFe6a7NZS6Fck76bkXiKliPCQo/vU8LZif7HUY7pO5X7JGZuY
|
||||||
|
ApyFoN02CtNKwBU4mbUx24hbPVUdHYdz5BaqwR2OIGWLZTP8X8Qkd5dLA2Sfln+1
|
||||||
|
auXQdjyxNXMCgYEAy9s2NM5I+Tuj0YNxCm6Bn0ZFbNhBC5nHBjhRz102f8P2SayR
|
||||||
|
i42nckf1GJTymH8qDTWMWhbIGAI6wb42NFzI7dTd5pcLTXoGZENdZOhPCKEG7XlP
|
||||||
|
R5e4y6R4cuLXnPJVkf1/bBaqelGHcahI1CjM9VUe8L8uFwVk07IMdWyqhHkCgYAq
|
||||||
|
ntYDm+bWxOYlAG1NgY41OpuCXHCoG9uRm85Eq8j5JH6qsnb0NDgEyPLzpG7fWEYd
|
||||||
|
Bcwe0qFBVdPP4uAUpDsgy3sNTMpCJbDUpDvyE0pnUuCACjdDEyuL2bDAaKsUhKeS
|
||||||
|
hTWZY2eD3MQwEI5c5qfMGT4VdgVMAUjvUxbR3YbaaQKBgQC7hDlqYZ8kCd6Im/q0
|
||||||
|
N8R9fEz/8ITlzWb9hAMEMAX/s54u0V0/kvIY6qgc9mZis9hJMhJpaK8G4hGrEbI3
|
||||||
|
kxHLOZd3enJw/BsbU/K2XA2pjv981GFlzGCSawgkmcY0pZ3U1DjwtAwC0HW/3c9E
|
||||||
|
f4hvelBU/Qi3HzrYkCcp8Ms54w==
|
||||||
|
-----END PRIVATE KEY-----
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
"prisma:generate": "prisma generate",
|
"prisma:generate": "prisma generate",
|
||||||
"prisma:migrate": "prisma migrate dev",
|
"prisma:migrate": "prisma migrate dev",
|
||||||
"prisma:seed": "ts-node prisma/seed.ts",
|
"prisma:seed": "ts-node prisma/seed.ts",
|
||||||
|
"studio:seed-gallery": "ts-node prisma/update-studio-gallery.ts",
|
||||||
"lint": "eslint \"{src,test}/**/*.ts\""
|
"lint": "eslint \"{src,test}/**/*.ts\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -52,9 +53,9 @@
|
|||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
"moduleFileExtensions": [
|
"moduleFileExtensions": [
|
||||||
|
"ts",
|
||||||
"js",
|
"js",
|
||||||
"json",
|
"json"
|
||||||
"ts"
|
|
||||||
],
|
],
|
||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
"testRegex": ".*\\.spec\\.ts$",
|
"testRegex": ".*\\.spec\\.ts$",
|
||||||
@@ -69,6 +70,7 @@
|
|||||||
"coverageDirectory": "../coverage",
|
"coverageDirectory": "../coverage",
|
||||||
"testEnvironment": "node",
|
"testEnvironment": "node",
|
||||||
"moduleNameMapper": {
|
"moduleNameMapper": {
|
||||||
|
"^@mp-pilates/shared$": "<rootDir>/../../shared/src/index.ts",
|
||||||
"^@mp-pilates/shared(.*)$": "<rootDir>/../../shared/src$1"
|
"^@mp-pilates/shared(.*)$": "<rootDir>/../../shared/src$1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `memberships` ADD COLUMN `total_times` INTEGER NULL;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `orders` ADD COLUMN `membership_id` VARCHAR(191) NULL;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX `memberships_user_id_card_type_id_idx` ON `memberships`(`user_id`, `card_type_id`);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX `orders_membership_id_idx` ON `orders`(`membership_id`);
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `orders` ADD CONSTRAINT `orders_membership_id_fkey` FOREIGN KEY (`membership_id`) REFERENCES `memberships`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- Backfill purchased-times snapshot for existing times/trial cards
|
||||||
|
UPDATE `memberships` `m`
|
||||||
|
INNER JOIN `card_types` `ct` ON `ct`.`id` = `m`.`card_type_id`
|
||||||
|
SET `m`.`total_times` = `ct`.`total_times`
|
||||||
|
WHERE `ct`.`total_times` IS NOT NULL AND `m`.`total_times` IS NULL;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `users` ADD COLUMN `last_login_at` DATETIME(3) NULL;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `bookings` ADD COLUMN `membership_times_deducted` BOOLEAN NULL;
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `lesson_supplements` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`user_id` VARCHAR(191) NOT NULL,
|
||||||
|
`request_id` VARCHAR(80) NOT NULL,
|
||||||
|
`quantity` INTEGER NOT NULL,
|
||||||
|
`membership_id` VARCHAR(191) NULL,
|
||||||
|
`deducted_times` INTEGER NOT NULL DEFAULT 0,
|
||||||
|
`card_name` VARCHAR(191) NULL,
|
||||||
|
`remark` VARCHAR(200) NULL,
|
||||||
|
`operator_id` VARCHAR(191) NOT NULL,
|
||||||
|
`operator_name` VARCHAR(191) NOT NULL,
|
||||||
|
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`revoked_at` DATETIME(3) NULL,
|
||||||
|
`revoked_by` VARCHAR(191) NULL,
|
||||||
|
|
||||||
|
INDEX `lesson_supplements_user_id_revoked_at_created_at_idx`(`user_id`, `revoked_at`, `created_at`),
|
||||||
|
UNIQUE INDEX `lesson_supplements_user_id_request_id_key`(`user_id`, `request_id`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `lesson_supplements` ADD CONSTRAINT `lesson_supplements_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `lesson_supplements` ADD CONSTRAINT `lesson_supplements_membership_id_fkey` FOREIGN KEY (`membership_id`) REFERENCES `memberships`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
@@ -38,6 +38,7 @@ enum TimeSlotSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum BookingStatus {
|
enum BookingStatus {
|
||||||
|
PENDING_CONFIRMATION
|
||||||
CONFIRMED
|
CONFIRMED
|
||||||
CANCELLED
|
CANCELLED
|
||||||
COMPLETED
|
COMPLETED
|
||||||
@@ -50,6 +51,24 @@ enum OrderStatus {
|
|||||||
REFUNDED
|
REFUNDED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum FlashSaleStatus {
|
||||||
|
DRAFT
|
||||||
|
ACTIVE
|
||||||
|
ENDED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum FlashSaleOrderStatus {
|
||||||
|
RESERVED
|
||||||
|
PAID
|
||||||
|
EXPIRED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum InviteReferralStatus {
|
||||||
|
REGISTERED
|
||||||
|
TRIAL_PURCHASED
|
||||||
|
QUALIFIED
|
||||||
|
}
|
||||||
|
|
||||||
// ===== Models =====
|
// ===== Models =====
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
@@ -60,16 +79,49 @@ model User {
|
|||||||
nickname String @default("")
|
nickname String @default("")
|
||||||
avatarUrl String? @map("avatar_url")
|
avatarUrl String? @map("avatar_url")
|
||||||
role UserRole @default(MEMBER)
|
role UserRole @default(MEMBER)
|
||||||
|
adminBookingSubscriptionCount Int @default(0) @map("admin_booking_subscription_count")
|
||||||
|
lastLoginAt DateTime? @map("last_login_at")
|
||||||
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")
|
||||||
|
|
||||||
|
lessonSupplements LessonSupplement[]
|
||||||
memberships Membership[]
|
memberships Membership[]
|
||||||
bookings Booking[]
|
bookings Booking[]
|
||||||
orders Order[]
|
orders Order[]
|
||||||
|
flashSaleOrders FlashSaleOrder[]
|
||||||
|
subscriptionMessageConsents SubscriptionMessageConsent[]
|
||||||
|
sentInviteReferrals InviteReferral[] @relation("InviteReferralInviter")
|
||||||
|
receivedInviteReferral InviteReferral[] @relation("InviteReferralInvitee")
|
||||||
|
inviteRewardGrants InviteRewardGrant[] @relation("InviteRewardGrantInviter")
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model SubscriptionMessageConsent {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String @map("user_id")
|
||||||
|
templateId String @map("template_id")
|
||||||
|
scene String
|
||||||
|
totalRequestCount Int @default(0) @map("total_request_count")
|
||||||
|
acceptCount Int @default(0) @map("accept_count")
|
||||||
|
rejectCount Int @default(0) @map("reject_count")
|
||||||
|
banCount Int @default(0) @map("ban_count")
|
||||||
|
filterCount Int @default(0) @map("filter_count")
|
||||||
|
sentCount Int @default(0) @map("sent_count")
|
||||||
|
lastResult String @map("last_result")
|
||||||
|
lastRequestedAt DateTime @default(now()) @map("last_requested_at")
|
||||||
|
lastSentAt DateTime? @map("last_sent_at")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
|
||||||
|
@@unique([userId, templateId, scene])
|
||||||
|
@@index([userId])
|
||||||
|
@@index([scene])
|
||||||
|
@@map("subscription_message_consents")
|
||||||
|
}
|
||||||
|
|
||||||
model CardType {
|
model CardType {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
name String
|
name String
|
||||||
@@ -79,6 +131,7 @@ model CardType {
|
|||||||
price Decimal @db.Decimal(10, 0)
|
price Decimal @db.Decimal(10, 0)
|
||||||
originalPrice Decimal? @map("original_price") @db.Decimal(10, 0)
|
originalPrice Decimal? @map("original_price") @db.Decimal(10, 0)
|
||||||
description String?
|
description String?
|
||||||
|
coverUrl String? @map("cover_url")
|
||||||
isActive Boolean @default(true) @map("is_active")
|
isActive Boolean @default(true) @map("is_active")
|
||||||
sortOrder Int @default(0) @map("sort_order")
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
@@ -86,6 +139,7 @@ model CardType {
|
|||||||
|
|
||||||
memberships Membership[]
|
memberships Membership[]
|
||||||
orders Order[]
|
orders Order[]
|
||||||
|
flashSales FlashSale[]
|
||||||
|
|
||||||
@@map("card_types")
|
@@map("card_types")
|
||||||
}
|
}
|
||||||
@@ -95,17 +149,22 @@ model Membership {
|
|||||||
userId String @map("user_id")
|
userId String @map("user_id")
|
||||||
cardTypeId String @map("card_type_id")
|
cardTypeId String @map("card_type_id")
|
||||||
remainingTimes Int? @map("remaining_times")
|
remainingTimes Int? @map("remaining_times")
|
||||||
|
totalTimes Int? @map("total_times")
|
||||||
startDate DateTime @map("start_date")
|
startDate DateTime @map("start_date")
|
||||||
expireDate DateTime @map("expire_date")
|
expireDate DateTime @map("expire_date")
|
||||||
status MembershipStatus @default(ACTIVE)
|
status MembershipStatus @default(ACTIVE)
|
||||||
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")
|
||||||
|
|
||||||
|
lessonSupplements LessonSupplement[]
|
||||||
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])
|
||||||
bookings Booking[]
|
bookings Booking[]
|
||||||
|
orders Order[]
|
||||||
|
inviteRewardGrants InviteRewardGrant[]
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
|
@@index([userId, cardTypeId])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
@@map("memberships")
|
@@map("memberships")
|
||||||
}
|
}
|
||||||
@@ -152,14 +211,21 @@ model Booking {
|
|||||||
userId String @map("user_id")
|
userId String @map("user_id")
|
||||||
timeSlotId String @map("time_slot_id")
|
timeSlotId String @map("time_slot_id")
|
||||||
membershipId String @map("membership_id")
|
membershipId String @map("membership_id")
|
||||||
status BookingStatus @default(CONFIRMED)
|
membershipTimesDeducted Boolean? @map("membership_times_deducted")
|
||||||
|
status BookingStatus @default(PENDING_CONFIRMATION)
|
||||||
cancelledAt DateTime? @map("cancelled_at")
|
cancelledAt DateTime? @map("cancelled_at")
|
||||||
|
confirmedAt DateTime? @map("confirmed_at")
|
||||||
|
completedAt DateTime? @map("completed_at")
|
||||||
|
operatorId String? @map("operator_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])
|
||||||
timeSlot TimeSlot @relation(fields: [timeSlotId], references: [id])
|
timeSlot TimeSlot @relation(fields: [timeSlotId], references: [id])
|
||||||
membership Membership @relation(fields: [membershipId], references: [id])
|
membership Membership @relation(fields: [membershipId], references: [id])
|
||||||
|
qualifiedInviteReferrals InviteReferral[]
|
||||||
|
|
||||||
|
statusHistory BookingStatusHistory[]
|
||||||
|
|
||||||
@@unique([userId, timeSlotId])
|
@@unique([userId, timeSlotId])
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@ -167,26 +233,88 @@ model Booking {
|
|||||||
@@map("bookings")
|
@@map("bookings")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model BookingStatusHistory {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
bookingId String @map("booking_id")
|
||||||
|
fromStatus String? @map("from_status")
|
||||||
|
toStatus String @map("to_status")
|
||||||
|
operatorId String? @map("operator_id")
|
||||||
|
remark String?
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
booking Booking @relation(fields: [bookingId], references: [id])
|
||||||
|
|
||||||
|
@@index([bookingId])
|
||||||
|
@@map("booking_status_history")
|
||||||
|
}
|
||||||
|
|
||||||
model Order {
|
model Order {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
userId String @map("user_id")
|
userId String @map("user_id")
|
||||||
cardTypeId String @map("card_type_id")
|
cardTypeId String @map("card_type_id")
|
||||||
|
membershipId String? @map("membership_id")
|
||||||
orderNo String @unique @map("order_no")
|
orderNo String @unique @map("order_no")
|
||||||
amount Decimal @db.Decimal(10, 0)
|
amount Decimal @db.Decimal(10, 0)
|
||||||
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])
|
||||||
|
flashSaleOrder FlashSaleOrder?
|
||||||
|
inviteReferrals InviteReferral[]
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
|
@@index([membershipId])
|
||||||
@@map("orders")
|
@@map("orders")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model InviteReferral {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
inviterId String @map("inviter_id")
|
||||||
|
inviteeId String @unique @map("invitee_id")
|
||||||
|
status InviteReferralStatus @default(REGISTERED)
|
||||||
|
trialOrderId String? @unique @map("trial_order_id")
|
||||||
|
qualifiedBookingId String? @unique @map("qualified_booking_id")
|
||||||
|
invitedAt DateTime @default(now()) @map("invited_at")
|
||||||
|
trialPurchasedAt DateTime? @map("trial_purchased_at")
|
||||||
|
qualifiedAt DateTime? @map("qualified_at")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
inviter User @relation("InviteReferralInviter", fields: [inviterId], references: [id])
|
||||||
|
invitee User @relation("InviteReferralInvitee", fields: [inviteeId], references: [id])
|
||||||
|
trialOrder Order? @relation(fields: [trialOrderId], references: [id])
|
||||||
|
qualifiedBooking Booking? @relation(fields: [qualifiedBookingId], references: [id])
|
||||||
|
|
||||||
|
@@unique([inviterId, inviteeId])
|
||||||
|
@@index([inviterId, status])
|
||||||
|
@@index([status])
|
||||||
|
@@map("invite_referrals")
|
||||||
|
}
|
||||||
|
|
||||||
|
model InviteRewardGrant {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
inviterId String @map("inviter_id")
|
||||||
|
membershipId String? @map("membership_id")
|
||||||
|
qualifiedReferralCount Int @map("qualified_referral_count")
|
||||||
|
rewardTimes Int @default(1) @map("reward_times")
|
||||||
|
grantedAt DateTime @default(now()) @map("granted_at")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
inviter User @relation("InviteRewardGrantInviter", fields: [inviterId], references: [id])
|
||||||
|
membership Membership? @relation(fields: [membershipId], references: [id], onDelete: Restrict)
|
||||||
|
|
||||||
|
@@index([inviterId, grantedAt])
|
||||||
|
@@map("invite_reward_grants")
|
||||||
|
}
|
||||||
|
|
||||||
model StudioConfig {
|
model StudioConfig {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
name String
|
name String
|
||||||
@@ -202,3 +330,72 @@ 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.
|
||||||
|
model LessonSupplement {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String @map("user_id")
|
||||||
|
requestId String @map("request_id") @db.VarChar(80)
|
||||||
|
quantity Int
|
||||||
|
membershipId String? @map("membership_id")
|
||||||
|
deductedTimes Int @default(0) @map("deducted_times")
|
||||||
|
cardName String? @map("card_name")
|
||||||
|
remark String? @db.VarChar(200)
|
||||||
|
operatorId String @map("operator_id")
|
||||||
|
operatorName String @map("operator_name")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
revokedAt DateTime? @map("revoked_at")
|
||||||
|
revokedBy String? @map("revoked_by")
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
membership Membership? @relation(fields: [membershipId], references: [id], onDelete: Restrict)
|
||||||
|
|
||||||
|
@@unique([userId, requestId])
|
||||||
|
@@index([userId, revokedAt, createdAt])
|
||||||
|
@@map("lesson_supplements")
|
||||||
|
}
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ async function main() {
|
|||||||
openid: 'admin_test_openid',
|
openid: 'admin_test_openid',
|
||||||
nickname: '教练',
|
nickname: '教练',
|
||||||
role: UserRole.ADMIN,
|
role: UserRole.ADMIN,
|
||||||
|
adminBookingSubscriptionCount: 0,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
console.log(' ✅ Admin user created')
|
console.log(' ✅ Admin user created')
|
||||||
|
|||||||
39
packages/server/prisma/update-studio-gallery.ts
Normal file
39
packages/server/prisma/update-studio-gallery.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
import { DEFAULT_STUDIO_GALLERY_PHOTOS } from '@mp-pilates/shared'
|
||||||
|
|
||||||
|
const prisma = new PrismaClient()
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('🖼️ Syncing studio gallery photos...')
|
||||||
|
|
||||||
|
const photos = [...DEFAULT_STUDIO_GALLERY_PHOTOS]
|
||||||
|
const existing = await prisma.studioConfig.findFirst({ select: { id: true } })
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await prisma.studioConfig.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: { photos },
|
||||||
|
})
|
||||||
|
console.log(` ✅ Updated existing studio config with ${photos.length} gallery images`)
|
||||||
|
} else {
|
||||||
|
await prisma.studioConfig.create({
|
||||||
|
data: {
|
||||||
|
name: '普拉提工作室',
|
||||||
|
address: '请在管理后台设置地址',
|
||||||
|
phone: '请在管理后台设置电话',
|
||||||
|
cancelHoursLimit: 2,
|
||||||
|
photos,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
console.log(` ✅ Created studio config with ${photos.length} gallery images`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('❌ Studio gallery sync failed:', error)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect()
|
||||||
|
})
|
||||||
37
packages/server/src/admin/admin.controller.ts
Normal file
37
packages/server/src/admin/admin.controller.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { Controller, Get, UseGuards } from '@nestjs/common'
|
||||||
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard'
|
||||||
|
import { Roles } from '../auth/roles.decorator'
|
||||||
|
import { RolesGuard } from '../auth/roles.guard'
|
||||||
|
import { UserRole } from '@mp-pilates/shared'
|
||||||
|
import { PrismaService } from '../prisma/prisma.service'
|
||||||
|
|
||||||
|
interface AdminStats {
|
||||||
|
todayBookings: number
|
||||||
|
totalOrders: number
|
||||||
|
totalBookings: number
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller('admin')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
export class AdminController {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
@Get('stats')
|
||||||
|
async getStats(): Promise<AdminStats> {
|
||||||
|
const today = new Date()
|
||||||
|
today.setUTCHours(0, 0, 0, 0)
|
||||||
|
|
||||||
|
const [todayBookings, totalOrders, totalBookings] = await Promise.all([
|
||||||
|
this.prisma.booking.count({
|
||||||
|
where: {
|
||||||
|
timeSlot: { date: today },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.order.count(),
|
||||||
|
this.prisma.booking.count(),
|
||||||
|
])
|
||||||
|
|
||||||
|
return { todayBookings, totalOrders, totalBookings }
|
||||||
|
}
|
||||||
|
}
|
||||||
7
packages/server/src/admin/admin.module.ts
Normal file
7
packages/server/src/admin/admin.module.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { Module } from '@nestjs/common'
|
||||||
|
import { AdminController } from './admin.controller'
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [AdminController],
|
||||||
|
})
|
||||||
|
export class AdminModule {}
|
||||||
@@ -10,6 +10,9 @@ import { MembershipModule } from './membership/membership.module'
|
|||||||
import { BookingModule } from './booking/booking.module'
|
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 { FlashSaleModule } from './flash-sale/flash-sale.module'
|
||||||
|
import { InviteModule } from './invite/invite.module'
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -26,6 +29,9 @@ import { PaymentModule } from './payment/payment.module'
|
|||||||
BookingModule,
|
BookingModule,
|
||||||
SchedulerModule,
|
SchedulerModule,
|
||||||
PaymentModule,
|
PaymentModule,
|
||||||
|
AdminModule,
|
||||||
|
FlashSaleModule,
|
||||||
|
InviteModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing'
|
import { Test, TestingModule } from '@nestjs/testing'
|
||||||
import { JwtService } from '@nestjs/jwt'
|
import { JwtService } from '@nestjs/jwt'
|
||||||
import { UnauthorizedException } from '@nestjs/common'
|
import { UnauthorizedException } from '@nestjs/common'
|
||||||
import { UserRole } from '@mp-pilates/shared'
|
import { MembershipStatus, UserRole } from '@mp-pilates/shared'
|
||||||
|
import { ConfigService } from '@nestjs/config'
|
||||||
import { AuthService, RANDOM_FN_TOKEN } from '../auth.service'
|
import { AuthService, RANDOM_FN_TOKEN } from '../auth.service'
|
||||||
import { WechatService } from '../wechat.service'
|
import { WechatService } from '../wechat.service'
|
||||||
import { PrismaService } from '../../prisma/prisma.service'
|
import { PrismaService } from '../../prisma/prisma.service'
|
||||||
|
import { InviteService } from '../../invite/invite.service'
|
||||||
|
|
||||||
// ─── Fixtures ────────────────────────────────────────────────────────────────
|
// ─── Fixtures ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -22,6 +24,8 @@ const mockUser = {
|
|||||||
nickname: TEST_NICKNAME,
|
nickname: TEST_NICKNAME,
|
||||||
avatarUrl: null,
|
avatarUrl: null,
|
||||||
role: UserRole.MEMBER,
|
role: UserRole.MEMBER,
|
||||||
|
adminBookingSubscriptionCount: 0,
|
||||||
|
lastLoginAt: new Date('2024-01-01T00:00:00Z'),
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
}
|
}
|
||||||
@@ -29,6 +33,9 @@ const mockUser = {
|
|||||||
// ─── Mocks ───────────────────────────────────────────────────────────────────
|
// ─── Mocks ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const mockPrismaService = {
|
const mockPrismaService = {
|
||||||
|
membership: {
|
||||||
|
count: jest.fn(),
|
||||||
|
},
|
||||||
user: {
|
user: {
|
||||||
findUnique: jest.fn(),
|
findUnique: jest.fn(),
|
||||||
findUniqueOrThrow: jest.fn(),
|
findUniqueOrThrow: jest.fn(),
|
||||||
@@ -46,6 +53,14 @@ const mockJwtService = {
|
|||||||
sign: jest.fn(),
|
sign: jest.fn(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mockInviteService = {
|
||||||
|
bindInviterToUser: jest.fn(),
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockConfigService = {
|
||||||
|
get: jest.fn(),
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Tests ───────────────────────────────────────────────────────────────────
|
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('AuthService', () => {
|
describe('AuthService', () => {
|
||||||
@@ -58,6 +73,8 @@ describe('AuthService', () => {
|
|||||||
{ provide: PrismaService, useValue: mockPrismaService },
|
{ provide: PrismaService, useValue: mockPrismaService },
|
||||||
{ provide: WechatService, useValue: mockWechatService },
|
{ provide: WechatService, useValue: mockWechatService },
|
||||||
{ provide: JwtService, useValue: mockJwtService },
|
{ provide: JwtService, useValue: mockJwtService },
|
||||||
|
{ provide: InviteService, useValue: mockInviteService },
|
||||||
|
{ provide: ConfigService, useValue: mockConfigService },
|
||||||
{ provide: RANDOM_FN_TOKEN, useValue: () => 0 }, // deterministic nickname
|
{ provide: RANDOM_FN_TOKEN, useValue: () => 0 }, // deterministic nickname
|
||||||
],
|
],
|
||||||
}).compile()
|
}).compile()
|
||||||
@@ -66,6 +83,8 @@ describe('AuthService', () => {
|
|||||||
|
|
||||||
jest.clearAllMocks()
|
jest.clearAllMocks()
|
||||||
mockJwtService.sign.mockReturnValue(JWT_TOKEN)
|
mockJwtService.sign.mockReturnValue(JWT_TOKEN)
|
||||||
|
mockPrismaService.membership.count.mockResolvedValue(0)
|
||||||
|
mockConfigService.get.mockReturnValue('tmpl-booking-confirmed')
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── login ──────────────────────────────────────────────────────────────────
|
// ── login ──────────────────────────────────────────────────────────────────
|
||||||
@@ -91,9 +110,35 @@ describe('AuthService', () => {
|
|||||||
where: { openid: OPENID },
|
where: { openid: OPENID },
|
||||||
})
|
})
|
||||||
expect(mockPrismaService.user.create).toHaveBeenCalledWith({
|
expect(mockPrismaService.user.create).toHaveBeenCalledWith({
|
||||||
data: { openid: OPENID, nickname: TEST_NICKNAME },
|
data: {
|
||||||
|
openid: OPENID,
|
||||||
|
nickname: TEST_NICKNAME,
|
||||||
|
adminBookingSubscriptionCount: 0,
|
||||||
|
lastLoginAt: expect.any(Date),
|
||||||
|
},
|
||||||
})
|
})
|
||||||
expect(result.user).toEqual(mockUser)
|
expect(result.user).toEqual(expect.objectContaining({
|
||||||
|
id: mockUser.id,
|
||||||
|
phone: mockUser.phone,
|
||||||
|
nickname: mockUser.nickname,
|
||||||
|
avatarUrl: mockUser.avatarUrl,
|
||||||
|
role: mockUser.role,
|
||||||
|
activeMembershipCount: 0,
|
||||||
|
inviteShareEligible: false,
|
||||||
|
adminBookingSubscriptionCount: 0,
|
||||||
|
}))
|
||||||
|
expect(result.user.subscriptionMessageTemplates.templates).toHaveLength(2)
|
||||||
|
expect(result.isNewUser).toBe(true)
|
||||||
|
expect(mockInviteService.bindInviterToUser).toHaveBeenCalledWith(USER_ID, undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('binds inviter for new users when inviterId is present', async () => {
|
||||||
|
mockPrismaService.user.findUnique.mockResolvedValue(null)
|
||||||
|
mockPrismaService.user.create.mockResolvedValue(mockUser)
|
||||||
|
|
||||||
|
await authService.login(loginCode, undefined, undefined, 'inviter-001')
|
||||||
|
|
||||||
|
expect(mockInviteService.bindInviterToUser).toHaveBeenCalledWith(USER_ID, 'inviter-001')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('creates user with unionid when present', async () => {
|
it('creates user with unionid when present', async () => {
|
||||||
@@ -109,12 +154,19 @@ describe('AuthService', () => {
|
|||||||
await authService.login(loginCode)
|
await authService.login(loginCode)
|
||||||
|
|
||||||
expect(mockPrismaService.user.create).toHaveBeenCalledWith({
|
expect(mockPrismaService.user.create).toHaveBeenCalledWith({
|
||||||
data: { openid: OPENID, unionid, nickname: TEST_NICKNAME },
|
data: {
|
||||||
|
openid: OPENID,
|
||||||
|
unionid,
|
||||||
|
nickname: TEST_NICKNAME,
|
||||||
|
adminBookingSubscriptionCount: 0,
|
||||||
|
lastLoginAt: expect.any(Date),
|
||||||
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns existing user when openid already exists', async () => {
|
it('returns existing user when openid already exists', async () => {
|
||||||
mockPrismaService.user.findUnique.mockResolvedValue(mockUser)
|
mockPrismaService.user.findUnique.mockResolvedValue(mockUser)
|
||||||
|
mockPrismaService.user.update.mockResolvedValue(mockUser)
|
||||||
|
|
||||||
const result = await authService.login(loginCode)
|
const result = await authService.login(loginCode)
|
||||||
|
|
||||||
@@ -122,11 +174,21 @@ describe('AuthService', () => {
|
|||||||
where: { openid: OPENID },
|
where: { openid: OPENID },
|
||||||
})
|
})
|
||||||
expect(mockPrismaService.user.create).not.toHaveBeenCalled()
|
expect(mockPrismaService.user.create).not.toHaveBeenCalled()
|
||||||
expect(result.user).toEqual(mockUser)
|
expect(mockPrismaService.user.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: USER_ID },
|
||||||
|
data: { lastLoginAt: expect.any(Date) },
|
||||||
|
})
|
||||||
|
expect(result.user).toEqual(expect.objectContaining({
|
||||||
|
id: mockUser.id,
|
||||||
|
nickname: mockUser.nickname,
|
||||||
|
role: mockUser.role,
|
||||||
|
}))
|
||||||
|
expect(result.isNewUser).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns a valid JWT token', async () => {
|
it('returns a valid JWT token', async () => {
|
||||||
mockPrismaService.user.findUnique.mockResolvedValue(mockUser)
|
mockPrismaService.user.findUnique.mockResolvedValue(mockUser)
|
||||||
|
mockPrismaService.user.update.mockResolvedValue(mockUser)
|
||||||
|
|
||||||
const result = await authService.login(loginCode)
|
const result = await authService.login(loginCode)
|
||||||
|
|
||||||
@@ -139,13 +201,40 @@ describe('AuthService', () => {
|
|||||||
|
|
||||||
it('returns both token and user in result', async () => {
|
it('returns both token and user in result', async () => {
|
||||||
mockPrismaService.user.findUnique.mockResolvedValue(mockUser)
|
mockPrismaService.user.findUnique.mockResolvedValue(mockUser)
|
||||||
|
mockPrismaService.user.update.mockResolvedValue(mockUser)
|
||||||
|
|
||||||
const result = await authService.login(loginCode)
|
const result = await authService.login(loginCode)
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual(expect.objectContaining({
|
||||||
token: JWT_TOKEN,
|
token: JWT_TOKEN,
|
||||||
user: mockUser,
|
isNewUser: false,
|
||||||
|
}))
|
||||||
|
expect(result.user).toEqual(expect.objectContaining({
|
||||||
|
id: mockUser.id,
|
||||||
|
subscriptionMessageTemplates: {
|
||||||
|
templates: [
|
||||||
|
expect.objectContaining({ scene: 'BOOKING_CREATED' }),
|
||||||
|
expect.objectContaining({ scene: 'ADMIN_BOOKING_CREATED' }),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('includes active membership count and invite eligibility in login response', async () => {
|
||||||
|
mockPrismaService.user.findUnique.mockResolvedValue(mockUser)
|
||||||
|
mockPrismaService.user.update.mockResolvedValue(mockUser)
|
||||||
|
mockPrismaService.membership.count.mockResolvedValue(2)
|
||||||
|
|
||||||
|
const result = await authService.login(loginCode)
|
||||||
|
|
||||||
|
expect(mockPrismaService.membership.count).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
userId: USER_ID,
|
||||||
|
status: MembershipStatus.ACTIVE,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(result.user.activeMembershipCount).toBe(2)
|
||||||
|
expect(result.user.inviteShareEligible).toBe(true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -163,6 +252,7 @@ describe('AuthService', () => {
|
|||||||
sessionKey: SESSION_KEY,
|
sessionKey: SESSION_KEY,
|
||||||
})
|
})
|
||||||
mockPrismaService.user.findUnique.mockResolvedValue(mockUser)
|
mockPrismaService.user.findUnique.mockResolvedValue(mockUser)
|
||||||
|
mockPrismaService.user.update.mockResolvedValue(mockUser)
|
||||||
mockJwtService.sign.mockReturnValue(JWT_TOKEN)
|
mockJwtService.sign.mockReturnValue(JWT_TOKEN)
|
||||||
await authService.login('login_code')
|
await authService.login('login_code')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import {
|
import {
|
||||||
Controller,
|
|
||||||
Post,
|
|
||||||
Body,
|
Body,
|
||||||
UseGuards,
|
Controller,
|
||||||
Request,
|
|
||||||
HttpCode,
|
HttpCode,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
|
Post,
|
||||||
|
Request,
|
||||||
|
UseGuards,
|
||||||
} from '@nestjs/common'
|
} from '@nestjs/common'
|
||||||
|
import type { UserProfileResponse } from '@mp-pilates/shared'
|
||||||
import { AuthService } from './auth.service'
|
import { AuthService } from './auth.service'
|
||||||
import { LoginDto } from './dto/login.dto'
|
import { LoginDto } from './dto/login.dto'
|
||||||
import { BindPhoneDto } from './dto/bind-phone.dto'
|
import { BindPhoneDto } from './dto/bind-phone.dto'
|
||||||
@@ -24,11 +25,12 @@ export class AuthController {
|
|||||||
|
|
||||||
@Post('login')
|
@Post('login')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
async login(@Body() loginDto: LoginDto): Promise<{ token: string; user: User }> {
|
async login(@Body() loginDto: LoginDto): Promise<{ token: string; user: UserProfileResponse; isNewUser: boolean }> {
|
||||||
return this.authService.login(
|
return this.authService.login(
|
||||||
loginDto.code,
|
loginDto.code,
|
||||||
loginDto.nickname,
|
loginDto.nickname,
|
||||||
loginDto.avatarUrl,
|
loginDto.avatarUrl,
|
||||||
|
loginDto.inviterId,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,16 +2,21 @@ import { Module } from '@nestjs/common'
|
|||||||
import { PassportModule } from '@nestjs/passport'
|
import { PassportModule } from '@nestjs/passport'
|
||||||
import { JwtModule } from '@nestjs/jwt'
|
import { JwtModule } from '@nestjs/jwt'
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config'
|
import { ConfigModule, ConfigService } from '@nestjs/config'
|
||||||
|
import { MembershipModule } from '../membership/membership.module'
|
||||||
import { AuthService, RANDOM_FN_TOKEN } from './auth.service'
|
import { AuthService, RANDOM_FN_TOKEN } from './auth.service'
|
||||||
import { AuthController } from './auth.controller'
|
import { AuthController } from './auth.controller'
|
||||||
import { WechatService } from './wechat.service'
|
import { WechatService } from './wechat.service'
|
||||||
import { JwtStrategy } from './jwt.strategy'
|
import { JwtStrategy } from './jwt.strategy'
|
||||||
import { JwtAuthGuard } from './jwt-auth.guard'
|
import { JwtAuthGuard } from './jwt-auth.guard'
|
||||||
import { RolesGuard } from './roles.guard'
|
import { RolesGuard } from './roles.guard'
|
||||||
|
import { InviteModule } from '../invite/invite.module'
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
PassportModule,
|
PassportModule,
|
||||||
|
InviteModule,
|
||||||
|
ConfigModule,
|
||||||
|
MembershipModule,
|
||||||
JwtModule.registerAsync({
|
JwtModule.registerAsync({
|
||||||
imports: [ConfigModule],
|
imports: [ConfigModule],
|
||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user