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

@@ -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="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>
<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 && !logoFailed" class="logo-img" :src="logoImage" mode="aspectFill" @error="logoFailed = true" />
<text v-else class="logo-letter">{{ studioName.slice(0, 1) }}</text>
</view>
<view class="brand-copy">
<text class="studio-name">{{ studioName }}</text>
<text class="studio-category">普拉提私教</text>
</view>
</view>
<!-- Studio name -->
<text class="studio-name">{{ studioName }}</text>
</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.