feat: 支持新版的首页 UI
@@ -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": {
|
||||
|
||||
55
packages/app/preview/generate-tab-icons.py
Normal 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.')
|
||||
79
packages/app/preview/home-fixtures.ts
Normal 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>`
|
||||
38
packages/app/preview/home.config.ts
Normal 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] }))
|
||||
158
packages/app/preview/home.spec.mjs
Normal 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')
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -348,7 +348,7 @@
|
||||
},
|
||||
"tabBar": {
|
||||
"color": "#999999",
|
||||
"selectedColor": "#1a1a2e",
|
||||
"selectedColor": "#6b8276",
|
||||
"backgroundColor": "#ffffff",
|
||||
"borderStyle": "white",
|
||||
"list": [
|
||||
|
||||
@@ -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>
|
||||
|
||||
BIN
packages/app/src/static/home/studio-morning.jpg
Normal file
|
After Width: | Height: | Size: 266 KiB |
|
Before Width: | Height: | Size: 378 B After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 371 B After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 387 B After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 382 B After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 460 B After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 455 B After Width: | Height: | Size: 3.2 KiB |
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
63
packages/app/src/utils/home.ts
Normal 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)
|
||||
}
|
||||
@@ -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.
|
||||
|
||||