feat: 支持新版的首页 UI

This commit is contained in:
richarjiang
2026-10-25 21:15:15 +08:00
parent 4ca32c22f9
commit d93cc921db
30 changed files with 827 additions and 223 deletions

View File

@@ -0,0 +1,62 @@
# 首页顶部 · 轻盈陪伴
按 2026-10-25 确认的双状态设计稿实现,主要代码在 `packages/app/src/pages/home/index.vue` 与 `components/BrandBanner.vue`。继续使用 Vue 3、uni-app 与微信原生 tabBar,不新增运行时依赖。
## 展示与业务
- 馆名、Logo、昵称、会员卡、预约和已授权案例来自现有接口;设计稿中的晓晓、8 次、26 日等示例数据不会进入生产页面。
- 游客显示「欢迎你」和身体评估入口。已登录但无会员卡的新用户仍显示欢迎文案。会员含已过期会员显示昵称;微信默认昵称和空昵称显示「欢迎回来」。身份尚在加载时使用中性文案,不提前判定新用户。
- 日期和早上 / 上午 / 下午 / 晚上问候使用中国时间。在前台按分钟更新,跨日更新日期和提醒,进入后台停止定时器。提醒按自然日稳定轮换,不在刷新时随机跳动。
- 可用会员卡展示余次或余天,实时排除过期、待生效和次数用尽的卡。待生效卡可进入会员卡页面,其余续卡继续使用现有续卡与购卡路径。首次加载展示骨架,失败可重试。
- 新用户(游客及已登录但无会员卡的用户)不展示案例,也不请求案例接口;问候和提醒后直接承接身体评估入口。老学员首页推荐一则真实案例,全部案例入口保留;原图预览、前后标记、授权说明、分页图片和详情入口保留。没有公开案例时不放置虚构的学员或效果图。
- 即将上课最多展示两节,按时间排序,排除已取消和已开始的课程。预约日期使用 UTC 日期列解释,不依赖设备时区。
- 下拉刷新合并重复请求;网络失败保留已加载内容。登录变化会重新获取对应数据;退出时清除预约并使在途响应失效,个人资料同样防止旧会话响应回写。
## 布局与动效
- 暖白 `#FBF9F6`、鼠尾草绿 `#6B8276`、暖灰文字。头图、圆角、字级与留白按确认稿实现,品牌使用真实馆名和 Logo。
- 顶部品牌栏固定,安全区和可用宽度读取微信真实胶囊位置。其余内容自然滚动,不再使用旧版大背景 Banner、浮层把手和磨砂重叠卡片。
- 问候区 520ms 淡入并上移 8px 左右;照片延迟 80ms、用 600ms 进入;提醒延迟 160ms;下方内容延迟 220ms。整组动效在约 720ms 内完成,850ms 后移除入场状态。
- 每次页面实例只播放一次入场动画,切换 tab、刷新、数据更新不会重复播放。照片固定尺寸,加载完成后淡入;失败有同尺寸的文字后备。按钮按压反馈 180ms。
- `prefers-reduced-motion: reduce` 关闭入场、加载呼吸和按压过渡。原生 tabBar 更换为可编辑矢量几何绘制的八张图标,选中项使用鼠尾草绿。
## 图片
生产封面:`packages/app/src/static/home/studio-morning.jpg`,1536×1024 JPEG,约 266 KiB。由内置 imagegen 从确认稿中的核心床照片提取重建,再以 JPEG 编码,随包提供。旧后台 `bannerUrl` 不再用于这个新的欢迎区;馆名与 Logo 配置继续生效。
生成提示词(使用确认稿作为引用图片,内置工具模式):
> Use case: compositing / precise-object-edit.
> Asset type: production homepage hero photograph for a Chinese Pilates studio mini-program.
> The provided image is the approved UI design. Extract and reproduce ONLY the wide photographic image inside the top hero rectangle (identical photo on left and right screen). Output the PHOTOGRAPH ALONE, edge to edge, landscape 3:2 aspect ratio at high resolution, not the UI screenshot, no rounded corners (corners will be implemented in CSS).
> Preserve the reference photo's exact camera angle, composition, subject placement, color, soft morning light and natural texture as closely as possible: close-up of wood Pilates reformer, cream upholstered carriage on left, oatmeal towel draped over left foreground, black metal handle/strap diagonal across top left, light wooden side rail diagonal across right foreground, soft sunlit oak floor, sheer ivory linen curtain upper right and warm plaster walls. Quiet, warm real editorial interior photography, not illustration, no people, no text, no logos, no icons, no UI, no borders, no watermark. This will be used at approximately 350px wide by 230px tall. Only the approved photo, do not redesign the scene.
图标可通过 `python3 packages/app/preview/generate-tab-icons.py` 重新生成,需要 Pillow;生产构建不需要 Python。
## 本地验收
```sh
pnpm --filter @mp-pilates/app dev:home-preview
```
打开 `http://127.0.0.1:5189/__home-preview`。该页面将实际应用嵌入手机尺寸视口,可切换新老用户、过期会员、空数据、错误重试、慢网络、长昵称以及 320 / 375 / 390 / 430px 宽度。
预览使用专用 Vite 配置、每个 iframe 独立的内存存储和模拟接口,不读取或修改真实登录凭据,不写入线上数据。只将真实业务页面用于布局和交互验证,不将预览模拟数据打入微信生产包。
```sh
pnpm --filter @mp-pilates/app type-check
pnpm --filter @mp-pilates/app test:home
pnpm build:app
```
`test:home` 覆盖中国时区问候边界、跨日提醒、昵称兜底、会员天数、即将上课的过滤排序、退出后的迟到响应、请求乱序和失败重试。
本次仅涉及小程序前端及本地预览;无需数据库迁移或新增环境变量。生产构建目录仍为 `packages/app/dist/build/mp-weixin`。未执行上传或发布。
## 本次验证结果
- `vue-tsc --noEmit`、10 项首页测试、微信生产构建和 `git diff --check` 均通过。
- 微信开发者工具 Stable 2.02.2608060 / 基础库 3.16.2 实际运行,使用现有账号和线上只读接口检查馆名、Logo、昵称与会员余次,控制台 Errors 为 0。工具仍有原有 ProductShop 选择器警告及内核预加载提示。
- 浏览器隔离预览检查了 320px 长昵称、375px 新用户、390px 会员与失败重试、430px 过期会员。已点击验证身体评估与课程预约跳转,以及会员信息重试恢复。
- 最后调整已同时确认游客、已登录无卡用户均不展示案例;案例区域也不会先出现再隐藏。
- 截图:`docs/screenshots/home-welcome-preview.png` 为最终双状态预览;`docs/screenshots/home-wechat-2026-10-25.png` 为微信实际运行截图。

View File

@@ -0,0 +1,42 @@
# 馆主介绍服务端发布记录
2026-10-25 完成馆主介绍的生产数据库迁移与服务器发布。
## 发布内容
- 对应代码:`4ca32c2`(`feat: 支持馆主个人信息编辑`)。
- 发布包 SHA-256:`39e3cc79bf1adfd2ff423f3ee1147420ba9fcbde4be514250e6742c28bc34369`,上传前后校验一致。
- 本地重新生成 Prisma Client,shared 和 server 构建通过;实现阶段的 36 个测试套件、584 项测试全部通过。
- 沿用 Node 22.16.0、Prisma 5.22.0、现有生产依赖、环境配置和 PM2 `mp-pilates-server`。
- 已更新 server/shared 构建产物、公开静态资源、Prisma schema 和迁移文件;运行文件与暂存发布包校验一致。
## 数据库迁移
- 15 个历史迁移的校验值全部与生产迁移记录匹配。
- 迁移 SQL 已在生产连接的临时表上预演通过。
- 成功应用 `20261025090000_owner_introduction`,新增 `studio_config.owner_introduction`(TEXT,可空)和 `owner_certifications`(JSON,可空)。
- `prisma migrate status` 确认全部 16 个迁移完成、数据库为最新状态,无未解决的失败迁移。
- 1 条原有场馆记录的全部原始字段摘要与发布前一致;新增字段为 NULL,由服务端返回原有介绍与 5 项认证。
- 环境配置文件摘要与发布前一致。
## 线上验证
- 内网及公网 HTTPS `/api/health`、`/api/studio/info` 均返回 200,介绍与认证初始内容正确。
- 匿名更新返回 401,会员更新返回 403。
- 管理员的介绍超长、NULL 介绍、认证类型错误、NULL 认证、认证条目超长、条目数量超限共 6 项校验均返回 400。
- 真实 MySQL 事务验证中文、多段介绍及认证顺序的存取,随后主动回滚;确认原始数据未变、新字段仍为 NULL,没有留下测试内容。
- 发布后进程持续在线、异常重启次数为 0;最终检查时进程已运行约 103 秒,最近 10 分钟 HTTP 5xx 数为 0。
## 备份与回退
资料仅保存在服务器私有目录 `/usr/local/web/releases/owner-20261025/`:
- `database-before.sql.gz`:发布前完整数据库逻辑备份,gzip 完整性检查通过。
- `previous-build.tgz`、`prisma-client-before/`:旧 server/shared 构建、公开资源、schema、迁移及原生 Prisma Client。
- `release.tgz`、`stage/`:本次发布包与暂存文件。
- `baseline.json`、`verification.json`:数据摘要及发布后验证结果,不保存访问令牌。
- `migration.log`、`generate.log`、`stop.log`、`restart.log`:迁移与服务切换记录。
如需应用回退,应停止进程并恢复旧运行文件与 Prisma Client,再重启。数据库新增字段应保留,避免删除上线后编辑的馆主内容;数据库整库备份仅用于灾难恢复,不应覆盖上线后的新数据。
本次未上传或发布微信小程序。首页“馆主介绍”和后台编辑界面需在新版小程序发布后使用。

Binary file not shown.

After

Width:  |  Height:  |  Size: 448 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

View File

@@ -5,6 +5,8 @@
"scripts": {
"dev:mp-weixin": "uni -p mp-weixin",
"build:mp-weixin": "uni build -p mp-weixin",
"dev:home-preview": "uni -p h5 --config preview/home.config.ts --host 127.0.0.1 --port 5189",
"test:home": "node --test preview/home.spec.mjs",
"type-check": "vue-tsc --noEmit"
},
"dependencies": {

View File

@@ -0,0 +1,55 @@
"""Render the homepage's simple native tab symbols from editable vector geometry.
Run with Python + Pillow. No external images or fonts are used.
"""
from pathlib import Path
from PIL import Image, ImageDraw
DEST = Path(__file__).resolve().parents[1] / 'src/static/tab'
SIZE, SCALE = 81, 24
FACTOR = SIZE * 4 / SCALE
def render(name, active):
image = Image.new('RGBA', (SIZE * 4, SIZE * 4))
draw = ImageDraw.Draw(image)
color = '#6b8276' if active else '#99948e'
weight = round(1.35 * FACTOR)
def box(coords): return tuple(round(value * FACTOR) for value in coords)
def line(points, width=weight):
points = [box(point) for point in points]
draw.line(points, fill=color, width=width, joint='curve')
r = width / 2
for x, y in (points[0], points[-1]):
draw.ellipse((x-r, y-r, x+r, y+r), fill=color)
if name == 'home':
roof = [(3.8,10), (12,3.2), (20.2,10)]
if active:
draw.polygon([box(p) for p in roof + [(20.2,20.4),(14.6,20.4),(14.6,13.2),(9.4,13.2),(9.4,20.4),(3.8,20.4)]], fill=color)
else:
line(roof)
line([(5.2,9.5),(5.2,20.4),(9.4,20.4),(9.4,13.2),(14.6,13.2),(14.6,20.4),(18.8,20.4),(18.8,9.5)])
elif name == 'booking':
draw.rounded_rectangle(box((4.2,5.7,19.8,21)), radius=round(3*FACTOR), outline=color, width=weight)
line([(4.5,10.5),(19.5,10.5)])
line([(8,3.2),(8,7.8)])
line([(16,3.2),(16,7.8)])
if active:
line([(8.5,15.8),(11,18),(15.8,13.8)])
elif name == 'learn':
draw.ellipse(box((3,3,21,21)), outline=color, width=weight)
points = [(15.8,8.2),(13.7,13.7),(8.2,15.8),(10.3,10.3),(15.8,8.2)]
if active:
draw.polygon([box(p) for p in points], fill=color)
else:
line(points, round(1.1*FACTOR))
elif name == 'profile':
draw.ellipse(box((8,2.8,16,10.8)), outline=color, width=weight)
draw.rounded_rectangle(box((4.2,14,19.8,21.5)), radius=round(3.6*FACTOR), outline=color, width=weight)
if active:
draw.ellipse(box((9.2,4,14.8,9.6)), fill=color)
image = image.resize((SIZE,SIZE), Image.Resampling.LANCZOS)
image.save(DEST / f"{name}{'-active' if active else ''}.png", optimize=True)
for name in ['home','booking','learn','profile']:
for active in [False,True]:
render(name,active)
print('Rendered 8 tab icons, 81×81px, sage active / warm-gray inactive.')

View File

@@ -0,0 +1,79 @@
const cover = '/static/home/studio-morning.jpg'
const membership = {
id: 'preview-membership', userId: 'preview-user', cardTypeId: 'preview-card',
remainingTimes: 8, totalTimes: 10, status: 'ACTIVE',
startDate: '2026-10-01T00:00:00.000Z', expireDate: '2027-10-01T00:00:00.000Z',
cardType: { id: 'preview-card', name: '私教课', type: 'TIMES', totalTimes: 10, price: 388000, validityDays: 365 },
}
const booking = {
id: 'preview-booking', membership, status: 'CONFIRMED',
timeSlot: { id: 'preview-slot', date: '2026-10-26T00:00:00.000Z', startTime: '10:00:00', endTime: '11:00:00' },
}
const story = {
id: 'preview-story', displayName: '学员 A', title: '找到更舒展的自己', lessonCount: 12,
achievement: '', background: '',
frames: [{ kind: 'pair', beforeId: 'preview-before', afterId: 'preview-after', beforeUrl: cover, afterUrl: cover, caption: '' }],
}
export function createHomeFixture(scenario: string, path: string, count: number): { status?: number; data: unknown } {
if (scenario === 'error' && ((path === '/membership/my' && count <= 2) || (path === '/booking/my/upcoming' && count === 1))) return { status: 503, data: null }
const isNew = scenario === 'new'
if (path === '/studio/info') return { data: { name: scenario === 'long' ? 'Focus Core 普拉提私教工作室' : 'Focus Core', logo: '', bannerUrl: null, ownerProfile: null } }
if (path === '/user/profile') return { data: { id: 'preview-user', nickname: scenario === 'long' ? '这是一个特别特别长的学员昵称' : '晓晓', role: 'MEMBER', activeMembershipCount: isNew ? 0 : 1, inviteShareEligible: false, subscriptionMessageTemplates: {}, createdAt: '2026-01-01T00:00:00.000Z' } }
if (path === '/membership/my') return { data: isNew ? [] : scenario === 'expired' ? [{ ...membership, status: 'EXPIRED', expireDate: '2026-10-01T00:00:00.000Z' }] : [membership] }
if (path === '/booking/my/upcoming') return { data: ['new', 'empty', 'expired'].includes(scenario) ? [] : [booking] }
if (path === '/teaching-cases') return { data: { items: scenario === 'empty' ? [] : [story], total: 1, page: 1, limit: 3 } }
if (path.startsWith('/teaching-cases/')) return { data: story }
if (path === '/auth/renew') return { data: { token: 'home-preview-token' } }
if (path === '/invite/summary') return { data: { inviteCode: '', invitedCount: 0, rewards: [] } }
if (path.includes('review')) return { data: { total: 0, averageRating: 0, items: [], distribution: {} } }
if (path.includes('products')) return { data: { items: [], data: [], total: 0 } }
if (path.includes('card-types')) return { data: [] }
if (path.includes('/booking/preview-booking')) return { data: booking }
return { data: [] }
}
export function fixtureBootstrap(scenario: string) {
return `(() => {
const scenario = ${JSON.stringify(scenario)};
const params = new URL(location.href).searchParams;
const instance = params.get('instance') || String(Math.random());
const storage = new Map();
const api = {
getItem: key => storage.has(String(key)) ? storage.get(String(key)) : null,
setItem: (key, value) => storage.set(String(key), String(value)),
removeItem: key => storage.delete(String(key)),
clear: () => storage.clear(),
key: index => Array.from(storage.keys())[index] || null,
get length() { return storage.size; }
};
if (scenario !== 'guest') {
api.setItem('token', 'home-preview-token');
api.setItem('token_renewed_at', JSON.stringify({ type: 'number', data: 1792892460000 }));
}
Object.defineProperty(window, 'localStorage', { value: api });
const RealDate = Date;
const previewTime = new RealDate('2026-10-25T01:41:00.000Z').getTime();
window.Date = class extends RealDate {
constructor(...args) { super(...(args.length ? args : [previewTime])); }
static now() { return previewTime; }
};
const originalOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function(method, rawUrl, ...args) {
const url = new URL(rawUrl, location.href);
if (url.hostname === 'focus.richarjiang.com' && url.pathname.startsWith('/api')) {
rawUrl = '/__home-fixture-api?' + new URLSearchParams({ path: url.pathname.slice(4), scenario, instance });
}
return originalOpen.call(this, method, rawUrl, ...args);
};
})();`
}
export const previewPage = `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>首页设计验收</title>
<style>
*{box-sizing:border-box}body{margin:0;background:#f0eeea;color:#403c36;font-family:-apple-system,BlinkMacSystemFont,'PingFang SC',sans-serif}header{padding:26px 30px 20px;position:sticky;top:0;z-index:50;background:#f0eeea}h1{font-size:21px;margin:0 0 10px;font-weight:500}p{font-size:13px;color:#8b817b;margin:0 0 16px}.controls{display:flex;gap:18px;align-items:center;font-size:13px}select,button{border:1px solid #d7d2ca;background:#fbf9f6;color:#403c36;border-radius:8px;padding:8px 12px;font:inherit}main{display:flex;gap:32px;padding:0 30px 30px;align-items:flex-start;flex-wrap:wrap}.screen{background:#fbf9f6;border-radius:18px;overflow:hidden;box-shadow:0 4px 24px #403c3608;position:relative}.caption{font-size:14px;margin:0 0 12px;color:#756a60}.status{height:44px;display:flex;align-items:center;justify-content:space-between;padding:0 24px;font-size:15px;font-weight:600;background:#fbf9f6}.status span:last-child{font-size:12px;letter-spacing:2px}iframe{display:block;width:100%;height:800px;border:0}.capsule{position:absolute;top:55px;right:16px;border:1px solid #ded9d1;border-radius:20px;height:32px;width:94px;display:flex;align-items:center;justify-content:center;gap:14px;color:#292a27;pointer-events:none}.capsule b{font-size:16px}.capsule i{height:16px;border-left:1px solid #ded9d1}.capsule em{font-size:21px;font-style:normal;line-height:1}.home-indicator{position:absolute;bottom:6px;left:calc(50% - 65px);width:130px;height:5px;border-radius:8px;background:#171817;pointer-events:none}
</style></head><body><header><h1>首页顶部 · 轻盈陪伴</h1><p>实际 Vue 页面与真实交互 · 以下均为隔离的模拟数据,案例图片仅用于检查布局</p><div class="controls"><label>右侧状态 <select id="scenario"><option value="member">老学员</option><option value="new">已登录新用户</option><option value="expired">会员卡已过期</option><option value="empty">暂无预约和案例</option><option value="error">首次请求失败</option><option value="loading">慢网络</option><option value="long">长昵称和馆名</option></select></label><label>屏幕宽度 <select id="width"><option>390</option><option>320</option><option>375</option><option>430</option></select></label><button id="reload">重新进入</button></div></header><main><section><p class="caption">新朋友</p><div class="screen"><div class="status"><span>9:41</span><span>▮▮▮ ◉ ▰</span></div><iframe title="新用户首页" id="guest"></iframe><div class="capsule"><b>•••</b><i></i><em>⊙</em></div><div class="home-indicator"></div></div></section><section><p class="caption">老学员 / 边界状态</p><div class="screen"><div class="status"><span>9:41</span><span>▮▮▮ ◉ ▰</span></div><iframe title="老学员首页" id="member"></iframe><div class="capsule"><b>•••</b><i></i><em>⊙</em></div><div class="home-indicator"></div></div></section></main><script>
const scenario=document.getElementById('scenario'),width=document.getElementById('width');
function load(){document.querySelectorAll('.screen').forEach(screen=>screen.style.width=width.value+'px');document.getElementById('guest').src='/?homeFixture=guest&instance='+Math.random();document.getElementById('member').src='/?homeFixture='+scenario.value+'&instance='+Math.random()}
scenario.onchange=load;width.onchange=load;document.getElementById('reload').onclick=load;load();
</script></body></html>`

View File

@@ -0,0 +1,38 @@
import { defineConfig, mergeConfig, type Plugin } from 'vite'
import baseConfig from '../vite.config'
import { createHomeFixture, fixtureBootstrap, previewPage } from './home-fixtures'
// This config is opt-in and is never loaded by the WeChat production build.
const fixtures: Plugin = {
name: 'home-design-preview',
transformIndexHtml(html, context) {
const scenario = new URL(context.originalUrl || '/', 'http://localhost').searchParams.get('homeFixture')
if (!scenario) return html
return { html, tags: [{ tag: 'script', children: fixtureBootstrap(scenario), injectTo: 'head-prepend' }] }
},
configureServer(server) {
const requests = new Map<string, number>()
server.middlewares.use((req, res, next) => {
const url = new URL(req.url || '/', 'http://localhost')
if (url.pathname === '/__home-preview') {
res.setHeader('Content-Type', 'text/html; charset=utf-8')
res.end(previewPage)
return
}
if (url.pathname !== '/__home-fixture-api') return next()
const scenario = url.searchParams.get('scenario') || 'guest'
const path = url.searchParams.get('path') || ''
const key = `${url.searchParams.get('instance')}:${path}`
const count = (requests.get(key) || 0) + 1
requests.set(key, count)
const result = createHomeFixture(scenario, path, count)
const respond = () => {
res.statusCode = result.status || 200
res.setHeader('Content-Type', 'application/json; charset=utf-8')
res.end(JSON.stringify({ success: res.statusCode < 400, data: result.data, message: '预览中的模拟网络错误' }))
}
setTimeout(respond, scenario === 'loading' ? 5000 : 120)
})
},
}
export default mergeConfig(baseConfig, defineConfig({ plugins: [fixtures] }))

View File

@@ -0,0 +1,158 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { transform } from 'esbuild'
const require = createRequire(import.meta.url)
const source = await readFile(new URL('../src/utils/home.ts', import.meta.url), 'utf8')
const { code } = await transform(source, { loader: 'ts', format: 'esm' })
const { getHomeMoment, getHomeGreeting, getHomeBookingDate, getHomeMembershipDays, getHomeUpcomingBookings } = await import(`data:text/javascript;base64,${Buffer.from(code).toString('base64')}`)
test('问候按中国时间在 5、9、12、18 点切换', () => {
for (const [time, expected] of [
['2026-10-24T20:59:59Z', '晚上好'], ['2026-10-24T21:00:00Z', '早上好'],
['2026-10-25T00:59:59Z', '早上好'], ['2026-10-25T01:00:00Z', '上午好'],
['2026-10-25T03:59:59Z', '上午好'], ['2026-10-25T04:00:00Z', '下午好'],
['2026-10-25T09:59:59Z', '下午好'], ['2026-10-25T10:00:00Z', '晚上好'],
]) assert.equal(getHomeMoment(new Date(time)).period, expected, time)
})
test('中国时间跨日更新日期和提醒;同一天刷新不随机跳动', () => {
const today = getHomeMoment(new Date('2026-10-25T01:41:00Z'))
assert.equal(today.dateLabel, '10月25日 · 星期日')
assert.equal(today.reminder, '练习前,给自己一次深呼吸。')
assert.equal(getHomeMoment(new Date('2026-10-25T15:59:59Z')).reminder, today.reminder)
const tomorrow = getHomeMoment(new Date('2026-10-25T16:00:00Z'))
assert.equal(tomorrow.dateLabel, '10月26日 · 星期一')
assert.notEqual(tomorrow.reminder, today.reminder)
assert.ok(getHomeMoment(new Date('2020-01-01T00:00:00Z')).reminder)
})
test('访客、加载状态、真实昵称和微信默认昵称分别显示合适问候', () => {
assert.equal(getHomeGreeting('上午好', 'guest', '晓晓'), '上午好,欢迎你')
assert.equal(getHomeGreeting('上午好', 'loading', '晓晓'), '上午好,欢迎回来')
assert.equal(getHomeGreeting('上午好', 'member', ' 晓晓 '), '上午好,晓晓')
for (const name of [null, '', ' ', '微信用户', '用户12345', '新用户']) {
assert.equal(getHomeGreeting('上午好', 'member', name), '上午好,欢迎回来')
}
})
test('预约自然日不受设备时区影响,非法日期安全降级', () => {
assert.deepEqual(getHomeBookingDate('2026-10-26T00:00:00.000Z'), { day: '26', caption: '10月 周一' })
assert.deepEqual(getHomeBookingDate('2026-10-26'), { day: '26', caption: '10月 周一' })
assert.deepEqual(getHomeBookingDate('invalid'), { day: '—', caption: '' })
})
test('会员剩余天数不会显示负值或 NaN', () => {
const now = new Date('2026-10-25T01:41:00Z')
assert.equal(getHomeMembershipDays('2026-10-26T01:41:00Z', now), 1)
assert.equal(getHomeMembershipDays('2026-10-25T02:00:00Z', now), 1)
assert.equal(getHomeMembershipDays('2026-10-25T00:00:00Z', now), 0)
assert.equal(getHomeMembershipDays('invalid', now), 0)
})
test('即将上课排除已开始和取消课程,按中国时间排序并最多显示两节', () => {
const make = (id, date, startTime, status = 'CONFIRMED') => ({ id, status, timeSlot: { date, startTime } })
const bookings = [
make('later', '2026-10-27T00:00:00.000Z', '10:00:00'),
make('past', '2026-10-25T00:00:00.000Z', '09:00:00'),
make('cancelled', '2026-10-26T00:00:00.000Z', '08:00:00', 'CANCELLED'),
make('next', '2026-10-26T00:00:00.000Z', '10:00:00', 'PENDING_CONFIRMATION'),
make('today', '2026-10-25T00:00:00.000Z', '10:00:00'),
make('invalid', 'invalid', 'invalid'),
]
const snapshot = bookings.slice()
assert.deepEqual(getHomeUpcomingBookings(bookings, Date.parse('2026-10-25T01:41:00Z')).map(item => item.id), ['today', 'next'])
assert.deepEqual(bookings, snapshot, '展示排序不得修改 store 数组')
assert.deepEqual(getHomeUpcomingBookings([], Date.now()), [])
})
// Bundle actual Pinia stores while replacing only network and unrelated store
// dependencies. These checks catch stale responses, rather than mirroring CSS.
const { build } = require('esbuild')
const piniaPath = require.resolve('pinia')
const vuePath = require.resolve('vue')
const requestQueue = []
globalThis.__homeRequest = () => new Promise((resolve, reject) => requestQueue.push({ resolve, reject }))
const storage = new Map([['token', 'test-token']])
globalThis.uni = {
getStorageSync: key => storage.get(key) || '', setStorageSync: (key, value) => storage.set(key, value),
removeStorageSync: key => storage.delete(key), showToast() {},
}
const dependencies = {
'/utils/request': 'export const get = (...args) => globalThis.__homeRequest(...args); export const post = get; export const put = get;',
'/stores/body-portrait': 'export const useBodyPortraitStore = () => ({ keepAnonymousOnLogout() {}, claimed: false });',
'/stores/invite': 'export const useInviteStore = () => ({ refresh: async () => {}, reset() {} });',
'/utils/wechat-subscription': 'export const cacheSubscriptionMessageTemplateConfig = () => {}; export const resetSubscriptionMessageTemplateCache = () => {};',
'/utils/auth': "export const isLoggedIn = () => !!uni.getStorageSync('token'); export const wxLogin = async () => ({}); export const logout = () => uni.removeStorageSync('token');",
}
const output = await build({
stdin: { contents: "export { useUserStore } from './src/stores/user'; export { useBookingStore } from './src/stores/booking';", resolveDir: new URL('..', import.meta.url).pathname, loader: 'ts' },
bundle: true, write: false, format: 'cjs', platform: 'node',
plugins: [{ name: 'test-dependencies', setup(build) {
build.onResolve({ filter: /^(vue|pinia)$/ }, args => ({ path: args.path === 'vue' ? vuePath : piniaPath, external: true }))
build.onResolve({ filter: /.*/ }, args => {
if (!args.path.startsWith('.')) return
const path = new URL(args.path, `file://${args.resolveDir}/`).pathname.replace(/\.ts$/, '')
const key = Object.keys(dependencies).find(key => path.endsWith(key))
if (key) return { path: key, namespace: 'test-stub' }
})
build.onLoad({ filter: /.*/, namespace: 'test-stub' }, args => ({ contents: dependencies[args.path], loader: 'js' }))
} }],
})
const module = { exports: {} }
new Function('require', 'module', 'exports', output.outputFiles[0].text)(require, module, module.exports)
const { useUserStore, useBookingStore } = module.exports
const { createPinia, setActivePinia } = require('pinia')
function reset() { requestQueue.length = 0; storage.set('token', 'test-token'); setActivePinia(createPinia()) }
test('退出登录后,迟到的个人资料不能重新显示上一位学员', async () => {
reset()
const user = useUserStore()
const task = user.fetchProfile()
assert.equal(requestQueue.length, 1)
user.logout()
requestQueue.shift().resolve({ id: 'old-user', nickname: '旧学员' })
await task
assert.equal(user.user, null)
assert.equal(user.loggedIn, false)
})
test('退出后清空预约,并丢弃已在途的旧预约响应', async () => {
reset()
const bookings = useBookingStore()
const task = bookings.fetchUpcomingBookings()
bookings.resetUpcomingBookings()
requestQueue.shift().resolve([{ id: 'old-booking' }])
await task
assert.deepEqual(bookings.upcomingBookings, [])
assert.equal(bookings.upcomingLoaded, false)
assert.equal(bookings.upcomingLoading, false)
})
test('预约刷新失败保留已加载预约;重试后清除错误', async () => {
reset()
const bookings = useBookingStore()
let task = bookings.fetchUpcomingBookings()
requestQueue.shift().resolve([{ id: 'booking-a' }]); await task
task = bookings.fetchUpcomingBookings()
requestQueue.shift().reject(new Error('offline')); await task
assert.equal(bookings.upcomingBookings[0].id, 'booking-a')
assert.equal(bookings.upcomingError, true)
task = bookings.fetchUpcomingBookings()
requestQueue.shift().resolve([]); await task
assert.equal(bookings.upcomingError, false)
assert.equal(bookings.upcomingLoaded, true)
})
test('较晚返回的旧预约请求不能覆盖最新结果', async () => {
reset()
const bookings = useBookingStore()
const old = bookings.fetchUpcomingBookings(), latest = bookings.fetchUpcomingBookings()
const olderResponse = requestQueue.shift(), latestResponse = requestQueue.shift()
latestResponse.resolve([{ id: 'latest' }]); await latest
olderResponse.resolve([{ id: 'stale' }]); await old
assert.equal(bookings.upcomingBookings[0].id, 'latest')
})

View File

@@ -1,126 +1,114 @@
<template>
<view class="brand-banner">
<!-- Background image layer with blur -->
<image
class="banner-bg"
:src="bannerImage"
mode="aspectFill"
/>
<!-- Dark overlay for readability -->
<view class="banner-overlay" />
<!-- Centered content -->
<view class="banner-content">
<!-- Circular logo -->
<view class="brand-banner" :class="{ 'is-entering': animate }" :style="layoutStyle">
<view class="brand-nav">
<view class="brand-identity">
<view class="logo-circle">
<image
v-if="logoImage"
class="logo-img"
:src="logoImage"
mode="aspectFill"
/>
<view v-else class="logo-placeholder">
<text>{{ studioName.slice(0, 1) || 'F' }}</text>
<image v-if="logoImage && !logoFailed" class="logo-img" :src="logoImage" mode="aspectFill" @error="logoFailed = true" />
<text v-else class="logo-letter">{{ studioName.slice(0, 1) }}</text>
</view>
</view>
<!-- Studio name -->
<view class="brand-copy">
<text class="studio-name">{{ studioName }}</text>
<text class="studio-category">普拉提私教</text>
</view>
</view>
</view>
<view class="welcome-copy">
<text class="date-label">{{ moment.dateLabel }}</text>
<text class="greeting" :class="{ 'greeting-long': greeting.length > 12 }">{{ greeting }}</text>
<view class="welcome-note">
<text v-if="visitorState === 'guest'">从了解身体开始,<text class="note-continuation">找到自己的练习节奏。</text></text>
<text v-else-if="visitorState === 'member'">很高兴,今天又见到你。</text>
<text v-else>今天,也留一点时间给自己。</text>
</view>
</view>
<view class="studio-photo">
<image v-if="!photoFailed" class="hero-image" :class="{ 'is-loaded': photoLoaded }" :src="heroImage"
mode="aspectFill" :fade-show="false" :draggable="false" aria-label="晨光中的普拉提核心床"
@load="photoLoaded = true" @error="photoFailed = true" />
<view v-else class="photo-fallback"><text>给自己,一段专注的练习时光。</text></view>
</view>
<view class="daily-reminder">
<view class="sun-icon" aria-hidden="true">
<view class="sun-core" />
<view v-for="ray in 4" :key="ray" class="sun-ray" :class="'ray-' + ray" />
</view>
<view class="reminder-copy">
<text class="reminder-label">今日小提醒</text>
<text class="reminder-text">{{ moment.reminder }}</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref, watch } from 'vue'
import type { StudioConfig } from '@mp-pilates/shared'
import { getHomeGreeting, getHomeMoment, type HomeVisitorState } from '../utils/home'
const props = defineProps<{
const props = withDefaults(defineProps<{
studioInfo: StudioConfig | null
}>()
now: number
visitorState: HomeVisitorState
nickname?: string | null
statusBarHeight: number
navigationHeight: number
menuLeft: number
animate?: boolean
}>(), { animate: true, nickname: null })
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'
const bannerImage = computed(() => props.studioInfo?.bannerUrl || fallbackBannerImage)
const logoImage = computed(() => props.studioInfo?.logo || fallbackLogoImage)
const studioName = computed(() => props.studioInfo?.name || 'Focus Core')
// Bundled cover reproduces the approved design and is available without network.
const heroImage = '/static/home/studio-morning.jpg'
const logoFailed = ref(false)
const photoLoaded = ref(false)
const photoFailed = ref(false)
const studioName = computed(() => props.studioInfo?.name?.trim() || 'Focus Core')
const logoImage = computed(() => props.studioInfo?.logo || '')
const moment = computed(() => getHomeMoment(new Date(props.now)))
const greeting = computed(() => getHomeGreeting(moment.value.period, props.visitorState, props.nickname))
const layoutStyle = computed(() => ({
'--home-status-height': `${props.statusBarHeight}px`,
'--home-navigation-height': `${props.navigationHeight}px`,
'--home-brand-max-width': `${Math.max(100, props.menuLeft - 36)}px`,
}))
watch(logoImage, () => { logoFailed.value = false })
</script>
<style lang="scss" scoped>
.brand-banner {
position: relative;
width: 100%;
height: 580rpx;
overflow: hidden;
background: linear-gradient(160deg, #E1F4FA 0%, $primary-color 50%, $primary-dark 100%);
}
.banner-bg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
filter: blur(2px);
transform: scale(1.05);
}
.banner-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba($primary-dark, 0.25);
}
.banner-content {
position: relative;
z-index: 2;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding-top: 120rpx;
}
.logo-circle {
width: 200rpx;
height: 200rpx;
border-radius: 50%;
background: #ffffff;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
box-shadow: 0 8rpx 40rpx rgba(0, 0, 0, 0.2);
}
.logo-img {
width: 200rpx;
height: 200rpx;
border-radius: 50%;
}
.logo-placeholder {
width: 200rpx;
height: 200rpx;
border-radius: 50%;
font-size: 64rpx;
font-weight: 800;
color: #333;
letter-spacing: 4rpx;
display: flex;
align-items: center;
justify-content: center;
}
.studio-name {
margin-top: 28rpx;
font-size: 40rpx;
font-weight: 700;
color: #ffffff;
letter-spacing: 6rpx;
text-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.3);
.brand-banner { margin: 0 32rpx; padding-top: calc(var(--home-status-height) + var(--home-navigation-height) + 46rpx); border-bottom: 1rpx solid #e3ddd5; color: #403c36; }
.brand-nav { position: fixed; top: 0; left: 0; right: 0; z-index: 20; box-sizing: border-box; padding: var(--home-status-height) 40rpx 0; height: calc(var(--home-status-height) + var(--home-navigation-height)); display: flex; align-items: center; background: #fbf9f6; }
.brand-identity { display: flex; align-items: center; gap: 16rpx; max-width: var(--home-brand-max-width); min-width: 0; }
.logo-circle { display: flex; flex-shrink: 0; align-items: center; justify-content: center; width: 76rpx; height: 76rpx; border-radius: 50%; overflow: hidden; background: #292a27; }
.logo-img { width: 100%; height: 100%; }
.logo-letter { color: #fffdf8; font-family: 'Baskerville', 'Times New Roman', serif; font-size: 49rpx; line-height: 1; }
.brand-copy { min-width: 0; }
.studio-name { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: 'Baskerville', 'Songti SC', serif; font-size: 34rpx; line-height: 1.2; color: #292a27; }
.studio-category { display: block; margin-top: 4rpx; font-size: 20rpx; line-height: 1.4; color: #8b817b; letter-spacing: 1rpx; }
.welcome-copy { padding: 0 8rpx; }
.date-label { display: block; font-size: 24rpx; line-height: 1.6; color: #8b817b; }
.greeting { display: block; margin-top: 18rpx; font-size: 64rpx; font-weight: 600; letter-spacing: -2rpx; line-height: 1.35; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.greeting-long { font-size: 54rpx; letter-spacing: -1rpx; }
.welcome-note { margin-top: 18rpx; min-height: 92rpx; font-size: 32rpx; line-height: 1.55; color: #756a60; }
.note-continuation { display: block; }
.studio-photo { position: relative; width: 100%; height: 452rpx; margin-top: 20rpx; border-radius: 36rpx; overflow: hidden; background: #e9e1d5; }
.hero-image { display: block; width: 100%; height: 100%; opacity: 0; transition: opacity .35s ease; }
.hero-image.is-loaded { opacity: 1; }
.photo-fallback { display: flex; align-items: center; justify-content: center; height: 100%; padding: 0 36rpx; box-sizing: border-box; color: #756a60; font-size: 28rpx; line-height: 1.8; }
.daily-reminder { display: flex; align-items: center; gap: 24rpx; padding: 32rpx 10rpx 42rpx; }
.reminder-copy { flex: 1; min-width: 0; }
.reminder-label { display: block; color: #8b817b; font-size: 23rpx; line-height: 1.5; }
.reminder-text { display: block; margin-top: 6rpx; color: #756a60; font-size: 25rpx; line-height: 1.65; overflow-wrap: anywhere; }
.sun-icon { position: relative; flex-shrink: 0; width: 60rpx; height: 60rpx; color: #8c826f; }
.sun-core { position: absolute; top: 18rpx; left: 18rpx; width: 24rpx; height: 24rpx; box-sizing: border-box; border: 2rpx solid currentColor; border-radius: 50%; }
.sun-ray { position: absolute; top: 0; left: 29rpx; height: 60rpx; width: 2rpx; }
.sun-ray::before, .sun-ray::after { position: absolute; left: 0; width: 2rpx; height: 10rpx; border-radius: 2rpx; background: currentColor; content: ''; }
.sun-ray::before { top: 0; }.sun-ray::after { bottom: 0; }
.ray-2 { transform: rotate(45deg); }.ray-3 { transform: rotate(90deg); }.ray-4 { transform: rotate(135deg); }
.is-entering .welcome-copy { animation: welcome-in .52s cubic-bezier(.22, 1, .36, 1) both; }
.is-entering .studio-photo { animation: welcome-in .6s .08s cubic-bezier(.22, 1, .36, 1) both; }
.is-entering .daily-reminder { animation: welcome-in .5s .16s cubic-bezier(.22, 1, .36, 1) both; }
@keyframes welcome-in { from { opacity: 0; transform: translateY(16rpx); } to { opacity: 1; transform: translateY(0); } }
@media (prefers-reduced-motion: reduce) {
.is-entering .welcome-copy, .is-entering .studio-photo, .is-entering .daily-reminder { animation: none; }
.hero-image { transition: none; }
}
</style>

View File

@@ -1,7 +1,14 @@
<template>
<view class="quick-entry">
<view v-if="userStore.loggedIn && !userStore.membershipsLoaded && !userStore.membershipsError" class="entry-pill pill-loading" aria-label="正在加载会员信息">
<view class="loading-label" /><view class="loading-action" />
</view>
<view v-else-if="userStore.loggedIn && !userStore.membershipsLoaded && userStore.membershipsError" class="entry-pill pill-expired" role="button" @tap="retryMemberships">
<text class="pill-label">会员信息暂未加载</text>
<view class="pill-action"><text class="pill-action-text">{{ userStore.membershipsLoading ? '加载中' : '重新加载' }}</text></view>
</view>
<!-- ① Not logged in -->
<view v-if="!userStore.loggedIn || userStore.memberships.length === 0" class="entry-pill pill-login" @tap="handlePortrait">
<view v-else-if="!userStore.loggedIn || userStore.memberships.length === 0" class="entry-pill pill-login" role="button" hover-class="entry-pressed" @tap="handlePortrait">
<text class="pill-label">3 分钟了解自己的身体状态</text>
<view class="pill-action action-login">
<text class="pill-action-text">开始评估</text>
@@ -9,8 +16,8 @@
</view>
<!-- Has valid active card -->
<template v-else-if="userStore.hasValidMembership">
<view class="entry-pill pill-active" @tap="handleBooking">
<template v-else-if="activeMemberships.length">
<view class="entry-pill pill-active" role="button" hover-class="entry-pressed" @tap="handleBooking">
<text class="pill-label pill-label-active">{{ activeMembershipLabel }}</text>
<view class="pill-action action-book">
<text class="pill-action-text">预约课程</text>
@@ -24,10 +31,17 @@
</view>
</template>
<!-- ④ Has memberships but none active → buy card -->
<view v-else-if="hasScheduledMembership" class="entry-pill pill-expired" role="button" hover-class="entry-pressed" @tap="handleMemberships">
<text class="pill-label">会员卡尚未生效</text>
<view class="pill-action"><text class="pill-action-text">查看会员卡</text></view>
</view>
<!-- Has memberships but none available → renew -->
<view
v-else
class="entry-pill pill-expired"
role="button"
hover-class="entry-pressed"
@tap="handleRenew"
>
<text class="pill-label">暂无可用会员卡</text>
@@ -39,47 +53,49 @@
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed } from 'vue'
import { getMembershipEffectiveState, getMembershipRenewalHint, MembershipStatus } from '@mp-pilates/shared'
import { useUserStore } from '../stores/user'
import { getErrorMessage } from '../utils/auth'
import { getHomeMembershipDays } from '../utils/home'
const props = defineProps<{ now: number }>()
const emit = defineEmits<{
(e: 'scroll-to-card-shop'): void
}>()
const userStore = useUserStore()
const loading = ref(false)
const activeMemberships = computed(() => userStore.memberships.filter(membership =>
getMembershipEffectiveState(membership, new Date(props.now)).status === MembershipStatus.ACTIVE
&& (membership.remainingTimes === null || membership.remainingTimes > 0),
))
const renewalHint = computed(() => getMembershipRenewalHint(userStore.memberships, new Date(props.now)))
const hasScheduledMembership = computed(() => userStore.memberships.some(membership =>
getMembershipEffectiveState(membership, new Date(props.now)).status === MembershipStatus.SCHEDULED,
))
async function handleLogin() {
if (loading.value) return
loading.value = true
try {
await userStore.loginWithSetup()
} catch (err: unknown) {
uni.showToast({ title: getErrorMessage(err, '登录失败,请重试'), icon: 'none' })
} finally {
loading.value = false
}
function retryMemberships() {
if (!userStore.membershipsLoading) void userStore.fetchMemberships()
}
function handlePortrait() {
uni.navigateTo({ url: '/pages/portrait/index' })
}
function handleTrialEntry() {
uni.navigateTo({ url: '/pages/card/detail?trial=1' })
}
function handleBooking() {
uni.switchTab({ url: '/pages/booking/index' })
}
function handleMemberships() {
uni.navigateTo({ url: '/pages/profile/membership' })
}
function scrollToCardShop() {
emit('scroll-to-card-shop')
}
function handleRenew() {
const cardTypeId = userStore.renewalHint?.cardTypeId
const cardTypeId = renewalHint.value?.cardTypeId
if (cardTypeId) {
uni.navigateTo({ url: `/pages/card/detail?id=${cardTypeId}` })
return
@@ -88,22 +104,20 @@ function handleRenew() {
}
const activeMembershipLabel = computed(() => {
const active = userStore.activeMemberships
const active = activeMemberships.value
if (!active.length) return ''
const m = active[0]
const cardName = m.cardType.name
if (m.remainingTimes !== null) {
return `${cardName} · 剩余 ${m.remainingTimes} 次`
}
const expire = new Date(m.expireDate)
const today = new Date()
const daysLeft = Math.ceil((expire.getTime() - today.getTime()) / 86400000)
const daysLeft = getHomeMembershipDays(m.expireDate, new Date(props.now))
return `${cardName} · 剩余 ${daysLeft} 天`
})
const renewStripText = computed(() => {
const hint = userStore.renewalHint
if (!hint || !userStore.hasValidMembership) return ''
const hint = renewalHint.value
if (!hint || !activeMemberships.value.length) return ''
if (hint.kind === 'times_low') {
return `仅剩 ${hint.remainingTimes ?? 0} 次 · 续卡保持节奏`
}
@@ -119,14 +133,21 @@ const renewStripText = computed(() => {
<style lang="scss" scoped>
.quick-entry { margin: 24rpx 32rpx 0; }
.entry-pill { display: flex; align-items: center; min-height: 120rpx; box-sizing: border-box; padding: 24rpx; gap: 20rpx; border-radius: 28rpx; background: #f1e8e1; }
.entry-pill { display: flex; align-items: center; min-height: 136rpx; box-sizing: border-box; padding: 24rpx 28rpx; gap: 20rpx; border-radius: 30rpx; background: #f1ece5; transition: opacity .18s ease, transform .18s ease; }
.entry-pressed { opacity: .82; transform: scale(.99); }
.pill-active { background: #edf2ec; }
.pill-expired { background: #f0ece7; }
.pill-label { flex: 1; min-width: 0; font-size: 26rpx; line-height: 1.6; color: #6f5c50; overflow-wrap: anywhere; }
.pill-label { flex: 1; min-width: 0; font-size: 29rpx; line-height: 1.6; color: #403c36; overflow-wrap: anywhere; }
.pill-label-active { color: #526b5d; }
.pill-action { flex-shrink: 0; min-height: 68rpx; padding: 0 24rpx; border-radius: 999rpx; display: flex; align-items: center; justify-content: center; background: #6b8276; }
.pill-action-text { font-size: 24rpx; font-weight: 400; color: #fff; white-space: nowrap; }
.pill-action { flex-shrink: 0; min-height: 80rpx; padding: 0 28rpx; border-radius: 999rpx; display: flex; align-items: center; justify-content: center; background: #6b8276; }
.pill-action-text { font-size: 27rpx; font-weight: 400; color: #fff; white-space: nowrap; }
.renew-strip { display: flex; align-items: center; justify-content: space-between; gap: 16rpx; padding: 18rpx 12rpx 0; }
.renew-strip-text { font-size: 22rpx; color: #8b817b; line-height: 1.5; }
.renew-strip-arrow { font-size: 28rpx; color: #8b817b; }
.pill-loading { justify-content: space-between; background: #edf2ec; }
.loading-label { width: 55%; height: 28rpx; border-radius: 8rpx; background: #dce3d9; }
.loading-action { width: 180rpx; height: 80rpx; border-radius: 999rpx; background: #dce3d9; }
.loading-label, .loading-action { animation: loading-breathe 1.4s ease-in-out infinite alternate; }
@keyframes loading-breathe { to { opacity: .45; } }
@media (prefers-reduced-motion: reduce) { .entry-pill { transition: none; }.loading-label, .loading-action { animation: none; } }
</style>

View File

@@ -1,18 +1,19 @@
<template>
<view class="case-card">
<view class="heading" @tap="open"><view class="copy"><text class="name">{{ item.displayName }} · 练习记录</text><text class="title">{{ item.title }}</text></view><view class="lessons"><text class="number">{{ item.lessonCount }}</text><text>节练习</text></view></view>
<TeachingCaseGallery :frames="item.frames" :lesson-count="item.lessonCount" @refresh="$emit('refresh')" />
<view class="case-card" :class="{ 'is-home': home }">
<view class="heading" @tap="open"><view class="copy"><text class="name">{{ item.displayName }} · 练习记录</text><text class="title">{{ item.title }}</text></view><view class="lessons"><text class="number lesson-count">{{ item.lessonCount }}</text><text class="lesson-unit">节练习</text></view></view>
<TeachingCaseGallery :frames="item.frames" :lesson-count="item.lessonCount" :compact="home" @refresh="$emit('refresh')" />
<text class="achievement">{{ item.achievement }}</text>
<button class="link" @tap="open">看这段改变的过程 <text>↗</text></button>
<button class="link" @tap="open">看这段改变的过程 <text class="link-arrow">↗</text></button>
</view>
</template>
<script setup lang="ts">
import type { TeachingCasePublic } from '@mp-pilates/shared'
import TeachingCaseGallery from './TeachingCaseGallery.vue'
const props = defineProps<{ item: TeachingCasePublic }>()
const props = defineProps<{ item: TeachingCasePublic; home?: boolean }>()
defineEmits<{ refresh: [] }>()
function open() { uni.navigateTo({ url: `/pages/home/teaching-case?id=${props.item.id}` }) }
</script>
<style scoped>
.case-card{padding:28rpx 0;border-bottom:1rpx solid #e4ded4;}.heading{display:flex;justify-content:space-between;gap:20rpx;align-items:center;margin-bottom:24rpx;}.copy{min-width:0;}.name{display:block;font-size:22rpx;color:#8b8174;margin-bottom:12rpx;}.title{font-size:34rpx;line-height:1.5;color:#4b4940;font-family:'Songti SC','STSong',serif;}.lessons{flex-shrink:0;text-align:center;color:#64705c;font-size:20rpx;}.lessons text{display:block;}.lessons .number{font-family:'Baskerville',serif;font-size:58rpx;line-height:1.1;}.achievement{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;margin-top:22rpx;font-size:25rpx;line-height:1.8;color:#746d61;white-space:pre-wrap;}.link{padding:0;margin:10rpx 0 0;text-align:left;font-size:25rpx;line-height:80rpx;color:#596b50;background:transparent;}.link text{float:right;}.link::after{border:0;}
.case-card{padding:28rpx 0;border-bottom:1rpx solid #e4ded4;}.heading{display:flex;justify-content:space-between;gap:20rpx;align-items:center;margin-bottom:24rpx;}.copy{min-width:0;}.name{display:block;font-size:22rpx;color:#8b8174;margin-bottom:12rpx;}.title{font-size:34rpx;line-height:1.5;color:#4b4940;font-family:'Songti SC','STSong',serif;}.lessons{flex-shrink:0;text-align:center;color:#64705c;font-size:20rpx;}.lesson-count,.lesson-unit{display:block;}.lessons .number{font-family:'Baskerville',serif;font-size:58rpx;line-height:1.1;}.achievement{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;margin-top:22rpx;font-size:25rpx;line-height:1.8;color:#746d61;white-space:pre-wrap;}.link{padding:0;margin:10rpx 0 0;text-align:left;font-size:25rpx;line-height:80rpx;color:#596b50;background:transparent;}.link-arrow{float:right;}.link::after{border:0;}
.is-home{padding:26rpx 20rpx 10rpx;margin-bottom:24rpx;border:0;border-radius:30rpx;background:#fff;}.is-home .heading{padding:0 8rpx;margin-bottom:24rpx;}.is-home .name{font-size:23rpx;color:#756a60;}.is-home .title{font-family:inherit;font-size:33rpx;color:#403c36;}.is-home .lessons{color:#8b817b;font-size:20rpx;}.is-home .lessons .number{font-family:inherit;font-size:43rpx;color:#292a27;margin-bottom:8rpx;}.is-home .achievement{padding:0 8rpx;}.is-home .link{margin:0 8rpx;font-size:23rpx;line-height:70rpx;}
</style>

View File

@@ -1,22 +1,22 @@
<template>
<view class="gallery">
<view class="gallery" :class="{ compact }">
<swiper class="slides" :current="current" :autoplay="false" @change="current = $event.detail.current">
<swiper-item v-for="(frame, index) in frames" :key="index">
<view class="frame" :class="{ pair: frame.kind === 'pair' }">
<view class="photo" @tap="preview(frame.beforeUrl)">
<image v-if="frame.beforeUrl" :src="frame.beforeUrl" mode="aspectFit" lazy-load @error="failed = true" />
<image class="gallery-image" v-if="frame.beforeUrl" :src="frame.beforeUrl" mode="aspectFit" lazy-load @error="failed = true" />
<text v-if="frame.kind === 'pair'" class="badge">练习前</text>
</view>
<view v-if="frame.kind === 'pair'" class="photo" @tap="preview(frame.afterUrl)">
<image v-if="frame.afterUrl" :src="frame.afterUrl" mode="aspectFit" lazy-load @error="failed = true" />
<image class="gallery-image" v-if="frame.afterUrl" :src="frame.afterUrl" mode="aspectFit" lazy-load @error="failed = true" />
<text class="badge after">{{ lessonCount }} 节后</text>
</view>
</view>
</swiper-item>
</swiper>
<view class="gallery-footer">
<view v-if="!compact || frames.length > 1 || frames[current]?.caption" class="gallery-footer">
<text class="caption">{{ frames[current]?.caption || '每一次练习,都留下改变的痕迹' }}</text>
<view class="pager"><button v-if="frames.length > 1" aria-label="上一组" @tap="move(-1)">‹</button><text>{{ current + 1 }} / {{ frames.length }}</text><button v-if="frames.length > 1" aria-label="下一组" @tap="move(1)">›</button></view>
<view class="pager"><button class="pager-button" v-if="frames.length > 1" aria-label="上一组" @tap="move(-1)">‹</button><text>{{ current + 1 }} / {{ frames.length }}</text><button class="pager-button" v-if="frames.length > 1" aria-label="下一组" @tap="move(1)">›</button></view>
</view>
<button v-if="failed" class="retry" @tap="$emit('refresh')">图片未能加载,点击刷新</button>
</view>
@@ -24,7 +24,7 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import type { TeachingCaseFrame } from '@mp-pilates/shared'
const props = defineProps<{ frames: TeachingCaseFrame[]; lessonCount: number }>()
const props = defineProps<{ frames: TeachingCaseFrame[]; lessonCount: number; compact?: boolean }>()
defineEmits<{ refresh: [] }>()
const current = ref(0), failed = ref(false)
watch(() => props.frames, () => { current.value = 0; failed.value = false })
@@ -36,5 +36,6 @@ function preview(url?: string) {
}
</script>
<style scoped>
.gallery{overflow:hidden;background:#f0eee8;border-radius:18rpx;}.slides{height:480rpx;}.frame{height:100%;display:flex;}.photo{position:relative;flex:1;min-width:0;height:100%;}.pair .photo+.photo{border-left:4rpx solid #fbf9f6;}.photo image{width:100%;height:100%;}.badge{position:absolute;left:14rpx;bottom:16rpx;padding:8rpx 16rpx;border-radius:8rpx;background:rgba(255,253,249,.93);color:#62584d;font-size:22rpx;}.after{background:#53624f;color:#fff;}.gallery-footer{padding:12rpx 18rpx;display:flex;align-items:center;gap:12rpx;min-height:64rpx;}.caption{flex:1;font-size:22rpx;color:#817566;line-height:1.6;}.pager{display:flex;align-items:center;flex-shrink:0;color:#716353;font-size:22rpx;}.pager button{margin:0;padding:0;width:64rpx;line-height:68rpx;background:transparent;color:#53624f;font-size:36rpx;}.pager button::after,.retry::after{border:0;}.retry{background:#eee5da;color:#765c44;font-size:24rpx;line-height:80rpx;}
.gallery{overflow:hidden;background:#f0eee8;border-radius:18rpx;}.slides{height:480rpx;}.frame{height:100%;display:flex;}.photo{position:relative;flex:1;min-width:0;height:100%;}.pair .photo+.photo{border-left:4rpx solid #fbf9f6;}.gallery-image{width:100%;height:100%;}.badge{position:absolute;left:14rpx;bottom:16rpx;padding:8rpx 16rpx;border-radius:8rpx;background:rgba(255,253,249,.93);color:#62584d;font-size:22rpx;}.after{background:#53624f;color:#fff;}.gallery-footer{padding:12rpx 18rpx;display:flex;align-items:center;gap:12rpx;min-height:64rpx;}.caption{flex:1;font-size:22rpx;color:#817566;line-height:1.6;}.pager{display:flex;align-items:center;flex-shrink:0;color:#716353;font-size:22rpx;}.pager-button{margin:0;padding:0;width:64rpx;line-height:68rpx;background:transparent;color:#53624f;font-size:36rpx;}.pager-button::after,.retry::after{border:0;}.retry{background:#eee5da;color:#765c44;font-size:24rpx;line-height:80rpx;}
.compact .slides{height:440rpx;}.compact .photo+.photo{border-left:10rpx solid #fff;}.compact .gallery-footer{background:#fff;}.compact .badge{font-size:19rpx;padding:6rpx 12rpx;}
</style>

View File

@@ -1,7 +1,7 @@
<template>
<view v-if="items.length" class="section">
<view class="section-head"><view><text class="eyebrow">练习,有迹可循</text><text class="title">看见真实的改变</text></view><button @tap="openAll">全部案例 ›</button></view>
<TeachingCaseCard v-for="item in items" :key="item.id" :item="item" @refresh="$emit('refresh')" />
<view class="section-head"><view><text class="eyebrow">练习,有迹可循</text><text class="title">看见真实的改变</text></view><button class="all-cases" @tap="openAll">全部案例 ›</button></view>
<TeachingCaseCard v-for="item in items.slice(0, 1)" :key="item.id" :item="item" home @refresh="$emit('refresh')" />
<text class="footnote">经学员授权分享 · 个体经历,实际变化因人而异</text>
</view>
</template>
@@ -13,5 +13,5 @@ defineEmits<{ refresh: [] }>()
function openAll() { uni.navigateTo({ url: '/pages/home/teaching-cases' }) }
</script>
<style scoped>
.section{margin:28rpx 32rpx 40rpx;}.section-head{display:flex;justify-content:space-between;align-items:center;gap:12rpx;}.eyebrow{display:block;font-size:21rpx;letter-spacing:3rpx;color:#8a876e;margin-bottom:13rpx;}.title{font-family:'Songti SC','STSong',serif;font-size:40rpx;line-height:1.5;color:#494c40;}.section-head button{flex-shrink:0;margin:0;padding:0 10rpx;font-size:23rpx;color:#738064;line-height:88rpx;background:transparent;}.section-head button::after{border:0;}.footnote{display:block;margin-top:24rpx;font-size:21rpx;color:#948b7c;line-height:1.7;}
.section{margin:36rpx 32rpx 40rpx;}.section-head{display:flex;justify-content:space-between;align-items:center;gap:12rpx;margin-bottom:28rpx;}.eyebrow{display:block;font-size:23rpx;letter-spacing:1rpx;color:#8b817b;margin-bottom:10rpx;}.title{font-family:'Songti SC','STSong',serif;font-size:43rpx;line-height:1.5;color:#403c36;}.all-cases{flex-shrink:0;align-self:flex-end;margin:0;padding:0;font-size:24rpx;color:#8b817b;line-height:68rpx;background:transparent;}.all-cases::after{border:0;}.footnote{display:block;margin-top:24rpx;font-size:21rpx;color:#948b7c;line-height:1.7;}
</style>

View File

@@ -1,5 +1,5 @@
<template>
<view v-if="bookingStore.upcomingBookings.length" class="upcoming-section">
<view v-if="displayedBookings.length || bookingStore.upcomingError" class="upcoming-section">
<view class="section-header">
<text class="section-title">即将上课</text>
<text class="section-more" @tap="goToBookings">全部预约 ›</text>
@@ -9,13 +9,14 @@
v-for="booking in displayedBookings"
:key="booking.id"
class="booking-card"
role="button"
hover-class="booking-pressed"
@tap="goToBookingDetail(booking.id)"
>
<!-- Date column -->
<view class="date-col">
<text class="date-day">{{ getDayNumber(booking.timeSlot.date) }}</text>
<text class="date-month">{{ getMonthLabel(booking.timeSlot.date) }}</text>
<text class="date-weekday">{{ getWeekdayLabel(booking.timeSlot.date) }}</text>
<text class="date-day">{{ getHomeBookingDate(booking.timeSlot.date).day }}</text>
<text class="date-caption">{{ getHomeBookingDate(booking.timeSlot.date).caption }}</text>
</view>
<!-- Divider -->
@@ -40,31 +41,27 @@
<!-- Right arrow -->
<text class="booking-arrow">›</text>
</view>
<view v-if="bookingStore.upcomingError" class="booking-error">
<text>{{ displayedBookings.length ? '预约更新暂未完成' : '预约暂未加载,请稍后重试' }}</text>
<button class="retry-button" :disabled="bookingStore.upcomingLoading" @tap="bookingStore.fetchUpcomingBookings()">{{ bookingStore.upcomingLoading ? '加载中' : '重新加载' }}</button>
</view>
</view>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useBookingStore } from '../stores/booking'
import { getWeekdayLabel } from '../utils/format'
import { getHomeBookingDate, getHomeUpcomingBookings } from '../utils/home'
import { BookingStatus } from '@mp-pilates/shared'
const props = defineProps<{ now: number }>()
const bookingStore = useBookingStore()
// Show at most 2 upcoming bookings
const displayedBookings = computed(() =>
bookingStore.upcomingBookings.slice(0, 2),
getHomeUpcomingBookings(bookingStore.upcomingBookings, props.now),
)
function getDayNumber(dateStr: string): string {
return new Date(dateStr).getDate().toString()
}
function getMonthLabel(dateStr: string): string {
const months = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月']
return months[new Date(dateStr).getMonth()]
}
function formatTime(timeStr: string): string {
// timeStr might be "HH:mm:ss" or "HH:mm"
return timeStr.slice(0, 5)
@@ -107,17 +104,18 @@ function goToBookingDetail(id: string) {
</script>
<style lang="scss" scoped>
.upcoming-section { margin: 36rpx 32rpx 0; }
.upcoming-section { margin: 36rpx 32rpx 0; padding-bottom: 32rpx; border-bottom: 1rpx solid #e3ddd5; }
.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-title { font-size: 38rpx; font-weight: 500; color: #403c36; }
.section-more { font-size: 23rpx; color: #8b817b; padding: 8rpx 0; }
.booking-card { display: flex; align-items: center; gap: 24rpx; margin-bottom: 16rpx; padding: 26rpx; border-radius: 28rpx; background: #fff; border: 1rpx solid #eee8e3; }
.date-col { display: flex; flex-direction: column; align-items: center; gap: 4rpx; width: 72rpx; flex-shrink: 0; }
.date-day { font-size: 44rpx; font-weight: 400; line-height: 1.1; color: #617d73; }
.date-month, .date-weekday { font-size: 20rpx; color: #8b817b; }
.booking-divider { width: 1rpx; height: 80rpx; background: #eee8e3; flex-shrink: 0; }
.booking-card { display: flex; align-items: center; gap: 24rpx; margin-bottom: 16rpx; padding: 30rpx 26rpx; border-radius: 30rpx; background: #fff; transition: opacity .18s ease, transform .18s ease; }
.booking-pressed { opacity: .8; transform: scale(.99); }
.date-col { display: flex; flex-direction: column; align-items: center; gap: 10rpx; width: 148rpx; flex-shrink: 0; }
.date-day { font-size: 68rpx; font-weight: 400; line-height: 1.1; color: #292a27; font-variant-numeric: tabular-nums; }
.date-caption { font-size: 24rpx; color: #8b817b; white-space: nowrap; }
.booking-divider { width: 1rpx; height: 124rpx; 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; }
.card-name { display: block; font-size: 29rpx; color: #403c36; font-weight: 500; line-height: 1.5; margin-bottom: 8rpx; overflow-wrap: anywhere; }
.time-row { margin-bottom: 8rpx; }
.time-text { font-size: 24rpx; color: #6f655e; font-variant-numeric: tabular-nums; }
.status-row { display: flex; align-items: center; gap: 8rpx; }
@@ -128,4 +126,7 @@ function goToBookingDetail(id: string) {
.dot--cancelled { background: #b69589; }
.text--cancelled { color: #9c7a6e; }
.booking-arrow { font-size: 32rpx; color: #b2a79e; flex-shrink: 0; }
.booking-error { display: flex; align-items: center; justify-content: space-between; gap: 16rpx; padding: 20rpx 0; font-size: 23rpx; line-height: 1.6; color: #8b817b; }
.retry-button { margin: 0; padding: 8rpx 12rpx; flex-shrink: 0; background: transparent; font-size: 23rpx; line-height: 1.6; color: #617d73; }.retry-button::after { border: 0; }
@media (prefers-reduced-motion: reduce) { .booking-card { transition: none; } }
</style>

View File

@@ -348,7 +348,7 @@
},
"tabBar": {
"color": "#999999",
"selectedColor": "#1a1a2e",
"selectedColor": "#6b8276",
"backgroundColor": "#ffffff",
"borderStyle": "white",
"list": [

View File

@@ -1,18 +1,15 @@
<template>
<view class="home-page" :style="{ height: pageHeight }">
<view class="banner-fixed">
<BrandBanner :studio-info="studioStore.studioInfo" />
</view>
<scroll-view class="page-scroll" scroll-y :scroll-into-view="scrollTarget"
scroll-with-animation refresher-enabled :refresher-triggered="refreshing"
@refresherrefresh="handleRefresh" @refresherrestore="refreshing = false">
<view class="banner-spacer" />
<view class="floating-card">
<view class="card-handle"><view class="card-handle-bar" /></view>
<TeachingCaseSection v-if="!userStore.loggedIn" :items="teachingCases" @refresh="fetchTeachingCases" />
<QuickEntry @scroll-to-card-shop="scrollToCardShop" />
<UpcomingBooking />
<TeachingCaseSection v-if="userStore.loggedIn" :items="teachingCases" @refresh="fetchTeachingCases" />
<view class="home-page" :class="{ 'is-entering': entranceActive }" :style="{ height: pageHeight }">
<scroll-view class="page-scroll" scroll-y :show-scrollbar="false" :scroll-into-view="scrollTarget"
scroll-with-animation refresher-enabled refresher-background="#fbf9f6" :refresher-triggered="refreshing"
@refresherrefresh="handleRefresh">
<BrandBanner :studio-info="studioStore.studioInfo" :now="now" :visitor-state="visitorState"
:nickname="userStore.user?.nickname" :status-bar-height="navigation.statusBarHeight"
:navigation-height="navigation.navigationHeight" :menu-left="navigation.menuLeft" :animate="entranceActive" />
<view class="home-content">
<QuickEntry :now="now" @scroll-to-card-shop="scrollToCardShop" />
<UpcomingBooking v-if="userStore.loggedIn" :now="now" />
<TeachingCaseSection v-if="visitorState === 'member'" :items="teachingCases" @refresh="fetchTeachingCases" />
<ReviewSummaryCard />
<StudioInfo :studio-info="studioStore.studioInfo" />
<view :id="cardShopAnchorId">
@@ -27,11 +24,13 @@
</template>
<script setup lang="ts">
import { ref, nextTick, onUnmounted } from 'vue'
import { onShow, onResize, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import { computed, ref, nextTick, onUnmounted, watch } from 'vue'
import { onShow, onHide, onReady, onResize, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import type { TeachingCasePage, TeachingCasePublic } from '@mp-pilates/shared'
import { get } from '../../utils/request'
import { getHomeNavigationLayout } from '../../utils/system'
import type { HomeVisitorState } from '../../utils/home'
import TeachingCaseSection from '../../components/TeachingCaseSection.vue'
import BrandBanner from '../../components/BrandBanner.vue'
import ReviewSummaryCard from '../../components/ReviewSummaryCard.vue'
@@ -49,14 +48,21 @@ import { useBookingStore } from '../../stores/booking'
const userStore = useUserStore()
const studioStore = useStudioStore()
const bookingStore = useBookingStore()
const now = ref(Date.now())
const navigation = ref(getHomeNavigationLayout())
const visitorState = computed<HomeVisitorState>(() => {
if (!userStore.loggedIn) return 'guest'
if (userStore.memberships.length || (userStore.user?.activeMembershipCount ?? 0) > 0) return 'member'
return userStore.membershipsLoaded ? 'guest' : 'loading'
})
const teachingCases = ref<TeachingCasePublic[]>([])
let caseRequest = 0
async function fetchTeachingCases() {
const request = ++caseRequest
try {
const result = await get<TeachingCasePage>('/teaching-cases', { page: 1, limit: 3 })
const result = await get<TeachingCasePage>('/teaching-cases', { page: 1, limit: 1 })
if (request === caseRequest) teachingCases.value = result.items
} catch { if (request === caseRequest) teachingCases.value = [] }
} catch { /* A refresh failure must not erase already loaded public stories. */ }
}
// ─── 微信分享 ───────────────────────────────────────────────
@@ -82,8 +88,33 @@ const productShopRef = ref<InstanceType<typeof ProductShop> | null>(null)
const cardShopAnchorId = 'card-shop-anchor'
const scrollTarget = ref('')
const pageHeight = ref(`${uni.getWindowInfo().windowHeight}px`)
onResize(() => { pageHeight.value = `${uni.getWindowInfo().windowHeight}px` })
onResize(() => {
pageHeight.value = `${uni.getWindowInfo().windowHeight}px`
navigation.value = getHomeNavigationLayout()
})
const pendingScrollToCardShop = ref(false)
const entranceActive = ref(true)
let entranceTimer: ReturnType<typeof setTimeout> | undefined
let clockTimer: ReturnType<typeof setTimeout> | undefined
let refreshTask: Promise<void> | null = null
let refreshIdentity = ''
let visible = false
// One entrance per mounted page; revisiting a tab and refreshing do not replay it.
onReady(() => {
entranceTimer = setTimeout(() => { entranceActive.value = false }, 850)
})
function stopClock() {
clearTimeout(clockTimer)
clockTimer = undefined
}
function updateClock() {
stopClock()
now.value = Date.now()
clockTimer = setTimeout(updateClock, 60_000 - Date.now() % 60_000)
}
// Listen for cross-page scroll request (e.g. from booking page "去购买")
uni.$on('scrollToCardShop', () => {
@@ -92,10 +123,21 @@ uni.$on('scrollToCardShop', () => {
onUnmounted(() => {
uni.$off('scrollToCardShop')
stopClock()
clearTimeout(entranceTimer)
caseRequest++
})
onHide(() => { visible = false; stopClock() })
watch(() => userStore.loggedIn, (loggedIn) => {
if (loggedIn && visible) void refreshData()
})
// Refresh all data on every show
onShow(async () => {
visible = true
updateClock()
if (uni.getStorageSync('home:card-shop-requested')) {
uni.removeStorageSync('home:card-shop-requested')
pendingScrollToCardShop.value = true
@@ -110,8 +152,18 @@ onShow(async () => {
}
})
async function refreshData() {
const tasks: Promise<unknown>[] = [studioStore.fetchStudioInfo(), fetchTeachingCases()]
function refreshData(): Promise<void> {
if (refreshTask && refreshIdentity === userStore.token) return refreshTask
refreshIdentity = userStore.token
const task = performRefresh().finally(() => {
if (refreshTask === task) refreshTask = null
})
refreshTask = task
return task
}
async function performRefresh() {
const tasks: Promise<unknown>[] = [studioStore.fetchStudioInfo()]
if (userStore.loggedIn) {
tasks.push(
@@ -125,13 +177,16 @@ async function refreshData() {
// Also refresh card shop
await Promise.allSettled([
...(visitorState.value === 'member' ? [fetchTeachingCases()] : []),
cardShopRef.value?.fetchCardTypes(),
productShopRef.value?.load(),
])
}
async function handleRefresh() {
if (refreshing.value) return
refreshing.value = true
now.value = Date.now()
try {
await refreshData()
} finally {
@@ -147,22 +202,11 @@ async function scrollToCardShop() {
</script>
<style lang="scss" scoped>
.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;
padding-top: 12rpx;
border-radius: 32rpx 32rpx 0 0;
border-top: 1rpx solid rgba(255, 255, 255, 0.7);
background: linear-gradient(180deg, rgba(251, 249, 246, 0.86), #fbf9f6 260rpx);
-webkit-backdrop-filter: blur(16px);
backdrop-filter: blur(16px);
box-shadow: 0 -4rpx 20rpx rgba(66, 54, 45, 0.06);
}
.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); }
.home-page { position: relative; height: 100vh; background: #fbf9f6; color: #403c36; font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', sans-serif; }
.page-scroll { position: relative; height: 100%; }
.home-content { padding-top: 18rpx; }
.is-entering .home-content { animation: content-in .5s .22s cubic-bezier(.22, 1, .36, 1) both; }
.bottom-padding { height: 48rpx; }
@keyframes content-in { from { opacity: 0; transform: translateY(12rpx); } to { opacity: 1; transform: translateY(0); } }
@media (prefers-reduced-motion: reduce) { .is-entering .home-content { animation: none; } }
</style>

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 378 B

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 371 B

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 387 B

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 382 B

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 460 B

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 455 B

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@@ -27,6 +27,18 @@ export const useBookingStore = defineStore('booking', () => {
const loadingSlots = ref(false)
const loadingBookings = ref(false)
const loadingTeachingSchedule = ref(false)
const upcomingLoading = ref(false)
const upcomingLoaded = ref(false)
const upcomingError = ref(false)
let upcomingRequestId = 0
function resetUpcomingBookings() {
upcomingRequestId++
upcomingBookings.value = []
upcomingLoading.value = false
upcomingLoaded.value = false
upcomingError.value = false
}
async function fetchSlots(date: string) {
loadingSlots.value = true
@@ -84,12 +96,18 @@ export const useBookingStore = defineStore('booking', () => {
}
async function fetchUpcomingBookings() {
const id = ++upcomingRequestId
upcomingLoading.value = true
upcomingError.value = false
try {
const result = await get<BookingWithDetails[]>('/booking/my/upcoming')
if (id !== upcomingRequestId) return
upcomingBookings.value = Array.isArray(result) ? result : []
} catch (err) {
console.error('Fetch upcoming bookings failed:', err)
upcomingBookings.value = []
upcomingLoaded.value = true
} catch {
if (id === upcomingRequestId) upcomingError.value = true
} finally {
if (id === upcomingRequestId) upcomingLoading.value = false
}
}
@@ -165,6 +183,10 @@ export const useBookingStore = defineStore('booking', () => {
slots,
myBookings,
upcomingBookings,
upcomingLoading,
upcomingLoaded,
upcomingError,
resetUpcomingBookings,
teachingSchedule,
loadingSlots,
loadingBookings,

View File

@@ -1,5 +1,6 @@
import { useInviteStore } from './invite'
import { useBodyPortraitStore } from './body-portrait'
import { useBookingStore } from './booking'
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type {
@@ -34,6 +35,7 @@ export const useUserStore = defineStore('user', () => {
const statsLoading = ref(false)
const statsError = ref(false)
let membershipRequestId = 0
let profileRequestId = 0
let statsRequestId = 0
let sessionId = 0
const token = ref<string>(uni.getStorageSync('token') as string || '')
@@ -83,9 +85,14 @@ export const useUserStore = defineStore('user', () => {
async function fetchProfile() {
if (!isLoggedIn()) return
const id = ++profileRequestId
const session = sessionId
try {
user.value = await get<UserProfileResponse>('/user/profile')
const profile = await get<UserProfileResponse>('/user/profile')
if (id !== profileRequestId || session !== sessionId) return
user.value = profile
await useInviteStore().refresh().catch(() => {})
if (id !== profileRequestId || session !== sessionId) return
syncSubscriptionTemplates(user.value)
return user.value
} catch (err) {
@@ -154,6 +161,7 @@ export const useUserStore = defineStore('user', () => {
function clearSession() {
sessionId++
profileRequestId++
membershipRequestId++
statsRequestId++
membershipsLoading.value = false
@@ -166,6 +174,7 @@ export const useUserStore = defineStore('user', () => {
user.value = null
stats.value = null
memberships.value = []
useBookingStore().resetUpcomingBookings()
resetSubscriptionMessageTemplateCache()
useBodyPortraitStore().keepAnonymousOnLogout()
}

View File

@@ -0,0 +1,63 @@
/** Homepage copy follows the studio's time zone, including when a member travels. */
const CHINA_OFFSET_MS = 8 * 60 * 60 * 1000
const DAY_MS = 24 * 60 * 60 * 1000
const REMINDER_EPOCH = Date.UTC(2026, 9, 25)
const reminders = [
'练习前,给自己一次深呼吸。',
'放松肩膀,找到舒服的呼吸节奏。',
'今天的练习,按自己的节奏来。',
'练习时,也记得听听身体的感受。',
'不必着急,每一次练习都算数。',
'给自己一点专注,也留一点放松。',
'忙碌之余,留一点时间照顾自己。',
] as const
export type HomeVisitorState = 'guest' | 'loading' | 'member'
export function getHomeMoment(now: Date) {
const china = new Date(now.getTime() + CHINA_OFFSET_MS)
const hour = china.getUTCHours()
const period = hour >= 5 && hour < 9 ? '早上好'
: hour >= 9 && hour < 12 ? '上午好'
: hour >= 12 && hour < 18 ? '下午好' : '晚上好'
const dateLabel = `${china.getUTCMonth() + 1}月${china.getUTCDate()}日 · 星期${'日一二三四五六'[china.getUTCDay()]}`
const day = Math.floor((Date.UTC(china.getUTCFullYear(), china.getUTCMonth(), china.getUTCDate()) - REMINDER_EPOCH) / DAY_MS)
const reminder = reminders[((day % reminders.length) + reminders.length) % reminders.length]
return { period, dateLabel, reminder }
}
export function getHomeGreeting(period: string, state: HomeVisitorState, nickname?: string | null) {
if (state === 'guest') return `${period},欢迎你`
const name = nickname?.trim()
const personalName = name && !/^(微信用户|新用户|用户\d*|微信昵称)$/.test(name)
return `${period},${state === 'member' && personalName ? name : '欢迎回来'}`
}
/** Date columns are UTC-midnight ISO strings; never format them in device time. */
export function getHomeBookingDate(value: string) {
const date = new Date(value)
if (!Number.isFinite(date.getTime())) return { day: '—', caption: '' }
return {
day: String(date.getUTCDate()),
caption: `${date.getUTCMonth() + 1}月 周${'日一二三四五六'[date.getUTCDay()]}`,
}
}
export function getHomeMembershipDays(expireDate: string, now: Date) {
const expiry = new Date(expireDate).getTime()
if (!Number.isFinite(expiry)) return 0
return Math.max(0, Math.ceil((expiry - now.getTime()) / DAY_MS))
}
interface HomeBooking {
readonly status: string
readonly timeSlot: { readonly date: string; readonly startTime: string }
}
/** The API includes today's earlier slots; homepage only shows future classes. */
export function getHomeUpcomingBookings<T extends HomeBooking>(bookings: readonly T[], now: number): T[] {
return bookings.filter(booking => {
const start = new Date(`${booking.timeSlot.date.slice(0, 10)}T${booking.timeSlot.startTime.slice(0, 5)}:00+08:00`).getTime()
return (booking.status === 'CONFIRMED' || booking.status === 'PENDING_CONFIRMATION') && start > now
}).sort((a, b) => `${a.timeSlot.date.slice(0, 10)}${a.timeSlot.startTime}`.localeCompare(`${b.timeSlot.date.slice(0, 10)}${b.timeSlot.startTime}`)).slice(0, 2)
}

View File

@@ -14,6 +14,24 @@ interface SystemLayout {
let cached: SystemLayout | null = null
/** Read the actual WeChat capsule to keep the homepage brand clear of it. */
export function getHomeNavigationLayout() {
const windowInfo = uni.getWindowInfo()
const statusBarHeight = windowInfo.statusBarHeight ?? 20
let navigationHeight = 48
let menuLeft = windowInfo.windowWidth - 100
// #ifdef MP-WEIXIN
try {
const menu = uni.getMenuButtonBoundingClientRect()
if (menu.height > 0 && menu.top >= statusBarHeight && menu.left > 0) {
navigationHeight = (menu.top - statusBarHeight) * 2 + menu.height
menuLeft = menu.left
}
} catch { /* Safe fallback for unavailable menu metrics. */ }
// #endif
return { statusBarHeight, navigationHeight, menuLeft }
}
/**
* Returns layout dimensions using the new granular APIs.
* Falls back to getSystemInfoSync only if the new APIs are unavailable.