diff --git a/docs/home-welcome-design.md b/docs/home-welcome-design.md new file mode 100644 index 0000000..c141222 --- /dev/null +++ b/docs/home-welcome-design.md @@ -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` 为微信实际运行截图。 diff --git a/docs/owner-release-2026-10-25.md b/docs/owner-release-2026-10-25.md new file mode 100644 index 0000000..d71dfb5 --- /dev/null +++ b/docs/owner-release-2026-10-25.md @@ -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,再重启。数据库新增字段应保留,避免删除上线后编辑的馆主内容;数据库整库备份仅用于灾难恢复,不应覆盖上线后的新数据。 + +本次未上传或发布微信小程序。首页“馆主介绍”和后台编辑界面需在新版小程序发布后使用。 diff --git a/docs/screenshots/home-wechat-2026-10-25.png b/docs/screenshots/home-wechat-2026-10-25.png new file mode 100644 index 0000000..0a9373a Binary files /dev/null and b/docs/screenshots/home-wechat-2026-10-25.png differ diff --git a/docs/screenshots/home-welcome-preview.png b/docs/screenshots/home-welcome-preview.png new file mode 100644 index 0000000..b7efa0e Binary files /dev/null and b/docs/screenshots/home-welcome-preview.png differ diff --git a/packages/app/package.json b/packages/app/package.json index 94812a3..7e39406 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -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": { diff --git a/packages/app/preview/generate-tab-icons.py b/packages/app/preview/generate-tab-icons.py new file mode 100644 index 0000000..9ec5acb --- /dev/null +++ b/packages/app/preview/generate-tab-icons.py @@ -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.') diff --git a/packages/app/preview/home-fixtures.ts b/packages/app/preview/home-fixtures.ts new file mode 100644 index 0000000..aadc2e5 --- /dev/null +++ b/packages/app/preview/home-fixtures.ts @@ -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 = `首页设计验收 +

首页顶部 · 轻盈陪伴

实际 Vue 页面与真实交互 · 以下均为隔离的模拟数据,案例图片仅用于检查布局

新朋友

9:41▮▮▮ ◉ ▰
•••⊙

老学员 / 边界状态

9:41▮▮▮ ◉ ▰
•••⊙
` diff --git a/packages/app/preview/home.config.ts b/packages/app/preview/home.config.ts new file mode 100644 index 0000000..4fe93c1 --- /dev/null +++ b/packages/app/preview/home.config.ts @@ -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() + 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] })) diff --git a/packages/app/preview/home.spec.mjs b/packages/app/preview/home.spec.mjs new file mode 100644 index 0000000..57e8a07 --- /dev/null +++ b/packages/app/preview/home.spec.mjs @@ -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') +}) diff --git a/packages/app/src/components/BrandBanner.vue b/packages/app/src/components/BrandBanner.vue index 3695c46..93b6115 100644 --- a/packages/app/src/components/BrandBanner.vue +++ b/packages/app/src/components/BrandBanner.vue @@ -1,126 +1,114 @@ diff --git a/packages/app/src/components/QuickEntry.vue b/packages/app/src/components/QuickEntry.vue index e5e3877..88a8034 100644 --- a/packages/app/src/components/QuickEntry.vue +++ b/packages/app/src/components/QuickEntry.vue @@ -1,7 +1,14 @@