diff --git a/docs/birthday-calendar-and-avatar.md b/docs/birthday-calendar-and-avatar.md new file mode 100644 index 0000000..ffdfb5c --- /dev/null +++ b/docs/birthday-calendar-and-avatar.md @@ -0,0 +1,45 @@ +# 出生历法与头像入口验收 + +## 实现与数据约定 + +- 编辑个人信息、完善注册资料共用 `BirthdayPicker`,支持阳历与农历、真实闰月、大小月日期联动。范围为阳历 1900-01-01 至北京时间今天,与现有生肖/五行计算的支持范围保持一致。 +- `birthday` 仍为对应阳历 `YYYY-MM-DD`,用于生肖、五行和每日计算;新增 `birthdayCalendar: "solar" | "lunar"` 仅记录输入/显示偏好。切换历法不改变实际生日,保存后重新编辑按接口偏好回显。农历闰月可由阳历日期准确还原,不需再保存第二份农历生日。 +- 生日或偏好变更沿用现有个人资料保存与修改额度规则;不在选择或切换时自动保存、不擅自改动后端生日计算。 +- 首页左上角头像使用主 Tab 导航到“我的”,不再是不可点的展示区域。 +- 微信端直接点击头像即使用 `openType=chooseAvatar`,不再先打开本项目的自定义菜单;回调只预览选中的临时文件,点击保存时仍走后端文件服务,不绕过平台用户确认。H5 不支持该微信能力,仍保留相册/拍照菜单。本次 H5 验收不代表已操作微信原生弹窗。 + +## 接口清单 + +没有新增路径或改变部署基址,复用以下接口: + +- GET `/api/user/profile`:响应新增 `birthdayCalendar`,生日仍为阳历。 +- PUT `/api/user/profile`:请求、响应新增 `birthdayCalendar`;服务端只允许 `solar`、`lunar`。 +- POST `/api/auth/wx-login`、POST `/api/auth/bind-phone`:登录资料中的 `user` 同样返回偏好。 +- POST `/api/user/avatar`:头像保存仍上传至现有文件存储服务,生日选择不影响头像接口。 + +## 部署前置条件 + +后端已补 DTO、实体、联合查询、资料 Mapper 和建表定义。已有目标数据库应先由部署人员执行: + +`/Users/zhouzhibo/file/hongzhen/idea/hera/src/main/resources/db/birthday-calendar-update.sql` + +随后部署/重启新版后端,再更新小程序。脚本只按列是否存在增加 `user_profile.birthday_calendar`,默认 `solar`,不修改生日、不删除数据;没有自动启动时远程迁移。本次未执行远程 MySQL DDL、未部署后端、未提交或推送。 + +## 验收步骤 + +1. 开发者工具重新编译,登录后从首页点击左上角头像:应切换到“我的”;再点头部进入编辑信息。 +2. 点击“农历”:显示同一天的农历及对应阳历,实际生日未变;打开日期选择器可单独选择闰月。 +3. 示例农历 2023 年闰二月初一,应对应阳历 2023-03-22。切换年份到无此闰月的年份时,使用该年的普通二月;切到小月时超出天数自动收敛,不提交无效日期。 +4. 在选择器点取消:实际生日不变。点确定、保存、返回再进入:农历偏好与日期保持;切回阳历保存后同样能回显。 +5. 首次注册完善资料使用同一控件。不能选未来日期;保存后生肖、五行使用换算后的阳历日期。 +6. 微信端直接点头像:应立即出现一次微信官方头像选择器,不先弹自定义菜单;确认后预览、取消不更换当前头像。H5 点击头像仍显示相册/拍照菜单。 + +本地视觉验收可用 `http://127.0.0.1:4173/?avatarDemo=1#/pages/home/index`,其中资料、头像仅为隔离演示,刷新重置;不能据此认定远程保存已生效。 + +## 已验证与边界 + +- 79 项前端测试通过,类型检查、H5/微信构建与 13 个文件的原生 WXSS 检查通过;其中头像入口测试验证微信端直接绑定官方选择器、无自定义菜单,以及 H5 的相册入口保留。 +- 后端 12 项 H2/MockMvc 测试通过,验证资料落库、回显、再次登录、非法历法拒绝、头像不受影响;1 项真实 MySQL 测试未配置专用测试库而跳过,没有写远程库。 +- 浏览器实测首页头像跳转、农历闰月确认、隔离演示保存与再次编辑回显,以及注册场景共用选择器。 +- 截图:`/private/tmp/hera-birthday-lunar-edit.png`。 +- 微信官方选择器的外观、取消/安全审核分支需微信开发者工具与真机验收;H5 不模拟成真实微信授权。 diff --git a/docs/incense-sound.md b/docs/incense-sound.md new file mode 100644 index 0000000..4cd184f --- /dev/null +++ b/docs/incense-sound.md @@ -0,0 +1,51 @@ +# 敬神:音频与真实供奉接口 + +## 本期规则 + +- 不提供功德、爱心、钱包、捐款、充值入口,不累计功德。服务端拒绝 offeringType=4 或任何非空 meritValue。历史列和流水保留,不删数据。 +- 喇叭默认开启,偏好仅保存在本机。关闭立即静音,音频以音量0继续推进;重新开启只恢复当前实际进度的声音,不暂停、不重头、不补播。最初静音上香也从首轮开始推进,音频已经结束则开启不再出声;追加上香延长点亮,不延长音频。 +- 每次上香独立提交。只有服务端返回有效记录 ID 后,次数才+1;请求失败不计数、不播音、不自动重试。网络结果不明时应刷新核对,避免手动重试重复记账。 +- 点亮截止时间为最后一次成功请求对应的点击时间+15秒(不从回包开始重计)。持续点击仅增加次数、延长点亮,不重播;音频自然结束不循环。点亮到期即停止声音、恢复供桌,不回退次数。 +- 页面隐藏/离开销毁音频。返回只恢复剩余点亮,不自动播放。播放错误提示用户,但不撤销已落库记录。 +- 祈愿最多500字;回向任意姓名最多50字+祝愿最多500字。权限、有效绑定由服务端再次验证。成功才清空,失败保留内容。表单提交中防重复,连续上香不节流丢次数。 + +## 已接入接口(JWT,标准 ApiResponse) + +服务前缀 `http://101.43.139.56/hera`。 + +| 接口 | 用途/请求 | +| --- | --- | +| GET `/api/tiangong/items`(新增) | 本人列表:id/name/iconUrl/description、holdingId、active、days、incenseCount。状态与统计来自数据库,无示例持有。 | +| POST `/api/user/virtual-items/offerings`(复用) | `{userVirtualItemId: 本人holdingId, offeringType: 2}` 上香;type=3+`content` 祈愿。返回流水及id。 | +| POST `/api/user/virtual-items/dedications`(新增) | `{userVirtualItemId: 本人holdingId, name, content}` 任意姓名回向。返回流水及id。接收人ID为空,不创建账号、不转移资产。 | + +前端调用:`src/services/tiangong.ts`;状态管理:`src/utils/tiangongStore.ts`。使用服务端持有ID,不猜测数字ID。原型与字典名称精确匹配(财神/武财神、禄星、寿星、文昌星、月老),未配置者禁用,不假装已恭请。账号变更清空缓存,旧响应不能写入新账号。 + +真实 NFC 绑定、解绑、送神不属于本次三项写接口;正式模式不执行本地 Mock 绑定,也不展示示例额度或虚构成功。已有绑定可正常供奉。此前拟定 NFC 契约仍未调用。 + +## 部署边界 + +**前后端代码推送不等于部署:本次未部署远程后端、未操作远程数据库。** + +部署后端前执行 `hera/src/main/resources/db/named-dedication-update.sql`,仅将 `user_virtual_item_transfer.to_user_id` 改为可空,保留数据。`CREATE TABLE IF NOT EXISTS` 不会更新现存表,不能省略。前端构建不能代替后端部署。此前生日偏好迁移也需按对应文档执行。 + +远端无新接口/新表结构时,正式页面提示加载或提交失败,不以 Mock 伪装成功。隔离 H2 测试已验证 Controller→Service→MyBatis→落库,不等同于远端联调。 + +H5演示:`http://127.0.0.1:4173/?avatarDemo=1#/pages/tiangong-detail/index?id=wealth`。仅此显式演示和preview使用内存Mock及明确提示,不上传姓名和祝愿,不代表真实账号。普通地址及微信构建走真实接口。 + +## 开发者工具验收 + +1. 重新编译,后端及SQL部署后,用已绑定神仙的账号登录→天宫→对应详情。无功德入口,上香次数与服务端一致。 +2. 开声→上香:网络出现type=2请求,成功才次数+1、点亮。10秒再点一次,再+1;音频不重播,第二次点击15秒后恢复。 +3. 上香第3秒关闭喇叭,立即无声;第10秒开启,应听到第10秒处剩余音频,不从第3秒接续,次数不变。第15秒音频结束后开启不得再播;第10秒追加上香使供桌仍亮时,也不得重播已结束音频。初始静音上香同样验证。 +4. 祈愿输入内容→确认,type=3成功显示“祈愿已记录”。回向输入任意姓名+祝愿→确认,dedications成功显示“回向已记录”;数据库to_user_id为空,姓名祝愿保留。 +5. 断网或停用绑定:三种提交均不显示成功;上香不增加;表单内容保留。切账号后不能沿用旧持有状态,离开不得继续响。 +6. 真机检查系统静音、音量及来电中断。自动测试验证音量开关、进度推进及结束状态,不能代替听感验收。 + +用户原WAV未改;本地15秒AAC约179KB。使用 createInnerAudioContext 的play/volume/stop/destroy、结束/错误事件及本机存储,不调用真实支付或硬件。 + +## 验证记录 + +- 音频规则调整后103项前端测试通过,类型检查、H5/微信构建、13个原生WXSS编译检查通过。后端沿用上次H2供奉/NFC测试14项、账号回归8项的验证结果,本次未修改后端。 +- 微信产物合计2,022,060 bytes,低于2MiB,后续新增素材需关注余量。 +- 浏览器375×812检查上香计数、开关、回向、祈愿及空值校验通过;无浏览器错误。截图 `/private/tmp/hera-dedication-no-merit.png`。未以浏览器检查替代真机听感和远端联调。 diff --git a/docs/tiangong-login-access.md b/docs/tiangong-login-access.md new file mode 100644 index 0000000..7dccc3b --- /dev/null +++ b/docs/tiangong-login-access.md @@ -0,0 +1,47 @@ +# 天宫登录访问限制 + +## 行为 + +- 真实模式没有登录 token:点击中间天宫入口直接发起微信登录,不切换到神仙内容、不堆叠重复登录。首页、我的与天宫直达路由共用同一套登录提示卡片。 +- 直接打开天宫或敬神/请神详情页:没有权限时只显示登录提示和返回首页入口,不挂载神仙列表、神像场景或业务弹窗。直接进入不强制反复跳转,避免用户按返回键陷入循环。 +- 有 token:同一 token 的资料缓存仍在 30 秒有效期内时直接挂载内容,不先切为 checking,不重复请求资料;没有有效缓存才通过共享资料接口确认。401 清除登录后隐藏内容;网络错误显示重试,不当成登录成功,也不随意删除仍有效的 token。 +- 页面隐藏不再重置可复用的已验证状态;重新显示时检查有效期。隐藏或卸载后的旧异步结果不恢复页面权限,切换账号不能沿用另一个 token 的 allowed 状态。注册未完成、仅取得微信临时会话而尚无登录 token,也不能进入天宫。 +- 必要的异步校验使用天宫轻量场景占位和可用的返回入口,不显示“正在确认登录状态”的整页卡片,不提前挂载神仙资料或业务按钮。 +- 现有 `preview=1` 和 `avatarDemo=1` 仅在 H5 的明确演示环境保留访问,仍沿用隔离 Mock,不创建真实身份、不调用真实天宫写接口;微信端不启用这些查询参数。 +- 登录/注册成功后的落点继续沿用我的/完善资料页,没有增加未约定的返回天宫逻辑。用户随后点击天宫即可进入。 + +## 接口边界 + +不新增 REST 接口。复用已存在且需要 Bearer token 的 GET `/api/user/profile` 校验,基址按部署配置默认使用 `http://101.43.139.56/hera`。本次没有把天宫 Mock 接口改成真实后端,也不以界面守卫代替服务端鉴权。 + +## 验收 + +1. 在微信开发者工具以未登录状态进入首页或我的,点击登录/注册或天宫入口:直接调用微信登录;不能看到任何神仙,连续点击不重复发起登录。 +2. 未登录直达 `pages/fortune/index` 或 `pages/tiangong-detail/index?id=wealth`:只出现登录提示,不能操作恭请、上香、祈愿或其他业务按钮。 +3. 正常完成登录/注册,重新点击天宫:资料校验通过后可进入;首页、我的的导航不受影响。 +4. 登录过期返回 401 后:天宫和详情内容隐藏,重新登录后恢复。断网导致校验失败:显示重新加载和返回首页,不展示旧内容。 +5. H5 普通地址不带演示参数时执行同样拦截;H5 点击登录只提示在微信环境完成,不伪造 code、不再打开“那吉”品牌中间页。明确的演示 URL 保留原型预览。 + +自动测试覆盖导航拦截、重复点击、登录失败重试、无 token 不请求资料、真实会话校验、401、网络错误、隐藏/卸载后的异步失效,以及直达页面不挂载业务内容。 + +## 本次验证 + +- 全量前端测试 83 项通过,TypeScript 检查、H5 构建、微信小程序构建通过;13 个 WXSS 文件通过微信原生编译器检查。 +- 浏览器实测:未登录首页和我的显示相同卡片,H5 登录入口不再跳品牌页;冷启动天宫列表和详情路由仅展示登录提示;独立头像演示模式仍展示隔离 Mock。 +- 小屏视口下检查统一登录提示、操作按钮与底部导航,无遮挡。截图保存在 `/private/tmp/hera-unified-login.png`。 +- 未在浏览器伪造微信登录,也未验证真实微信授权流程;有效会话和过期会话分支由自动测试覆盖。本次未修改后端、未提交或推送代码。 + +## 锁定页与精简登录流程 + +- 未绑定任意神仙时,首页和我的元素报告使用固定高度的锁定首屏,裁切长报告并拦截触摸滚动。绑定弹窗、返回、底部导航仍可操作;绑定后移除限制,恢复报告滚动。 +- 微信入口直接执行 `wx.login` 和 POST `/api/auth/wx-login`。已注册的响应直接沿用原有登录成功落点;未绑定手机号的响应仅把临时会话保留在内存,再进入必要的手机号表单,不放入 URL 或持久存储。 +- 手机号验证仍使用 POST `/api/auth/sms` 与 POST `/api/auth/bind-phone`。品牌名称、“一触即吉”和二次微信登录点击已从正常入口流程移除,不绕过后端注册和手机号验证。 +- 微信开发者工具验收:未绑定时在首页和“我的→我的元素”上下滑动,页面不位移;点击绑定/取消仍有效,绑定一个神仙后可正常滚动。分别从三个入口登录,已注册用户不再看到品牌中间页,新用户才显示手机号与验证码表单。 +- 浏览器已用隔离 Mock 验证解绑→锁定→绑定→恢复滚动;没有操作真实绑定或真实短信。截图:`/private/tmp/hera-locked-home.png`、`/private/tmp/hera-unified-login.png`。 + +## 详情页无登录检查闪屏 + +- 微信开发者工具登录后进入天宫,再点财神详情、返回、再次进入:资料缓存有效时应直接看到详情,不出现登录检查页。 +- 超过资料缓存有效期、冷启动只有 token 时,仍先校验,使用轻量占位;401 应显示登录入口,断网应显示重试,不能展示未验证的内容。 +- 自动测试覆盖有效缓存首帧允许且不发请求、隐藏/返回不闪检查态、缓存过期必须重新验证、切换账号/退出不能复用旧状态、轻量占位不渲染登录检查卡片。 +- 浏览器已验证隔离 Mock 中详情进入和返回,以及真实未登录直达详情仍被拦截;未伪造登录凭证。真实会话缓存/延迟分支通过自动测试验证,仍需微信端实际网络环境验收。截图:`/private/tmp/hera-tiangong-smooth-detail.png`。 diff --git a/package.json b/package.json index fd99d41..21d5fc0 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "test:wxss": "node --test scripts/wxssCompatibility.test.cjs", "test:navigation": "node --test scripts/navigation.test.cjs scripts/frontendPreview.test.cjs", "test:tiangong": "node --test scripts/tiangong.test.cjs", + "test:tiangong-access": "node --test scripts/tiangongAccess.test.cjs scripts/navigation.test.cjs", "test:elements": "node --test scripts/fiveElements.test.cjs scripts/elementIcon.test.cjs", "test:zodiac": "node --test scripts/zodiac.test.cjs", "test:zodiac-weekly": "node --test scripts/zodiacWeekly.test.cjs", diff --git a/scripts/accountIntegration.test.cjs b/scripts/accountIntegration.test.cjs index a16212a..9df2ef2 100644 --- a/scripts/accountIntegration.test.cjs +++ b/scripts/accountIntegration.test.cjs @@ -81,6 +81,35 @@ test('无登录凭证不发送个人资料请求;401清除缓存并同步所 unsubscribe() }) +test('生日阳历日期与农历显示偏好一起保存并从接口回显', async () => { + const ctx = setup() + const original = await ctx.service.api.profile() + const form = { ...ctx.editing.toEditableProfile(original), birthdayCalendar: 'lunar' } + const saved = await ctx.editing.saveProfileEdits(original, form, '', ctx.service.api, () => {}) + const request = ctx.calls.find(call => call.method === 'PUT') + assert.equal(request.data.birthday, original.birthday) + assert.equal(request.data.birthdayCalendar, 'lunar') + assert.equal(saved.profile.birthdayCalendar, 'lunar') + const reloaded = ctx.editing.toEditableProfile(await ctx.service.api.profile(true)) + assert.equal(reloaded.birthdayCalendar, 'lunar') + assert.equal(reloaded.birthday, original.birthday) + const before = ctx.calls.length + const unchanged = await ctx.editing.saveProfileEdits(saved.profile, reloaded, '', ctx.service.api, () => {}) + assert.equal(unchanged.changed, false) + assert.equal(ctx.calls.length, before) +}) + +test('登录校验只复用同token的有效资料,账号切换与退出后立即失效', async () => { + const ctx = setup() + assert.equal(ctx.service.getFreshCachedProfile(), null) + await ctx.service.api.profile() + assert.equal(ctx.service.getFreshCachedProfile().id, fullProfile.id) + ctx.storage.token = 'another-session' + assert.equal(ctx.service.getFreshCachedProfile(), null) + ctx.service.clearLogin() + assert.equal(ctx.service.getFreshCachedProfile(), null) +}) + test('头像经专用COS后端接口上传,Bearer字段正确,禁止H5跨域Cookie', async () => { const ctx = setup() const updates = [] diff --git a/scripts/avatarEntry.test.cjs b/scripts/avatarEntry.test.cjs new file mode 100644 index 0000000..c8342e0 --- /dev/null +++ b/scripts/avatarEntry.test.cjs @@ -0,0 +1,71 @@ +const test = require('node:test') +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') +const vm = require('node:vm') +const ts = require('typescript') +const profile = { nickname: '测试', gender: '男', birthday: '1998-06-18', birthdayCalendar: 'solar', birthHour: '辰时', province: '北京市', city: '北京市', district: '朝阳区', remainingEdits: 5 } + +function render(env, sheet = false) { + const exports = {}, updates = [], media = [] + let index = 0 + const source = fs.readFileSync(path.join(__dirname, '../src/pages/profile-edit/index.tsx'), 'utf8') + vm.runInNewContext(ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2019, jsx: ts.JsxEmit.ReactJSX } }).outputText, { + exports, process: { env: { TARO_ENV: env } }, require: id => { + if (id === 'react/jsx-runtime') return require(id) + if (id === 'react') return { useEffect: () => {}, useRef: initial => ({ current: initial }), useState: initial => { + const current = index++ + let value = typeof initial === 'function' ? initial() : initial + if (value === null || (value && typeof value === 'object' && 'nickname' in value)) value = profile + if (initial === true) value = false + if (current === 5) value = sheet // avatarSheetOpen + return [value, next => updates.push([current, next])] + } } + if (id === '@tarojs/taro') return { useRouter: () => ({ params: {} }), default: { + chooseMedia: async options => { media.push(options); return { tempFiles: [{ tempFilePath: 'temp:album', size: 10 }] } }, showToast: () => {} + } } + if (id === '@tarojs/components') return { View: 'View', Text: 'Text', Button: 'Button', Input: 'Input', Picker: 'Picker' } + if (id === 'china-area-data') return { default: {} } + if (id.endsWith('/NavBar')) return { NavBar: 'NavBar' } + if (id.endsWith('/UserAvatar')) return { UserAvatar: 'UserAvatar' } + if (id.endsWith('/BirthdayPicker')) return { BirthdayPicker: 'BirthdayPicker' } + if (id.endsWith('/frontendPreview')) return { isFrontendPreview: () => false } + if (id.endsWith('/avatarDemo')) return { isAvatarDemo: () => false } + return {} + } + }) + const collect = node => { + if (!node || typeof node !== 'object') return [] + if (Array.isArray(node)) return node.flatMap(collect) + return [node, ...collect(node.props?.children)] + } + return { nodes: collect(exports.default()), updates, media } +} + +test('微信端头像本身直接绑定官方chooseAvatar,不再弹自定义菜单', () => { + const h = render('weapp', true) + const button = h.nodes.find(node => node.props?.className === 'avatar-button') + assert.equal(button.props.openType, 'chooseAvatar') + assert.equal(button.props.onClick, undefined) + assert.equal(typeof button.props.onChooseAvatar, 'function') + assert.ok(!h.nodes.some(node => node.props?.className === 'avatar-mask')) + button.props.onChooseAvatar({ detail: { avatarUrl: 'temp:wechat-avatar' } }) + assert.equal(h.media.length, 0) + assert.ok(h.updates.some(([, value]) => value === 'temp:wechat-avatar')) + assert.ok(h.updates.some(([index, value]) => index === 5 && value === false)) +}) + +test('H5保留相册拍照菜单,不伪造微信官方头像选择能力', async () => { + const h = render('h5') + const button = h.nodes.find(node => node.props?.className === 'avatar-button') + assert.equal(button.props.openType, undefined) + assert.equal(button.props.onChooseAvatar, undefined) + button.props.onClick() + assert.ok(h.updates.some(([index, value]) => index === 5 && value === true)) + const opened = render('h5', true) + const album = opened.nodes.find(node => node.props?.children === '从相册选择') + await album.props.onClick() + assert.equal(opened.media.length, 1) + assert.equal(opened.media[0].sourceType[0], 'album') +}) diff --git a/scripts/birthdayCalendar.test.cjs b/scripts/birthdayCalendar.test.cjs new file mode 100644 index 0000000..3bd13dd --- /dev/null +++ b/scripts/birthdayCalendar.test.cjs @@ -0,0 +1,97 @@ +const test = require('node:test') +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') +const vm = require('node:vm') +const ts = require('typescript') +const exports_ = {} +vm.runInNewContext(ts.transpileModule(fs.readFileSync(path.join(__dirname, '../src/utils/birthdayCalendar.ts'), 'utf8'), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2019 } +}).outputText, { exports: exports_, require }) +const { solarToLunarBirthday, lunarBirthdayToSolar, lunarBirthdayModel, changeLunarBirthdayColumn, formatLunarBirthday } = exports_ +const max = '2026-10-18' + +test('阳历农历准确往返,闰月单列而不是重复普通月份', () => { + for (const solar of ['1900-01-01', '1998-06-18', '2000-02-05', '2023-03-22', '2024-02-29', max]) { + assert.equal(lunarBirthdayToSolar(solarToLunarBirthday(solar)), solar) + } + const leap = solarToLunarBirthday('2023-03-22') + assert.equal(leap.year, 2023); assert.equal(leap.month, -2); assert.equal(leap.day, 1) + assert.match(formatLunarBirthday('2023-03-22'), /闰二月初一/) + const model = lunarBirthdayModel(leap, max) + assert.ok(model.months.some(month => month.value === 2)) + assert.ok(model.months.some(month => month.value === -2 && month.label === '闰二月')) +}) + +test('小月天数、无闰月年份联动纠正,不生成无效生日', () => { + const original = lunarBirthdayModel({ year: 2023, month: 2, day: 30 }, max) + const leapIndex = original.months.findIndex(month => month.value === -2) + const leap = changeLunarBirthdayColumn(original, 1, leapIndex, max) + assert.equal(leap.month, -2); assert.equal(leap.day, 29) + const leapModel = lunarBirthdayModel(leap, max) + const next = changeLunarBirthdayColumn(leapModel, 0, leapModel.years.indexOf(2024), max) + assert.equal(next.month, 2) + assert.ok(lunarBirthdayToSolar(next)) + assert.equal(lunarBirthdayToSolar({ year: 2023, month: -2, day: 30 }), null) + assert.equal(lunarBirthdayToSolar({ year: 2024, month: -2, day: 1 }), null) +}) + +test('边界年月日只包含1900至今天,农历跨年仍正确', () => { + for (const solar of ['1900-01-01', max]) { + const model = lunarBirthdayModel(solarToLunarBirthday(solar), max) + for (const month of model.months) for (const day of month.days) { + const candidate = lunarBirthdayToSolar({ year: model.selection.year, month: month.value, day }) + assert.ok(candidate >= '1900-01-01' && candidate <= max) + } + } + const current = lunarBirthdayModel({ year: 2026, month: 12, day: 30 }, max) + assert.ok(lunarBirthdayToSolar(current.selection) <= max) + assert.equal(solarToLunarBirthday('2026-02-30'), null) + assert.equal(solarToLunarBirthday('2026-2-1'), null) +}) + +test('同一生日两种输入不改变生肖与日主计算日期', () => { + for (const solar of ['2000-02-04', '2000-02-05', '2023-03-22']) { + const lunar = solarToLunarBirthday(solar) + const input = lunarBirthdayModel(lunar, max) + assert.equal(lunarBirthdayToSolar(input.selection), solar) + } +}) + +test('微信头像选择仅接收系统结果,不会再次调用媒体选择', () => { + const source = fs.readFileSync(path.join(__dirname, '../src/pages/profile-edit/index.tsx'), 'utf8') + assert.equal((source.match(/\? 'chooseAvatar' : undefined/g) || []).length, 1) + const callback = source.match(/const handleWechatAvatar = [\s\S]*?\n }/)[0] + assert.match(callback, /useAvatar\(event.detail.avatarUrl\)/) + assert.doesNotMatch(callback, /chooseMedia|chooseFrom|chooseImage/) + assert.doesNotMatch(source, /微信头像选择|wechat-avatar-action/) +}) + +test('日期控件回显农历偏好,切换历法不修改实际生日或触发保存', () => { + const component = {} + const file = fs.readFileSync(path.join(__dirname, '../src/components/BirthdayPicker.tsx'), 'utf8') + vm.runInNewContext(ts.transpileModule(file, { compilerOptions: { module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2019, jsx: ts.JsxEmit.ReactJSX } }).outputText, { exports: component, require: id => { + if (id === 'react') return { useState: initial => [initial(), () => {}], useEffect: () => {}, useMemo: fn => fn() } + if (id.endsWith('/birthdayCalendar')) return exports_ + if (id === '@tarojs/components') return { View: 'View', Text: 'Text', Button: 'Button', Picker: 'Picker' } + return require(id) + } }) + const modes = [], dates = [] + const tree = component.BirthdayPicker({ value: '2023-03-22', calendar: 'lunar', onChange: value => dates.push(value), onCalendarChange: mode => modes.push(mode) }) + const collect = node => { + if (!node || typeof node !== 'object') return [] + if (Array.isArray(node)) return node.flatMap(collect) + return [node, ...collect(node.props?.children)] + } + const nodes = collect(tree) + const lunar = nodes.find(node => node.props?.['aria-label'] === '选择农历') + assert.match(lunar.props.className, /active/) + nodes.find(node => node.props?.['aria-label'] === '选择阳历').props.onClick() + assert.deepEqual(modes, ['solar']); assert.deepEqual(dates, []) + const picker = nodes.find(node => node.type === 'Picker') + assert.equal(picker.props.mode, 'multiSelector') + assert.ok(picker.props.range[1].includes('闰二月')) + picker.props.onCancel() + assert.deepEqual(dates, []) +}) diff --git a/scripts/incenseRitual.test.cjs b/scripts/incenseRitual.test.cjs new file mode 100644 index 0000000..d8cd7c6 --- /dev/null +++ b/scripts/incenseRitual.test.cjs @@ -0,0 +1,241 @@ +const test = require('node:test') +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') +const vm = require('node:vm') +const ts = require('typescript') +const root = path.resolve(__dirname, '..') +function load(file, dependencies = {}, globals = {}) { + const exports = {} + vm.runInNewContext(ts.transpileModule(fs.readFileSync(path.join(root, file), 'utf8'), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2019 } + }).outputText, { exports, require: id => dependencies[id], setTimeout, clearTimeout, ...globals }) + return exports +} +const ritualModule = load('src/utils/incenseRitual.ts') +const { createIncenseRitual, INCENSE_DURATION_MS } = ritualModule +const { createTiangongMock } = load('src/utils/tiangongMock.ts', { './incenseRitual': ritualModule }) + +function harness() { + let time = 0, nextTimer = 0, enabled = true, errors = 0, refreshed = 0, creationFails = false + const timers = new Map(), audios = [] + const mock = createTiangongMock(() => time) + const scheduler = { + now: () => time, + schedule: (callback, delay) => { const id = ++nextTimer; timers.set(id, { callback, at: time + delay }); return id }, + cancel: id => { timers.delete(id) } + } + const create = () => createIncenseRitual({ + ...scheduler, + getDeadline: () => mock.snapshot().deities.wealth.incenseUntil, + recordIncense: () => { mock.incense('wealth') }, + refresh: () => { refreshed++; mock.refreshIncense('wealth') }, + soundEnabled: () => enabled, + createAudio: () => { + if (creationFails) throw new Error('unavailable') + const startedAt = time + const audio = { plays: 0, pauses: 0, stops: 0, destroys: 0, volume: 1, + get currentTime() { return Math.min(15, (time - startedAt) / 1000) }, + play() { this.plays++ }, pause() { this.pauses++ }, stop() { this.stops++ }, destroy() { this.destroys++ }, + onEnded(callback) { this.ended = callback }, onError(callback) { this.error = callback } } + audios.push(audio) + return audio + }, + onAudioError: () => { errors++ } + }) + const ritual = create() + const advance = milliseconds => { + const target = time + milliseconds + while (true) { + const next = [...timers.entries()].filter(([, timer]) => timer.at <= target).sort((a, b) => a[1].at - b[1].at)[0] + if (!next) break + time = next[1].at; timers.delete(next[0]); next[1].callback() + } + time = target + } + return { ritual, create, mock, audios, advance, timers, + mute: value => { enabled = value; if (!value) ritual.mute(); else ritual.resume() }, + creationFails: value => { creationFails = value }, errors: () => errors, refreshed: () => refreshed } +} + +test('首次上香播放一次,15秒后恢复供桌,次数不回退', () => { + const h = harness() + h.ritual.tap() + assert.equal(h.mock.snapshot().deities.wealth.incenseCount, 60) + assert.equal(h.mock.snapshot().deities.wealth.lit, true) + assert.equal(h.audios.length, 1); assert.equal(h.audios[0].plays, 1) + h.advance(INCENSE_DURATION_MS - 1) + assert.equal(h.mock.snapshot().deities.wealth.lit, true) + h.advance(1) + assert.equal(h.mock.snapshot().deities.wealth.lit, false) + assert.equal(h.mock.snapshot().deities.wealth.incenseCount, 60) + assert.equal(h.refreshed(), 1) + assert.equal(h.audios[0].destroys, 1) +}) + +test('重复上香每次+1并按最后点击延长15秒,但音频不重播或续播', () => { + const h = harness() + h.ritual.tap(); h.advance(10000); h.ritual.tap() + assert.equal(h.mock.snapshot().deities.wealth.incenseUntil, 25000) + assert.equal(h.mock.snapshot().deities.wealth.incenseCount, 61) + h.advance(5000) + assert.equal(h.audios[0].destroys, 1) + assert.equal(h.mock.snapshot().deities.wealth.lit, true) + h.advance(5000); h.ritual.tap() + assert.equal(h.audios.length, 1) + h.advance(14999); assert.equal(h.mock.snapshot().deities.wealth.lit, true) + h.advance(1); assert.equal(h.mock.snapshot().deities.wealth.lit, false) + assert.equal(h.mock.snapshot().deities.wealth.incenseCount, 62) + h.ritual.tap(); assert.equal(h.audios.length, 2) +}) + +test('关闭立即静音但进度继续,重新开启接实际进度,不重播不增加次数', () => { + const h = harness() + h.ritual.tap(); h.advance(3000); h.mute(false) + assert.equal(h.audios[0].volume, 0); assert.equal(h.audios[0].pauses, 0); assert.equal(h.audios[0].destroys, 0) + h.advance(2000); h.mute(true) + assert.equal(h.audios[0].volume, 1); assert.equal(h.audios[0].currentTime, 5) + assert.equal(h.audios[0].plays, 1); assert.equal(h.mock.snapshot().deities.wealth.incenseCount, 60) + h.ritual.tap(); assert.equal(h.audios.length, 1) + h.advance(15000); h.ritual.tap(); assert.equal(h.audios.length, 2) +}) + +test('静音上香以音量0推进,轮次内开启按实际进度出声,过期不补播', () => { + const h = harness() + h.mute(false); h.ritual.tap() + assert.equal(h.audios.length, 1); assert.equal(h.audios[0].volume, 0) + assert.equal(h.mock.snapshot().deities.wealth.incenseCount, 60) + h.advance(7000); h.mute(true); h.ritual.tap(); assert.equal(h.audios.length, 1) + assert.equal(h.audios[0].currentTime, 7); assert.equal(h.audios[0].volume, 1) + h.mute(false); h.advance(15000); h.mute(true); assert.equal(h.audios.length, 1) + h.ritual.tap(); assert.equal(h.audios.length, 2) +}) + +test('播放失败不回退计数、不重复播放,也不妨碍15秒状态恢复', () => { + const h = harness() + h.ritual.tap(); h.audios[0].error() + assert.equal(h.errors(), 1); assert.equal(h.audios[0].destroys, 1) + h.audios[0].error(); assert.equal(h.errors(), 1) + h.ritual.tap(); assert.equal(h.audios.length, 1) + h.advance(15000); assert.equal(h.mock.snapshot().deities.wealth.lit, false) + h.creationFails(true); h.ritual.tap(); assert.equal(h.errors(), 2) + assert.equal(h.mock.snapshot().deities.wealth.incenseCount, 62) + h.advance(15000); assert.equal(h.mock.snapshot().deities.wealth.lit, false) +}) + +test('静音期间自然结束,延长点亮也不能让开关重新播放已结束的音频', () => { + const h = harness() + h.ritual.tap(); h.advance(3000); h.mute(false) + h.advance(7000); h.ritual.tap(); h.advance(5000) + assert.equal(h.audios[0].destroys, 1); assert.equal(h.mock.snapshot().deities.wealth.lit, true) + h.mute(false); h.mute(true); assert.equal(h.audios.length, 1) + assert.equal(h.audios[0].plays, 1) + h.advance(10000); h.ritual.tap(); assert.equal(h.audios.length, 2) +}) + +test('异步失败不播音,离开后成功回包也不产生后台声音', async () => { + let resolve, reject, deadline = 0, creations = 0 + const response = () => new Promise((ok, fail) => { resolve = ok; reject = fail }) + const ritual = createIncenseRitual({ now: () => 1000, getDeadline: () => deadline, + recordIncense: response, refresh() {}, soundEnabled: () => true, + createAudio: () => { creations++; throw new Error('不应该创建') }, onAudioError() {} }) + const failed = ritual.tap(); reject(new Error('失败')); await assert.rejects(failed, /失败/) + assert.equal(creations, 0) + const pending = ritual.tap(); ritual.dispose(); deadline = 16000; resolve({ startsRound: true }); await pending + assert.equal(creations, 0) +}) + +test('音频结束回调被延迟时,重新开声仍按15秒截止时间判断,不恢复过期声音', () => { + const h = harness() + h.ritual.tap(); h.advance(10000); h.ritual.tap(); h.mute(false) + h.timers.clear(); h.advance(6000); h.mute(true) + assert.equal(h.mock.snapshot().deities.wealth.lit, true) + assert.equal(h.audios[0].volume, 0); assert.equal(h.audios[0].destroys, 1) + assert.equal(h.audios[0].plays, 1); assert.equal(h.audios.length, 1) + h.ritual.dispose() +}) + +test('音频自然结束不提前熄香,旧回调不会停止下一轮音频', () => { + const h = harness() + h.ritual.tap(); h.advance(12000); h.audios[0].ended() + assert.equal(h.mock.snapshot().deities.wealth.lit, true) + h.advance(3000); h.ritual.tap() + h.audios[0].ended(); h.audios[0].error() + assert.equal(h.audios[1].stops, 0); assert.equal(h.errors(), 0) +}) + +test('离开清理声音和定时器,返回只恢复剩余视觉计时,不自动播放', () => { + const h = harness() + h.ritual.tap(); h.advance(3000); h.ritual.dispose() + assert.equal(h.timers.size, 0); assert.equal(h.audios[0].destroys, 1) + h.advance(2000) + const returning = h.create(); returning.sync() + assert.equal(h.audios.length, 1) + assert.equal(h.mock.snapshot().deities.wealth.lit, true) + h.advance(10000); assert.equal(h.mock.snapshot().deities.wealth.lit, false) + returning.tap(); assert.equal(h.audios.length, 2) + returning.dispose() +}) + +test('后台定时器延迟时按绝对截止时间校正,解绑与重置清除点亮状态', () => { + const h = harness() + h.ritual.tap(); h.ritual.dispose(); h.advance(20000) + assert.equal(h.mock.snapshot().deities.wealth.lit, false) + assert.equal(h.mock.snapshot().deities.wealth.incenseCount, 60) + h.mock.incense('wealth'); h.mock.unbind('wealth') + assert.equal(h.mock.snapshot().deities.wealth.incenseUntil, 0) + h.mock.invite('wealth'); h.mock.incense('wealth'); h.mock.reset() + assert.equal(h.mock.snapshot().deities.wealth.incenseUntil, 0) +}) + +test('无恭请权限时不创建声音或定时器,不允许额外扣款或奖励', () => { + const h = harness() + h.mock.unbind('wealth') + assert.throws(() => h.ritual.tap(), /未恭请/) + assert.equal(h.audios.length, 0); assert.equal(h.timers.size, 0) + assert.equal('balance' in h.mock.snapshot(), false) +}) + +test('喇叭默认开启、开关跨详情记忆,微信音频懒创建且离开销毁', () => { + const storage = {}, audio = [], cleanups = [] + const instantiate = () => { + let state, show, hide, stateIndex = 0 + const mock = createTiangongMock() + const context = load('src/hooks/useIncenseRitual.ts', { + react: { useState: initial => { const index = stateIndex++; const value = typeof initial === 'function' ? initial() : initial; if (!index) state = value; return [value, value => { if (!index) state = value }] }, useRef: initial => ({ current: initial }), useEffect: fn => { cleanups.push(fn()) } }, + '@tarojs/taro': { useDidShow: fn => { show = fn }, useDidHide: fn => { hide = fn }, default: { + getStorageSync: key => storage[key], setStorageSync: (key, value) => { storage[key] = value }, showToast: () => {}, + createInnerAudioContext: () => { const item = { plays: 0, destroys: 0, play() { this.plays++ }, pause() {}, stop() {}, destroy() { this.destroys++ }, onEnded() {}, onError() {} }; audio.push(item); return item } + } }, + '../services/tiangong': { tiangongSession: { ...mock, incense: id => { const startsRound = !mock.snapshot().deities[id].lit; mock.incense(id); return { startsRound } } } }, + '../utils/incenseRitual': ritualModule, + '../assets/tiangong/incense-yinqing.m4a': { default: 'assets/incense-yinqing.m4a' } + }, { process: { env: { TARO_ENV: 'weapp' } } }) + const result = context.useIncenseRitual('wealth') + return { result, show: () => show(), hide: () => hide(), state: () => state } + } + try { + const first = instantiate(); first.show() + assert.equal(first.state(), true); assert.equal(audio.length, 0) + first.result.toggleSound(); assert.equal(first.state(), false) + first.hide() + const second = instantiate(); second.show() + assert.equal(second.state(), false) + second.result.incense(); assert.equal(audio.length, 1); assert.equal(audio[0].volume, 0) + second.result.toggleSound(); assert.equal(second.state(), true) + second.result.incense(); assert.equal(audio.length, 1) + second.hide() + const third = instantiate(); third.show(); third.result.incense(); third.result.incense() + assert.equal(audio.length, 2); assert.equal(audio[1].plays, 1) + assert.equal(audio[1].src, '/assets/incense-yinqing.m4a') + assert.equal(audio[1].autoplay, false); assert.equal(audio[1].loop, false) + third.hide(); assert.equal(audio[1].destroys, 1) + } finally { cleanups.forEach(fn => fn?.()) } +}) + +test('打包音频为用户素材转码的本地M4A,大小低于200KB,原WAV不进入主包', () => { + const bytes = fs.readFileSync(path.join(root, 'src/assets/tiangong/incense-yinqing.m4a')) + assert.ok(bytes.length > 100000 && bytes.length < 200 * 1024) + assert.equal(bytes.subarray(4, 8).toString(), 'ftyp') + assert.equal(fs.existsSync(path.join(root, 'src/assets/tiangong/incense-yinqing.wav')), false) +}) diff --git a/scripts/loginPresentation.test.cjs b/scripts/loginPresentation.test.cjs new file mode 100644 index 0000000..b83b0ed --- /dev/null +++ b/scripts/loginPresentation.test.cjs @@ -0,0 +1,86 @@ +const test = require('node:test') +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') +const vm = require('node:vm') +const ts = require('typescript') +const root = path.resolve(__dirname, '..') +const read = file => fs.readFileSync(path.join(root, file), 'utf8') + +function page(file, { guest = false, bound = false, element = false, routes = [] } = {}) { + const profile = guest ? null : { birthday: '1998-06-18', birthHour: '辰时' } + const factor = { description: '测试' } + const report = { scores: [], dayMaster: '甲', masterElement: '木', strength: '偏弱', strengthScore: 20, + pillars: [], favorable: '水木', unfavorable: '土', analysis: { season: factor, roots: factor, support: factor } } + const day = { label: '今日', date: '2026-10-18' } + const exports = {} + vm.runInNewContext(ts.transpileModule(read(file), { compilerOptions: { + module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2019, jsx: ts.JsxEmit.ReactJSX + } }).outputText, { exports, require: id => { + if (id === 'react/jsx-runtime') return require(id) + if (id === 'react') return { useEffect: () => {}, useMemo: fn => fn(), useRef: value => ({ current: value }), + useState: value => [element && value === 'home' ? 'element' : typeof value === 'function' ? value() : value, () => {}] } + if (id === '@tarojs/taro') return { useDidShow: () => {} } + if (id === '@tarojs/components') return { View: 'View', Text: 'Text', Button: 'Button', Image: 'Image' } + if (id.endsWith('/LoginGate')) return { LoginGate: 'shared-login-gate' } + if (id.endsWith('/tabNavigation')) return { switchMainTab: (target, active) => routes.push([target, active]) } + if (id.endsWith('/useTiangongSnapshot')) return { useTiangongSnapshot: () => ({}) } + if (id.endsWith('/tiangongMock')) return { hasBoundDeity: () => bound } + if (id.endsWith('/tiangong')) return { deityCatalog: [] } + if (id.endsWith('/frontendPreview')) return { isFrontendPreview: () => false } + if (id.endsWith('/avatarDemo')) return { isAvatarDemo: () => false } + if (id.endsWith('/request')) return { getCachedProfile: () => profile, getToken: () => guest ? '' : 'test' } + if (id.endsWith('/zodiacWeekly')) return { getBeijingDate: () => '2026-10-18', calculateWeeklyZodiacFortune: () => null } + if (id.endsWith('/zodiac')) return { calculateZodiac: () => null } + if (id.endsWith('/fiveElements')) return { calculateFiveElements: () => report } + if (id.endsWith('/homeOverview')) return { HOME_DIMENSION_IDS: [], generateHomeOverview: () => ({ days: [day, day, day], dimensions: [], luckyColors: [], luckyNumbers: [], luckyHours: [] }) } + return {} + } }) + return exports.default() +} + +test('首页和我的未登录入口使用同一登录组件;天宫包装也复用它', () => { + assert.equal(page('src/pages/home/index.tsx', { guest: true }).type, 'shared-login-gate') + assert.equal(page('src/pages/my/index.tsx', { guest: true }).type, 'shared-login-gate') + assert.match(read('src/components/TiangongLoginGate.tsx'), /return { + const routes = [], tree = page('src/pages/home/index.tsx', { routes }) + function find(node) { + if (!node || typeof node !== 'object') return null + if (node.props?.['aria-label'] === '前往我的页面') return node + const children = node.props?.children + return (Array.isArray(children) ? children : [children]).map(find).find(Boolean) + } + const avatar = find(tree) + assert.ok(avatar) + avatar.props.onClick() + assert.deepEqual(routes, [['my', 'home']]) +}) + +test('未绑定首页与元素报告固定首屏;绑定后移除锁定类恢复滚动', () => { + for (const [file, element] of [['src/pages/home/index.tsx', false], ['src/pages/my/index.tsx', true]]) { + const locked = page(file, { bound: false, element }), unlocked = page(file, { bound: true, element }) + assert.match(locked.props.className, /binding-viewport-locked/) + assert.equal(locked.props.catchMove, true) + assert.doesNotMatch(unlocked.props.className, /binding-viewport-locked/) + assert.equal(unlocked.props.catchMove, false) + } + const runner = require.resolve('@tarojs/webpack5-runner') + const sass = require(require.resolve('sass', { paths: [runner] })) + const postcss = require(require.resolve('postcss', { paths: [runner] })) + const css = sass.renderSync({ file: path.join(root, 'src/components/DeityBinding.scss') }).css.toString() + let checked = false + postcss.parse(css).walkRules(rule => { + if (!rule.selector.includes('.home-page.binding-viewport-locked')) return + const declarations = Object.fromEntries(rule.nodes.filter(node => node.type === 'decl').map(node => [node.prop, node.value])) + assert.equal(declarations.position, 'fixed') + assert.equal(declarations.overflow, 'hidden') + assert.equal(declarations.height, '100%') + checked = true + }) + assert.ok(checked) + assert.match(read('src/components/DeityBinding.tsx'), /className='binding-gate' catchMove/) +}) diff --git a/scripts/navigation.test.cjs b/scripts/navigation.test.cjs index f8156a9..5926bca 100644 --- a/scripts/navigation.test.cjs +++ b/scripts/navigation.test.cjs @@ -23,7 +23,7 @@ test('三个主页面均注册为Tab,切换不使用push/redirect', () => { test('连续点击只切换一次,完成或失败后均可继续点击', async () => { const urls = [], notices = [] let complete - const { switchMainTab } = load('src/services/tabNavigation.ts', () => ({ default: { + const { switchMainTab } = load('src/services/tabNavigation.ts', id => id === './loginAccess' ? { hasLoginAccess: () => true } : ({ default: { switchTab: ({ url }) => { urls.push(url); return new Promise(resolve => { complete = resolve }) }, showToast: value => notices.push(value.title) } })) @@ -36,7 +36,7 @@ test('连续点击只切换一次,完成或失败后均可继续点击', async const next = switchMainTab('my', 'fortune') complete(); await next assert.deepEqual(urls, ['/pages/fortune/index', '/pages/my/index']) - const failing = load('src/services/tabNavigation.ts', () => ({ default: { + const failing = load('src/services/tabNavigation.ts', id => id === './loginAccess' ? { hasLoginAccess: () => true } : ({ default: { switchTab: () => Promise.reject(new Error('offline')), showToast: value => notices.push(value.title) } })) await failing.switchMainTab('my') @@ -50,8 +50,10 @@ test('资料缓存合并并发请求,过期保留快照并重取;失败可 const [a,b] = await Promise.all([cache.load('a', loader), cache.load('a', loader)]) assert.equal(a, b); assert.equal(calls, 1) await cache.load('a', loader); assert.equal(calls, 1) + assert.equal(cache.peekFresh('a').nickname, 'A') now = 31 assert.equal(cache.peek('a').nickname, 'A') + assert.equal(cache.peekFresh('a'), undefined) await assert.rejects(cache.load('a', async () => { throw new Error('offline') }), /offline/) await cache.load('a', loader); assert.equal(calls, 2) await cache.load('a', loader, true); assert.equal(calls, 3) @@ -65,6 +67,7 @@ test('保存后的新资料不会被旧请求覆盖,不同账号不共用缓 finish({ nickname: 'Old' }) assert.equal((await old).nickname, 'New') assert.equal(cache.peek('b'), undefined) + assert.equal(cache.peekFresh('b'), undefined) let resolveA const readA = cache.load('a', () => new Promise(resolve => { resolveA = resolve })) await Promise.resolve() diff --git a/scripts/tiangong.test.cjs b/scripts/tiangong.test.cjs index b7285f9..50c1bb1 100644 --- a/scripts/tiangong.test.cjs +++ b/scripts/tiangong.test.cjs @@ -7,6 +7,10 @@ const ts = require('typescript') const root = path.resolve(__dirname, '..') const filename = path.join(root, 'src/utils/tiangongMock.ts') const loaded = new Module(filename, module) +const ritualFilename = path.join(root, 'src/utils/incenseRitual.ts') +const ritual = new Module(ritualFilename, module) +ritual._compile(ts.transpileModule(fs.readFileSync(ritualFilename, 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2019 } }).outputText, ritualFilename) +loaded.require = id => id === './incenseRitual' ? ritual.exports : Module.prototype.require.call(loaded, id) loaded._compile(ts.transpileModule(fs.readFileSync(filename, 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2019 } }).outputText, filename) const { createTiangongMock, DEITY_IDS, isDeityId, hasBoundDeity } = loaded.exports @@ -15,7 +19,7 @@ test('天宫仅财神初始已恭请,五位神仙独立且状态快照隔离', const first = mock.snapshot() assert.equal(DEITY_IDS.length, 5) assert.deepEqual(DEITY_IDS.filter(id => first.deities[id].unlocked), ['wealth']) - assert.equal(first.balance, 100) + assert.equal('balance' in first, false) first.deities.wealth.incenseCount = 999 assert.equal(mock.snapshot().deities.wealth.incenseCount, 59) assert.equal(isDeityId('not-a-god'), false) @@ -25,8 +29,7 @@ test('上香点亮供桌次数+1,不猜测爱心扣减与功德奖励', () => const state = mock.incense('wealth') assert.equal(state.deities.wealth.incenseCount, 60) assert.equal(state.deities.wealth.lit, true) - assert.equal(state.deities.wealth.merit, 99) - assert.equal(state.balance, 100) + assert.equal('merit' in state.deities.wealth, false) assert.throws(() => mock.incense('fortune'), /未恭请/) }) test('模拟恭请和送神只改变指定神仙,返回列表状态可同步', () => { @@ -38,16 +41,14 @@ test('模拟恭请和送神只改变指定神仙,返回列表状态可同步', assert.equal(mock.snapshot().deities.fortune.unlocked, false) assert.equal(mock.snapshot().deities.fortune.lit, false) assert.equal(mock.snapshot().deities.wealth.unlocked, true) - assert.equal(mock.snapshot().balance, 100) }) -test('档位、非法金额与余额不足可以验证,但不执行任何真实扣款', () => { +test('本期无功德、余额、捐款接口或入口', () => { const mock = createTiangongMock() - assert.equal(mock.offering('wealth', 99).insufficient, false) - assert.equal(mock.offering('wealth', 168).insufficient, true) - assert.equal(mock.snapshot().balance, 100) - for (const value of [0, -1, 1.5, NaN, Infinity]) assert.throws(() => mock.offering('wealth', value), /正整数/) - mock.setDemoBalance(0) - assert.equal(mock.offering('wealth', 9).insufficient, true) + assert.equal(mock.offering, undefined) + assert.equal(mock.setDemoBalance, undefined) + for (const page of ['fortune', 'tiangong-detail']) { + assert.doesNotMatch(fs.readFileSync(path.join(root, `src/pages/${page}/index.tsx`), 'utf8'), /功德|爱心|充值|balance|merit/) + } }) test('祈愿/回向校验空值,不持久化姓名或祝愿;重置还原样例', () => { const mock = createTiangongMock() @@ -59,9 +60,8 @@ test('祈愿/回向校验空值,不持久化姓名或祝愿;重置还原样 mock.invite('fortune') mock.reset() assert.equal(mock.snapshot().deities.fortune.unlocked, false) - assert.equal(mock.snapshot().balance, 100) }) -test('未知神仙不回退为财神,页面只导入Mock;预留契约不被调用', () => { +test('未知神仙不回退为财神,页面通过服务层调用,未接入契约不被调用', () => { const service = fs.readFileSync(path.join(root, 'src/services/tiangong.ts'), 'utf8') assert.ok(!service.includes("id || 'wealth'")) for (const page of ['fortune', 'tiangong-detail']) { diff --git a/scripts/tiangongAccess.test.cjs b/scripts/tiangongAccess.test.cjs new file mode 100644 index 0000000..bcef72b --- /dev/null +++ b/scripts/tiangongAccess.test.cjs @@ -0,0 +1,214 @@ +const test = require('node:test') +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') +const vm = require('node:vm') +const ts = require('typescript') +const root = path.resolve(__dirname, '..') +const flush = () => new Promise(resolve => setImmediate(resolve)) +function load(file, requireImpl, env = 'weapp') { + const exports = {} + const code = ts.transpileModule(fs.readFileSync(path.join(root, file), 'utf8'), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2019, jsx: ts.JsxEmit.ReactJSX } + }).outputText + vm.runInNewContext(code, { exports, require: requireImpl, process: { env: { TARO_ENV: env } } }) + return exports +} + +test('未登录点击天宫不切换Tab,连续点击只打开一次登录页,其他Tab仍可访问', async () => { + const routes = [] + let complete, loggedIn = false + const { switchMainTab } = load('src/services/tabNavigation.ts', id => id === './loginAccess' ? { + hasLoginAccess: () => loggedIn, + openLogin: () => { routes.push('login'); return new Promise(resolve => { complete = resolve }) } + } : { default: { switchTab: async ({ url }) => routes.push(url), showToast: () => {} } }) + const first = switchMainTab('fortune', 'my') + await switchMainTab('fortune', 'my') + assert.deepEqual(routes, ['login']) + complete(); await first + await switchMainTab('home', 'my') + await switchMainTab('my', 'home') + assert.deepEqual(routes, ['login', '/pages/home/index', '/pages/my/index']) + loggedIn = true + await switchMainTab('fortune', 'my') + assert.equal(routes.at(-1), '/pages/fortune/index') +}) + +function entryHarness(env = 'weapp') { + let token = '', preview = false, demo = false, response = { phoneBound: true, token: 'test', user: { id: 1 } }, failure, resolveLogin + const calls = [], routes = [], accepted = [], toasts = [] + const service = load('src/services/loginAccess.ts', id => { + if (id === './request') return { getToken: () => token, api: { wxLogin: async code => { calls.push(code); if (failure) throw new Error('请求失败'); return response } } } + if (id === './account') return { finishAccountLogin: async session => { accepted.push(session) } } + if (id === './frontendPreview') return { isFrontendPreview: () => preview } + if (id === './avatarDemo') return { isAvatarDemo: () => demo } + return { default: { + login: () => new Promise(resolve => { resolveLogin = resolve }), + navigateTo: async ({ url }) => { routes.push(url) }, + showLoading: () => {}, hideLoading: () => {}, showToast: data => toasts.push(data.title) + } } + }, env) + return { service, calls, routes, accepted, toasts, complete: code => resolveLogin({ code }), + token: value => { token = value }, preview: value => { preview = value }, demo: value => { demo = value }, + response: value => { response = value }, fail: value => { failure = value } } +} + +test('入口一次点击直接微信登录、去重;已注册不跳品牌页,失败可重试', async () => { + const h = entryHarness(), { service } = h + assert.equal(service.hasLoginAccess(), false) + h.token('session'); assert.equal(service.hasLoginAccess(), true) + h.token(''); h.preview(true); assert.equal(service.hasLoginAccess(), true) + h.preview(false); h.demo(true); assert.equal(service.hasLoginAccess(), true) + h.demo(false) + const first = service.openLogin(), second = service.openLogin() + assert.equal(first, second) + await flush(); h.complete('wx-test-code'); await first + assert.deepEqual(h.calls, ['wx-test-code']) + assert.equal(h.accepted.length, 1) + assert.equal(h.routes.length, 0) + h.fail(true) + const failed = service.openLogin(); await flush(); h.complete('failed'); await failed + assert.equal(h.toasts.length, 1) + h.fail(false) + const retry = service.openLogin(); await flush(); h.complete('retry'); await retry + assert.equal(h.accepted.length, 2) +}) + +test('新注册仅进入必要的手机号表单,临时会话留内存、不放入URL', async () => { + const h = entryHarness() + h.response({ phoneBound: false, sessionKey: 'temporary-test-session' }) + const attempt = h.service.openLogin(); await flush(); h.complete('new-user'); await attempt + assert.equal(h.service.getRegistrationSession(), 'temporary-test-session') + assert.deepEqual(h.routes, ['/pages/login/index']) + assert.equal(h.accepted.length, 0) + h.service.clearRegistrationSession() + assert.equal(h.service.getRegistrationSession(), '') +}) + +test('H5和演示环境不伪造登录;缺少code或token不接受身份', async () => { + const web = entryHarness('h5') + await web.service.openLogin() + assert.equal(web.routes.length, 0); assert.equal(web.calls.length, 0) + assert.match(web.toasts[0], /微信开发者工具/) + const h = entryHarness() + h.preview(true); await h.service.openLogin(); assert.equal(h.calls.length, 0) + h.preview(false) + const noCode = h.service.openLogin(); await flush(); h.complete(''); await noCode + assert.equal(h.calls.length, 0) + h.response({ phoneBound: true }) + const invalid = h.service.openLogin(); await flush(); h.complete('incomplete'); await invalid + assert.equal(h.accepted.length, 0) +}) + +function authHarness(initialToken = '', sandbox = false, fresh = false) { + let token = initialToken, stateIndex = 0, refIndex = 0, initialized = false, show, hide, listener, cleanup + let freshToken = fresh ? initialToken : '' + const states = [], refs = [] + let requests = 0, complete, fail + const { useRequiredLogin } = load('src/hooks/useRequiredLogin.ts', id => { + if (id === 'react') return { + useState: initial => { const index = stateIndex++; if (!(index in states)) states[index] = typeof initial === 'function' ? initial() : initial; return [states[index], value => { states[index] = value }] }, + useRef: initial => { const index = refIndex++; return refs[index] || (refs[index] = { current: initial }) }, + useEffect: effect => { if (!initialized) { cleanup = effect(); initialized = true } } + } + if (id === '@tarojs/taro') return { useDidShow: callback => { show = callback }, useDidHide: callback => { hide = callback } } + if (id === '../services/frontendPreview') return { isFrontendPreview: () => sandbox } + if (id === '../services/avatarDemo') return { isAvatarDemo: () => false } + return { + getToken: () => token, + getFreshCachedProfile: () => fresh && token === freshToken ? { id: 1 } : null, + subscribeProfile: callback => { listener = callback; return () => { listener = undefined } }, + api: { profile: () => { requests++; return new Promise((resolve, reject) => { complete = resolve; fail = reject }) } } + } + }) + const render = () => { stateIndex = 0; refIndex = 0; return useRequiredLogin() } + render() + return { render, show: () => show(), hide: () => hide(), resolve: () => complete({ id: 1 }), reject: () => fail(new Error('offline')), + emit: value => listener?.(value), setToken: value => { token = value }, setFresh: value => { fresh = value; freshToken = value ? token : '' }, count: () => requests, unmount: () => cleanup() } +} + +test('同账号有效登录缓存直接允许进入,前后台切换不再闪检查页或重复请求', async () => { + const auth = authHarness('session', false, true) + assert.equal(auth.render().status, 'allowed') + auth.show(); assert.equal(auth.render().status, 'allowed'); assert.equal(auth.count(), 0) + auth.hide(); auth.show(); assert.equal(auth.render().status, 'allowed'); assert.equal(auth.count(), 0) + auth.setFresh(false) + auth.show(); assert.equal(auth.render().status, 'checking'); assert.equal(auth.count(), 1) + auth.reject(); await flush(); assert.equal(auth.render().status, 'error') +}) + +test('缓存页面切换账号或退出登录时,不复用旧账号已允许的状态', () => { + const auth = authHarness('first-session', false, true) + assert.equal(auth.render().status, 'allowed') + auth.hide(); auth.setToken('second-session') + assert.equal(auth.render().status, 'checking') + auth.setToken('') + assert.equal(auth.render().status, 'denied') +}) + +test('未登录不发资料请求;已登录必须校验资料才显示内容,401后立即隐藏', async () => { + const guest = authHarness() + guest.show(); assert.equal(guest.render().status, 'denied'); assert.equal(guest.count(), 0) + const auth = authHarness('session') + assert.equal(auth.render().status, 'checking') + auth.show(); assert.equal(auth.render().status, 'checking') + auth.emit({ id: 1 }); auth.resolve(); await flush(); assert.equal(auth.render().status, 'allowed') + auth.setToken(''); auth.emit(null) + assert.equal(auth.render().status, 'denied') + const demo = authHarness('', true) + demo.show(); assert.equal(demo.render().status, 'allowed'); assert.equal(demo.count(), 0) +}) + +test('网络错误保持登录但不显示内容,可重试;失效token不能通过校验', async () => { + const auth = authHarness('session') + auth.show(); auth.reject(); await flush() + assert.equal(auth.render().status, 'error') + auth.render().retry(); assert.equal(auth.render().status, 'checking') + auth.resolve(); await flush(); assert.equal(auth.render().status, 'allowed') + const expired = authHarness('expired') + expired.show(); expired.setToken(''); expired.emit(null); expired.reject(); await flush() + assert.equal(expired.render().status, 'denied') +}) + +test('页面隐藏或卸载后,旧校验结果不会恢复内容权限', async () => { + const hidden = authHarness('session') + hidden.show(); hidden.hide(); hidden.emit({ id: 1 }); hidden.resolve(); await flush() + assert.equal(hidden.render().status, 'checking') + const unmounted = authHarness('session') + unmounted.show(); unmounted.unmount(); unmounted.resolve(); await flush() + assert.equal(unmounted.render().status, 'checking') +}) + +test('直达天宫和详情页先渲染登录守卫,未验证时不挂载神仙列表或详情', () => { + for (const file of ['src/pages/fortune/index.tsx', 'src/pages/tiangong-detail/index.tsx']) { + let status = 'denied', session = false, contentReads = 0 + const page = load(file, id => { + if (id === 'react' || id === 'react/jsx-runtime') return require(id) + if (id.endsWith('/useRequiredLogin')) return { useRequiredLogin: () => ({ status, retry: () => {} }) } + if (id.endsWith('/loginAccess')) return { hasLoginAccess: () => session } + if (id.endsWith('/TiangongLoginGate')) return { TiangongLoginGate: 'login-gate' } + if (id.endsWith('/useTiangongSnapshot')) return { useTiangongSnapshot: () => { contentReads++; return {} } } + if (id.endsWith('/tiangong')) return { getDeity: () => { contentReads++; return {} } } + return {} + }) + for (const next of ['denied', 'checking', 'error', 'allowed']) { + status = next + assert.equal(page.default().type, 'login-gate') + } + assert.equal(contentReads, 0) + session = true; status = 'checking'; assert.equal(page.default().type, 'login-gate') + status = 'allowed'; assert.equal(typeof page.default().type, 'function') + } +}) + +test('必要校验只显示轻量场景占位,不出现整页登录检查提示', () => { + const gate = load('src/components/TiangongLoginGate.tsx', id => { + if (id === 'react/jsx-runtime') return require(id) + if (id === './TiangongPending') return { TiangongPending: 'pending-scene' } + if (id === './LoginGate') return { LoginGate: 'login-gate' } + return {} + }) + assert.equal(gate.TiangongLoginGate({ status: 'checking', retry: () => {} }).type, 'pending-scene') + assert.equal(gate.TiangongLoginGate({ status: 'denied', retry: () => {} }).type, 'login-gate') + assert.equal(gate.TiangongLoginGate({ status: 'error', retry: () => {} }).type, 'login-gate') +}) diff --git a/scripts/tiangongStore.test.cjs b/scripts/tiangongStore.test.cjs new file mode 100644 index 0000000..2d1efa1 --- /dev/null +++ b/scripts/tiangongStore.test.cjs @@ -0,0 +1,91 @@ +const test = require('node:test') +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') +const vm = require('node:vm') +const ts = require('typescript') +function load(file, deps = {}) { + const exports = {} + vm.runInNewContext(ts.transpileModule(fs.readFileSync(path.join(__dirname, '..', file), 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2019 } }).outputText, { exports, require: id => deps[id], setTimeout, clearTimeout }) + return exports +} +const ritual = load('src/utils/incenseRitual.ts') +const mock = load('src/utils/tiangongMock.ts', { './incenseRitual': ritual }) +const { createTiangongStore } = load('src/utils/tiangongStore.ts', { './tiangongMock': mock, './incenseRitual': ritual }) +const defer = () => { let resolve, reject; const promise = new Promise((ok, fail) => { resolve = ok; reject = fail }); return { promise, resolve, reject } } +function harness() { + let key = 'account-a', time = 1000, rows = [{ id: 81, name: '财神', holdingId: 726, active: true, incenseCount: 7, days: 3 }] + let loader = async () => rows, offer = async () => ({ id: 99 }) + const calls = [] + const store = createTiangongStore({ key: () => key, now: () => time, resolve: name => name === '财神' ? 'wealth' : undefined, + load: () => loader(), offer: (...args) => { calls.push(args); return offer(...args) }, dedicate: async (...args) => { calls.push(args); return { id: 100 } } }) + return { store, calls, key: value => { key = value }, time: value => { time = value }, rows: value => { rows = value }, load: value => { loader = value }, offer: value => { offer = value } } +} +test('真实状态不预置财神,按后端持有ID提交,后端确认后才计数', async () => { + const h = harness() + assert.equal(h.store.snapshot().deities.wealth.unlocked, false) + await h.store.refresh() + const response = defer(); h.offer(() => response.promise) + const request = h.store.incense('wealth') + assert.equal(h.store.snapshot().deities.wealth.incenseCount, 7) + assert.equal(h.store.snapshot().deities.wealth.lit, false) + assert.equal(h.calls[0][0], 726); assert.equal(h.calls[0][1], 2) + response.resolve({ id: 10 }); await request + assert.equal(h.store.snapshot().deities.wealth.incenseCount, 8) + assert.equal(h.store.snapshot().deities.wealth.incenseUntil, 16000) +}) +test('失败无计数无自动重试;晚到查询不覆盖已确认上香', async () => { + const h = harness(); await h.store.refresh() + h.offer(async () => { throw new Error('网络失败') }) + await assert.rejects(h.store.incense('wealth'), /网络失败/) + assert.equal(h.calls.length, 1); assert.equal(h.store.snapshot().deities.wealth.incenseCount, 7) + const read = defer(); h.load(() => read.promise) + const refreshing = h.store.refresh(true) + h.offer(async () => ({ id: 1 })); await h.store.incense('wealth') + read.resolve([{ name: '财神', holdingId: 726, active: true, incenseCount: 7 }]); await refreshing + assert.equal(h.store.snapshot().deities.wealth.incenseCount, 8) +}) +test('乱序并发响应逐次累计,不让旧点击缩短最后截止时间', async () => { + const h = harness(); await h.store.refresh() + const a = defer(), b = defer(); let count = 0 + h.offer(() => (++count === 1 ? a : b).promise) + const first = h.store.incense('wealth'); h.time(4000); const second = h.store.incense('wealth') + b.resolve({ id: 2 }); assert.equal((await second).startsRound, true) + a.resolve({ id: 1 }); assert.equal((await first).startsRound, false) + assert.equal(h.store.snapshot().deities.wealth.incenseCount, 9) + assert.equal(h.store.snapshot().deities.wealth.incenseUntil, 19000) + h.time(19001); assert.equal(h.store.snapshot().deities.wealth.lit, false) +}) + +test('写入在途不采纳可能已包含该流水的GET,避免回包后再次加一', async () => { + const h = harness(); await h.store.refresh() + const response = defer(), read = defer() + h.load(() => read.promise) + const refreshing = h.store.refresh(true) + h.offer(() => response.promise); const sending = h.store.incense('wealth') + read.resolve([{ name: '财神', holdingId: 726, active: true, incenseCount: 8 }]); await refreshing + assert.equal(h.store.snapshot().deities.wealth.incenseCount, 7) + await h.store.refresh(true) + response.resolve({ id: 2 }); await sending + assert.equal(h.store.snapshot().deities.wealth.incenseCount, 8) +}) +test('切换账号丢弃旧响应,不以Mock兜底;未绑定不可提交', async () => { + const h = harness(); await h.store.refresh() + const response = defer(); h.offer(() => response.promise) + const sending = h.store.incense('wealth') + h.key('account-b'); assert.equal(h.store.snapshot().deities.wealth.unlocked, false) + response.resolve({ id: 10 }); await assert.rejects(sending, /登录状态/) + h.rows([]); await h.store.refresh() + await assert.rejects(h.store.incense('wealth'), /绑定/) + h.load(async () => { throw new Error('未部署') }); await assert.rejects(h.store.refresh(true), /未部署/) + assert.equal(h.store.snapshot().deities.wealth.unlocked, false) +}) +test('祈愿和任意姓名回向去空白提交,不携带功德或注册收件人ID', async () => { + const h = harness(); await h.store.refresh() + await h.store.wish('wealth', ' 愿顺遂 ') + await h.store.dedicate('wealth', ' 家人 ', ' 平安 ') + assert.deepEqual(h.calls, [[726, 3, '愿顺遂'], [726, '家人', '平安']]) + await assert.rejects(h.store.wish('wealth', ' '), /愿望/) + await assert.rejects(h.store.dedicate('wealth', '', '祝愿'), /回向对象/) + assert.equal(h.calls.length, 2) +}) diff --git a/src/assets/tiangong/incense-yinqing.m4a b/src/assets/tiangong/incense-yinqing.m4a new file mode 100644 index 0000000..fa8e0fa Binary files /dev/null and b/src/assets/tiangong/incense-yinqing.m4a differ diff --git a/src/components/BirthdayPicker.tsx b/src/components/BirthdayPicker.tsx new file mode 100644 index 0000000..9106ce9 --- /dev/null +++ b/src/components/BirthdayPicker.tsx @@ -0,0 +1,38 @@ +import { useEffect, useMemo, useState } from 'react' +import { Button, Picker, Text, View } from '@tarojs/components' +import { beijingToday, BirthdayCalendar, changeLunarBirthdayColumn, formatLunarBirthday, lunarBirthdayModel, lunarBirthdayToSolar, MIN_BIRTHDAY, solarToLunarBirthday } from '../utils/birthdayCalendar' + +// Both registration and editing use the same form and this controlled picker. +// The saved birthday is always solar; calendar is a separate persisted preference. +export function BirthdayPicker({ value, calendar, onChange, onCalendarChange }: { + value: string; calendar: BirthdayCalendar; onChange: (solar: string) => void; onCalendarChange: (calendar: BirthdayCalendar) => void +}) { + const today = beijingToday() + const initial = () => solarToLunarBirthday(value) || solarToLunarBirthday(today)! + const [draft, setDraft] = useState(initial) + useEffect(() => { setDraft(initial()) }, [value]) + const model = useMemo(() => lunarBirthdayModel(draft, today), [draft.year, draft.month, draft.day, today]) + const solarLabel = value ? value.replace(/-/g, ' / ') : '' + const lunarLabel = formatLunarBirthday(value) + const field = + 出生日期 + {value ? calendar === 'lunar' ? lunarLabel : solarLabel : '请选择'} + › + + return + + {(['solar', 'lunar'] as const).map(type => )} + + {calendar === 'solar' ? onChange(String(event.detail.value))}>{field} + : setDraft(changeLunarBirthdayColumn(model, Number(event.detail.column), Number(event.detail.value), today))} + onCancel={() => setDraft(initial())} + onChange={event => { + const [y, m, d] = event.detail.value as number[] + const selected = { year: model.years[y], month: model.months[m]?.value, day: model.days[d] } + const solar = lunarBirthdayToSolar(selected) + if (solar && solar >= MIN_BIRTHDAY && solar <= today) onChange(solar) + }}>{field}} + {value && {calendar === 'lunar' ? `对应阳历:${solarLabel}` : `对应农历:${lunarLabel}`}} + +} diff --git a/src/components/DeityBinding.scss b/src/components/DeityBinding.scss index 7f98c36..0d864d0 100644 --- a/src/components/DeityBinding.scss +++ b/src/components/DeityBinding.scss @@ -48,6 +48,12 @@ .binding-gate-content > text { display: block; font-size: 30px; line-height: 1.8; } .binding-gate-actions { display: flex; justify-content: space-between; gap: 28px; margin: 100px 14px 0; } .binding-gate-actions > button { flex: 1; width: auto; padding-left: 14px; padding-right: 14px; white-space: nowrap; } +// A fixed, clipped viewport removes the long blurred report from page scroll. +// Binding dialogs remain separate fixed layers and keep their own interactions. +.home-page.binding-viewport-locked, .my-page.binding-viewport-locked { + position: fixed; inset: 0; width: 100%; height: 100%; min-height: 0; + overflow: hidden; overscroll-behavior: none; +} .binding-lock { position: relative; width: 48px; height: 43px; margin: 0 auto 60px; border-radius: 10px; background: #675088; } .binding-lock::before { content: ''; position: absolute; width: 29px; height: 29px; border: 6px solid #675088; border-bottom: 0; box-sizing: border-box; border-radius: 22px 22px 0 0; left: 9px; top: -23px; } .binding-lock > view { position: absolute; left: 20px; top: 13px; width: 9px; height: 16px; border-radius: 8px; background: #f0dfff; } diff --git a/src/components/DeityBinding.tsx b/src/components/DeityBinding.tsx index 7242314..9274142 100644 --- a/src/components/DeityBinding.tsx +++ b/src/components/DeityBinding.tsx @@ -1,13 +1,13 @@ import { useRef, useState } from 'react' import Taro from '@tarojs/taro' import { Button, Image, Text, View } from '@tarojs/components' -import { deityCatalog, getDeity, tiangongMock } from '../services/tiangong' +import { deityCatalog, getDeity, isTiangongDemo, tiangongMock } from '../services/tiangong' import { DeityId } from '../utils/tiangongMock' import { useTiangongSnapshot } from '../hooks/useTiangongSnapshot' import './DeityBinding.scss' export function BindingGate({ onBind, lock = true }: { onBind: () => void; lock?: boolean }) { - return + return {lock && } 须绑定神仙开启你的守护之旅 @@ -27,6 +27,12 @@ export function DeityCardDialog({ deityId, mode = 'view', onClose }: { deityId: const [confirmUnbind, setConfirmUnbind] = useState(false) const [error, setError] = useState('') const working = useRef(false) + if (!isTiangongDemo()) return + {deity.name} + + {state.unlocked ? `已守护 ${state.days} 天 · 持有记录 ${state.holdingId}` : '请通过 NFC 绑定神仙。此处不模拟建立真实绑定关系。'} + + const perform = (action: () => void) => { if (working.current) return working.current = true @@ -82,6 +88,7 @@ export function DeityCardDialog({ deityId, mode = 'view', onClose }: { deityId: export function DeityBindingFlow({ initialDeity, onClose }: { initialDeity?: DeityId; onClose: () => void }) { const [selected, setSelected] = useState(initialDeity || null) const snapshot = useTiangongSnapshot() + if (!isTiangongDemo()) return 碰一碰,绑定神仙请通过 NFC 绑定神仙,本次不在页面中模拟真实设备绑定。 if (selected) return const available = deityCatalog.filter(item => !snapshot.deities[item.id].unlocked) return diff --git a/src/components/LoginGate.tsx b/src/components/LoginGate.tsx new file mode 100644 index 0000000..cb9c990 --- /dev/null +++ b/src/components/LoginGate.tsx @@ -0,0 +1,18 @@ +import { Button, Text, View } from '@tarojs/components' +import { openLogin } from '../services/loginAccess' +import { MainTab, switchMainTab } from '../services/tabNavigation' +import type { LoginAccessStatus } from '../hooks/useRequiredLogin' +import { AppTabBar } from './AppTabBar' +import './TiangongLoginGate.scss' + +export function LoginGate({ status = 'denied', retry = () => {}, active }: { status?: LoginAccessStatus; retry?: () => void; active?: MainTab }) { + return + + {status === 'checking' ? '正在确认登录状态…' : status === 'error' ? '登录状态暂时无法确认' : '登录后开启守护之旅'} + {status === 'checking' ? '请稍候' : status === 'error' ? '请检查网络后重试。' : '微信登录 / 注册,查看你的专属内容'} + {status !== 'checking' && } + {!active && } + + {active && } + +} diff --git a/src/components/TiangongLoginGate.scss b/src/components/TiangongLoginGate.scss new file mode 100644 index 0000000..096c838 --- /dev/null +++ b/src/components/TiangongLoginGate.scss @@ -0,0 +1,20 @@ +.tiangong-login-gate { + position: fixed; + inset: 0; + overflow: hidden; + overscroll-behavior: none; + box-sizing: border-box; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: calc(40px + env(safe-area-inset-top)) 44px calc(210px + env(safe-area-inset-bottom)); + background: linear-gradient(180deg,#c4b0ff,#e9e1fa 48%,#edf4ff); + color: #4c3b6b; + + .tiangong-login-card { width: 100%; max-width: 600px; box-sizing: border-box; padding: 58px 34px; border-radius: 32px; background: rgba(255,255,255,.78); text-align: center; } + .tiangong-login-title { display: block; font-size: 34px; font-weight: 700; line-height: 1.5; } + .tiangong-login-copy { display: block; margin-top: 18px; color: #8c7c9f; font-size: 25px; line-height: 1.8; } + .tiangong-login-action { width: 100%; margin: 34px 0 0; padding: 24px 12px; border-radius: 48px; color: #fff; font-size: 28px; line-height: 1.4; background: linear-gradient(135deg,#a984ed,#8d6acb); } + .tiangong-login-home { margin-top: 24px; padding: 14px; color: #8c7c9f; font-size: 25px; line-height: 1.5; } +} diff --git a/src/components/TiangongLoginGate.tsx b/src/components/TiangongLoginGate.tsx new file mode 100644 index 0000000..070f733 --- /dev/null +++ b/src/components/TiangongLoginGate.tsx @@ -0,0 +1,8 @@ +import type { LoginAccessStatus } from '../hooks/useRequiredLogin' +import { LoginGate } from './LoginGate' +import { TiangongPending } from './TiangongPending' + +export function TiangongLoginGate({ status, retry, tab = false }: { status: LoginAccessStatus; retry: () => void; tab?: boolean }) { + if (status === 'checking') return + return +} diff --git a/src/components/TiangongPending.scss b/src/components/TiangongPending.scss new file mode 100644 index 0000000..e506b28 --- /dev/null +++ b/src/components/TiangongPending.scss @@ -0,0 +1,7 @@ +.tg-pending { + min-height: 100vh; box-sizing: border-box; background: #faf2d4; color: #314337; + .tg-pending-content { min-height: 68vh; padding: 12vh 42px 42px; box-sizing: border-box; display: flex; flex-direction: column; justify-content: flex-end; align-items: center; gap: 30px; } + .tg-pending-silhouette { width: 48%; height: 32vh; border-radius: 48% 48% 24px 24px; background: rgba(172,160,123,.08); } + .tg-pending-base { width: 90%; height: 14vh; border-radius: 18px; background: rgba(172,160,123,.08); } + &.catalog { background: linear-gradient(180deg,#bda5fa,#edf4ff); padding-bottom: calc(190px + env(safe-area-inset-bottom)); } +} diff --git a/src/components/TiangongPending.tsx b/src/components/TiangongPending.tsx new file mode 100644 index 0000000..90ea046 --- /dev/null +++ b/src/components/TiangongPending.tsx @@ -0,0 +1,17 @@ +import Taro from '@tarojs/taro' +import { View } from '@tarojs/components' +import { AppTabBar } from './AppTabBar' +import { TiangongHeader } from './TiangongUI' +import './TiangongPending.scss' + +// No deity data or business controls mount before authentication is confirmed. +export function TiangongPending({ tab = false }: { tab?: boolean }) { + const back = () => Taro.getCurrentPages().length > 1 ? Taro.navigateBack() : Taro.switchTab({ url: '/pages/fortune/index' }) + return + + } -export function TiangongMockNote() { return Mock 原型演示 · 不扣费、不连接设备 · 刷新重置 } +export function TiangongMockNote() { return isTiangongDemo() ? Mock 原型演示 · 不扣费、不连接设备 · 刷新重置 : null } diff --git a/src/hooks/useIncenseRitual.ts b/src/hooks/useIncenseRitual.ts new file mode 100644 index 0000000..ce0db6f --- /dev/null +++ b/src/hooks/useIncenseRitual.ts @@ -0,0 +1,65 @@ +import { useEffect, useRef, useState } from 'react' +import Taro, { useDidHide, useDidShow } from '@tarojs/taro' +import { tiangongSession } from '../services/tiangong' +import { DeityId } from '../utils/tiangongMock' +import { createIncenseRitual } from '../utils/incenseRitual' +import sound from '../assets/tiangong/incense-yinqing.m4a' + +const SOUND_KEY = 'tiangong.incense-sound.enabled.v1' +function readSoundPreference() { + try { return Taro.getStorageSync(SOUND_KEY) !== false } catch { return true } +} + +export function useIncenseRitual(deityId?: DeityId) { + const [soundEnabled, setSoundEnabled] = useState(readSoundPreference) + const enabled = useRef(soundEnabled) + const mounted = useRef(true) + const [pending, setPending] = useState(0) + const ritual = useRef | null>(null) + const getRitual = () => { + if (!deityId) return null + if (!ritual.current) ritual.current = createIncenseRitual({ + getDeadline: () => tiangongSession.snapshot().deities[deityId].incenseUntil, + recordIncense: () => tiangongSession.incense(deityId), + refresh: () => { tiangongSession.refreshIncense(deityId) }, + soundEnabled: () => enabled.current, + createAudio: () => { + const context = Taro.createInnerAudioContext() + context.autoplay = false + context.loop = false + context.src = process.env.TARO_ENV === 'weapp' && !sound.startsWith('/') ? `/${sound}` : sound + return context + }, + onAudioError: () => { Taro.showToast({ title: '音频暂时无法播放,上香次数已记录', icon: 'none' }) } + }) + return ritual.current + } + const leave = () => { ritual.current?.dispose(); ritual.current = null } + useDidShow(() => { + enabled.current = readSoundPreference() + setSoundEnabled(enabled.current) + getRitual()?.sync() + }) + useDidHide(leave) + useEffect(() => { mounted.current = true; return () => { mounted.current = false; leave() } }, [deityId]) + + return { + soundEnabled, + pending, + toggleSound: () => { + const next = !enabled.current + enabled.current = next + setSoundEnabled(next) + if (!next) ritual.current?.mute() + else ritual.current?.resume() + try { Taro.setStorageSync(SOUND_KEY, next) } + catch { Taro.showToast({ title: '开关本次生效,未能保存到本机', icon: 'none' }) } + }, + incense: async () => { + setPending(value => value + 1) + try { await getRitual()?.tap() } + catch (error) { Taro.showToast({ title: error instanceof Error ? error.message : '上香提交失败,请稍后重试', icon: 'none' }) } + finally { if (mounted.current) setPending(value => Math.max(0, value - 1)) } + } + } +} diff --git a/src/hooks/useRequiredLogin.ts b/src/hooks/useRequiredLogin.ts new file mode 100644 index 0000000..699e9ca --- /dev/null +++ b/src/hooks/useRequiredLogin.ts @@ -0,0 +1,50 @@ +import { useEffect, useRef, useState } from 'react' +import { useDidHide, useDidShow } from '@tarojs/taro' +import { api, getFreshCachedProfile, getToken, subscribeProfile } from '../services/request' +import { isFrontendPreview } from '../services/frontendPreview' +import { isAvatarDemo } from '../services/avatarDemo' + +export type LoginAccessStatus = 'checking' | 'allowed' | 'denied' | 'error' +const sandbox = () => isFrontendPreview() || isAvatarDemo() +const initialStatus = (): LoginAccessStatus => sandbox() ? 'allowed' : !getToken() ? 'denied' : getFreshCachedProfile() ? 'allowed' : 'checking' + +// Presence of a stored token is not proof of a valid login. Confirm through the +// shared authenticated profile cache; 401 clears all page access immediately. +export function useRequiredLogin() { + const [status, setStatus] = useState(initialStatus) + const revision = useRef(0) + const active = useRef(true) + const verifiedToken = useRef(status === 'allowed' ? getToken() : '') + const retry = () => { + const version = ++revision.current + const next = initialStatus() + const token = getToken() + verifiedToken.current = next === 'allowed' ? token : '' + setStatus(next) + if (next !== 'checking') return + api.profile().then(() => { + if (active.current && version === revision.current && token === getToken()) { + verifiedToken.current = token + setStatus('allowed') + } + }).catch(() => { + if (active.current && version === revision.current) setStatus(getToken() ? 'error' : 'denied') + }) + } + useDidShow(() => { active.current = true; retry() }) + useDidHide(() => { active.current = false; revision.current++ }) + useEffect(() => { + active.current = true + const unsubscribe = subscribeProfile(profile => { + if (!active.current || sandbox()) return + revision.current++ + verifiedToken.current = profile && getToken() ? getToken() : '' + setStatus(profile && getToken() ? 'allowed' : 'denied') + }) + return () => { active.current = false; revision.current++; unsubscribe() } + }, []) + // A cached tab may render once before useDidShow: never reuse another token's + // allowed state, even when both accounts have non-empty stored credentials. + const visibleStatus = !sandbox() && status === 'allowed' && verifiedToken.current !== getToken() ? initialStatus() : status + return { status: visibleStatus, retry } +} diff --git a/src/hooks/useTiangongSnapshot.ts b/src/hooks/useTiangongSnapshot.ts index a585133..4d4823b 100644 --- a/src/hooks/useTiangongSnapshot.ts +++ b/src/hooks/useTiangongSnapshot.ts @@ -1,16 +1,20 @@ import { useEffect, useState } from 'react' import { useDidShow } from '@tarojs/taro' -import { tiangongMock } from '../services/tiangong' +import { tiangongSession } from '../services/tiangong' // Cached Tab pages also react immediately when a dialog binds/unbinds a deity. export function useTiangongSnapshot() { - const [snapshot, setSnapshot] = useState(tiangongMock.snapshot) + const [snapshot, setSnapshot] = useState(tiangongSession.snapshot) useEffect(() => { - const refresh = () => setSnapshot(tiangongMock.snapshot()) - const unsubscribe = tiangongMock.subscribe(refresh) + const refresh = () => setSnapshot(tiangongSession.snapshot()) + const unsubscribe = tiangongSession.subscribe(refresh) refresh() + tiangongSession.refresh().catch(() => { /* Snapshot exposes a retryable load error. */ }) return unsubscribe }, []) - useDidShow(() => setSnapshot(tiangongMock.snapshot())) + useDidShow(() => { + setSnapshot(tiangongSession.snapshot()) + tiangongSession.refresh().catch(() => { /* Shared snapshot exposes the error for retry. */ }) + }) return snapshot } diff --git a/src/pages/fortune/index.tsx b/src/pages/fortune/index.tsx index 4ba38ea..1fa3027 100644 --- a/src/pages/fortune/index.tsx +++ b/src/pages/fortune/index.tsx @@ -3,24 +3,35 @@ import Taro from '@tarojs/taro' import { Button, Image, Text, View } from '@tarojs/components' import { AppTabBar } from '../../components/AppTabBar' import { TiangongHeader, TiangongMockNote, TiangongModal } from '../../components/TiangongUI' -import { deityCatalog, tiangongMock } from '../../services/tiangong' +import { deityCatalog, isTiangongDemo, tiangongMock, tiangongSession } from '../../services/tiangong' +import { resolveAssetUrl } from '../../services/request' import { useTiangongSnapshot } from '../../hooks/useTiangongSnapshot' import { DeityId } from '../../utils/tiangongMock' +import { hasLoginAccess } from '../../services/loginAccess' +import { useRequiredLogin } from '../../hooks/useRequiredLogin' +import { TiangongLoginGate } from '../../components/TiangongLoginGate' import './index.scss' export default function FortunePage() { + const auth = useRequiredLogin() + if (auth.status !== 'allowed' || !hasLoginAccess()) return + return +} + +function FortuneContent() { const snapshot = useTiangongSnapshot() const items = deityCatalog.map(item => ({ ...item, ...snapshot.deities[item.id] })) const [selected, setSelected] = useState(null) const [settings, setSettings] = useState(false) - const balance = snapshot.balance const open = (id: DeityId, invite = false) => Taro.navigateTo({ url: `/pages/tiangong-detail/index?id=${id}${invite ? '&mode=invite' : ''}` }) return + {snapshot.loading && !snapshot.ready && 正在加载神仙列表…} + {snapshot.error && {snapshot.error}} {items.map(item => - {selected === item.id && !item.unlocked && @@ -29,11 +40,9 @@ export default function FortunePage() { } )} - + {isTiangongDemo() && } {settings && setSettings(false)} variant='plain'> - Mock 演示设置当前模拟爱心:{balance} - 仅切换演示余额,不是充值。刷新网页或重新启动小程序后恢复初始样例。 - {([0, 100] as const).map(value => )} + Mock 演示设置仅重置本次演示,不修改真实供奉记录。 } diff --git a/src/pages/home/index.tsx b/src/pages/home/index.tsx index 2790ccd..fb14a39 100644 --- a/src/pages/home/index.tsx +++ b/src/pages/home/index.tsx @@ -2,6 +2,8 @@ import { useEffect, useMemo, useRef, useState } from 'react' import Taro, { useDidShow } from '@tarojs/taro' import { Button, Image, Text, View } from '@tarojs/components' import { AppTabBar } from '../../components/AppTabBar' +import { LoginGate } from '../../components/LoginGate' +import { switchMainTab } from '../../services/tabNavigation' import { UserAvatar } from '../../components/UserAvatar' import { BindingGate, DeityBindingFlow } from '../../components/DeityBinding' import { useTiangongSnapshot } from '../../hooks/useTiangongSnapshot' @@ -91,13 +93,15 @@ export default function HomePage() { catch { Taro.showToast({ title: '排序保存失败,请重试', icon: 'none' }) } } + const loggedOut = !isFrontendPreview() && !isAvatarDemo() && !getToken() + if (loggedOut) return + if (loading || !overview) { - const loggedOut = !isFrontendPreview() && !isAvatarDemo() && !getToken() return - {loggedOut ? '登录后开启你的守护之旅' : loading ? '正在准备你的每日参考…' : loadFailed ? '个人资料加载失败,请重试' : '请先完善有效的出生日期和时辰'} - {!loading && } + {loading ? '正在准备你的每日参考…' : loadFailed ? '个人资料加载失败,请重试' : '请先完善有效的出生日期和时辰'} + {!loading && } @@ -121,11 +125,11 @@ export default function HomePage() { } const activeDay = overview.days[dayOffset + 1] - return + return {isFrontendPreview() && 前端预览 · 示例资料 · 不保存个人资料} {isAvatarDemo() && 已登录演示 · 头像仅本地保存 · 刷新重置} - } /> + {overview.days.map((day, index) => )} diff --git a/src/pages/login/index.scss b/src/pages/login/index.scss index 13a0c01..da0cbf8 100644 --- a/src/pages/login/index.scss +++ b/src/pages/login/index.scss @@ -1,80 +1,17 @@ .login-page { - min-height: 100vh; - padding: 170px 56px 80px; - box-sizing: border-box; - display: flex; - flex-direction: column; - align-items: center; -} - -.brand-mark { - width: 180px; - height: 180px; - border-radius: 90px; - background: rgba(255, 255, 255, 0.86); - box-shadow: 0 18px 42px rgba(143, 100, 13, 0.14); - color: #8f640d; - font-size: 54px; - font-weight: 700; - display: flex; - align-items: center; - justify-content: center; -} - -.login-title { - margin-top: 44px; - font-size: 48px; - font-weight: 800; - color: #8f640d; -} - -.login-subtitle { - margin-top: 22px; - font-size: 26px; - color: #9e8b65; -} -.login-platform-note { display: block; margin-top: 24px; font-size: 25px; line-height: 1.8; color: #9e8b65; } -.login-page .login-button[disabled] { opacity: .55; } -.login-page .code-input { min-width: 0; } -.login-page .send-code { flex: none; width: 180px; font-size: 25px; } - -.login-button { - width: 100%; - margin-top: 54px; -} - -.login-form { - width: 100%; - margin-top: 40px; - display: flex; - flex-direction: column; - gap: 22px; -} - -.code-row { - display: flex; - gap: 18px; -} - -.code-input { - flex: 1; -} - -.send-code { - width: 148px; - height: 78px; - border-radius: 40px; - background: #fff; - color: #8f640d; - box-shadow: 0 10px 24px rgba(143, 100, 13, 0.14); - font-size: 28px; - display: flex; - align-items: center; - justify-content: center; -} - -.mock-code { - padding-left: 24px; - font-size: 24px; - color: #b38a32; + min-height: 100vh; padding: calc(80px + env(safe-area-inset-top)) 44px calc(60px + env(safe-area-inset-bottom)); + box-sizing: border-box; display: flex; flex-direction: column; justify-content: center; align-items: center; + background: linear-gradient(180deg,#c4b0ff,#e9e1fa 48%,#edf4ff); color: #4c3b6b; + .login-card { width: 100%; max-width: 600px; padding: 58px 34px; box-sizing: border-box; border-radius: 32px; background: rgba(255,255,255,.78); } + .login-title { display: block; text-align: center; font-size: 34px; line-height: 1.5; font-weight: 700; } + .login-subtitle, .login-platform-note { display: block; margin-top: 18px; color: #8c7c9f; font-size: 25px; line-height: 1.8; text-align: center; } + .login-button { width: 100%; margin-top: 34px; padding: 24px 12px; border-radius: 48px; color: white; font-size: 28px; line-height: 1.4; background: linear-gradient(135deg,#a984ed,#8d6acb); } + .login-button[disabled] { opacity: .55; } + .login-form { display: flex; flex-direction: column; gap: 22px; margin-top: 34px; } + .input-pill { width: 100%; min-width: 0; height: 88px; padding: 0 24px; box-sizing: border-box; font-size: 26px; line-height: 88px; border-radius: 44px; background: white; color: #4c3b6b; } + .code-row { display: flex; align-items: center; gap: 14px; } + .code-input { flex: 1; } + .send-code { flex: none; margin: 0; width: 180px; padding: 20px 8px; font-size: 24px; line-height: 1.5; border-radius: 40px; background: #eee5fc; color: #8d6acb; } + .mock-code { font-size: 23px; line-height: 1.6; color: #8c7c9f; } + .login-retry { margin: 0; font-size: 25px; line-height: 1.6; color: #8c7c9f; background: transparent; } } diff --git a/src/pages/login/index.tsx b/src/pages/login/index.tsx index d6af33f..d4ed157 100644 --- a/src/pages/login/index.tsx +++ b/src/pages/login/index.tsx @@ -4,10 +4,11 @@ import { Button, Input, Text, View } from '@tarojs/components' import { api } from '../../services/request' import { finishAccountLogin } from '../../services/account' import { isFrontendPreview } from '../../services/frontendPreview' +import { beginWechatLogin, clearRegistrationSession, getRegistrationSession } from '../../services/loginAccess' import './index.scss' export default function LoginPage() { - const [sessionKey, setSessionKey] = useState('') + const [sessionKey, setSessionKey] = useState(getRegistrationSession) const [phone, setPhone] = useState('') const [captcha, setCaptcha] = useState('') const [mockCode, setMockCode] = useState('') @@ -27,16 +28,14 @@ export default function LoginPage() { try { await work() } catch (error) { const message = error instanceof Error ? error.message : '操作失败,请检查网络后重试' - if (/会话.*(过期|不存在)/.test(message)) { setSessionKey(''); setCaptcha(''); setMockCode('') } + if (/会话.*(过期|不存在)/.test(message)) { clearRegistrationSession(); setSessionKey(''); setCaptcha(''); setMockCode('') } Taro.showToast({ title: message, icon: 'none' }) } finally { working.current = false; setBusy('') } } const wxLogin = () => run('wx', async () => { if (!supported || isFrontendPreview()) throw new Error('请在微信小程序中登录') setSessionKey(''); setCaptcha(''); setMockCode(''); setCountdown(0) - const login = await Taro.login() - if (!login.code) throw new Error('微信未返回登录凭证,请重试') - const res = await api.wxLogin(login.code) + const res = await beginWechatLogin() if (res.phoneBound) { if (!res.token || !res.user) throw new Error('登录响应不完整,请重试') await finishAccountLogin({ token: res.token, user: res.user }) @@ -62,17 +61,18 @@ export default function LoginPage() { if (!/^1\d{10}$/.test(phone)) throw new Error('请输入正确的11位手机号') if (!/^\d{6}$/.test(captcha)) throw new Error('请输入6位验证码') const res = await api.bindPhone(sessionKey, phone, captcha) + clearRegistrationSession() await finishAccountLogin(res) }) return ( - - 那吉 - 一触即吉 - 微信登录后绑定手机号,开启你的守护之旅 - {!supported && 微信登录 / 注册请在微信开发者工具或微信小程序中完成。H5 不伪造微信凭证;已有会话可继续联调资料和头像接口。} + + + {sessionKey ? '绑定手机号' : '登录后开启守护之旅'} + {sessionKey ? '首次注册需验证手机号,完成后即可继续' : '微信登录 / 注册,查看你的专属内容'} + {!supported && 请在微信开发者工具或微信小程序中完成登录 / 注册。} {isFrontendPreview() && 当前为纯前端预览,不会请求注册接口。} - + {!sessionKey && } {sessionKey && { setPhone(String(e.detail.value)); setCaptcha(''); setMockCode('') }} /> @@ -80,8 +80,10 @@ export default function LoginPage() { {mockCode && 开发验证码:{mockCode}(后端返回,非真实短信)} - + + } + ) } diff --git a/src/pages/my/index.tsx b/src/pages/my/index.tsx index 87b4535..afaf885 100644 --- a/src/pages/my/index.tsx +++ b/src/pages/my/index.tsx @@ -3,6 +3,7 @@ import Taro, { useDidShow } from '@tarojs/taro' import { Button, Image, ScrollView, Text, View } from '@tarojs/components' import { RadarChart } from '../../components/RadarChart' import { AppTabBar } from '../../components/AppTabBar' +import { LoginGate } from '../../components/LoginGate' import { UserAvatar } from '../../components/UserAvatar' import { FiveElementIcon } from '../../components/FiveElementIcon' import { BindingGate, DeityBindingFlow, DeityCardDialog } from '../../components/DeityBinding' @@ -128,7 +129,7 @@ export default function MyPage() { const renderElement = () => !elementReport ? ( - + {previewNote} @@ -139,7 +140,7 @@ export default function MyPage() { {renderBindingDialogs()} ) : ( - + {previewNote} @@ -202,9 +203,10 @@ export default function MyPage() { if (!profile) { const loggedOut = !isFrontendPreview() && !isAvatarDemo() && !getToken() + if (loggedOut) return return - {loggedOut ? '登录后查看个人信息' : profileError ? '个人资料加载失败,请重试' : '个人资料加载中…'} - {loggedOut ? : profileError && } + {profileError ? '个人资料加载失败,请重试' : '个人资料加载中…'} + {profileError && } {renderTabs()} } diff --git a/src/pages/profile-edit/index.scss b/src/pages/profile-edit/index.scss index 89401af..21fb937 100644 --- a/src/pages/profile-edit/index.scss +++ b/src/pages/profile-edit/index.scss @@ -219,6 +219,14 @@ padding: 0 28px 0 31px; } +.birthday-picker { width: 100%; margin-bottom: 34px; } +.birthday-calendar-tabs { display: flex; justify-content: flex-end; gap: 8px; margin-bottom: 14px; } +.birthday-calendar-tab { margin: 0; padding: 10px 24px; border-radius: 28px; background: rgba(255,255,255,.4); color: #847498; font-size: 24px; line-height: 1.4; } +.birthday-calendar-tab.active { color: #fff; background: #a181d7; } +.birthday-card { margin-bottom: 0; } +.birthday-card .field-value { font-size: 26px; margin-left: 20px; } +.birthday-equivalent { display: block; margin: 12px 12px 0; color: #8e82a0; font-size: 22px; line-height: 1.5; } + .form-picker { width: 100%; display: block; @@ -311,18 +319,6 @@ position: relative; } -.wechat-avatar-action { - height: 154px; - flex-direction: column; - gap: 12px; -} - -.wechat-avatar-preview { - width: 44px; - height: 44px; - border-radius: 6px; -} - .sheet-gap { height: 16px; background: #f5f5f7; diff --git a/src/pages/profile-edit/index.tsx b/src/pages/profile-edit/index.tsx index dca41df..e618b3f 100644 --- a/src/pages/profile-edit/index.tsx +++ b/src/pages/profile-edit/index.tsx @@ -1,13 +1,15 @@ import { useEffect, useRef, useState } from 'react' import Taro, { useRouter } from '@tarojs/taro' -import { Button, Image, Input, Picker, Text, View } from '@tarojs/components' +import { Button, Input, Picker, Text, View } from '@tarojs/components' import areaData from 'china-area-data' import { NavBar } from '../../components/NavBar' import { UserAvatar } from '../../components/UserAvatar' +import { BirthdayPicker } from '../../components/BirthdayPicker' import { api, resolveAssetUrl, UserProfile } from '../../services/request' import { isFrontendPreview } from '../../services/frontendPreview' import { isAvatarDemo } from '../../services/avatarDemo' import { EditableProfile, toEditableProfile, saveProfileEdits, validateProfileForm } from '../../utils/profileEditing' +import { openLogin } from '../../services/loginAccess' import './index.scss' const hours = [ @@ -31,6 +33,7 @@ const emptyForm: EditableProfile = { nickname: '', gender: '', birthday: '', + birthdayCalendar: 'solar', birthHour: '', province: '', city: '', @@ -144,8 +147,6 @@ export default function ProfileEditPage() { } const birthHour = hours.find((item) => item.name === form.birthHour) || hours[0] - const dateParts = form.birthday ? form.birthday.split('-') : ['', '', ''] - const today = new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString().slice(0, 10) const provinceEntries = regionEntries('86') const provinceCode = regionCodes[0] || provinceEntries[0]?.[0] || '' const cityEntries = regionEntries(provinceCode) @@ -199,12 +200,15 @@ export default function ProfileEditPage() { 加载中... ) : loadError || !profile ? {loadError || '个人信息加载失败'} - + : ( {isFrontendPreview() && 纯前端预览 · 不保存个人资料或头像} {isAvatarDemo() && 已登录演示 · 选择图片并保存可查看三处头像同步,不上传真实存储;刷新页面重置。} - } diff --git a/src/pages/tiangong-detail/index.scss b/src/pages/tiangong-detail/index.scss index 5b1a207..bc4b246 100644 --- a/src/pages/tiangong-detail/index.scss +++ b/src/pages/tiangong-detail/index.scss @@ -7,7 +7,6 @@ // one stage size and centreline so lighting incense never moves the composition. .tg-altar-frame { position: relative; flex: none; height: 0; padding-top: 43.4%; overflow: hidden; background: #e7d5ae; } .tg-altar { position: absolute; top: -0.3%; left: -3.7%; display: block; width: 106%; height: 100.3%; } -.tg-merit-entry { position: absolute; left: 24px; top: 20px; padding: 10px 24px; margin: 0; color: #786c48; border: 1px solid rgba(157,135,71,.25); border-radius: 25px; background: rgba(255,253,236,.8); font-size: 23px; line-height: 32px; } .tg-incense-count { position: absolute; right: 24px; top: 30px; color: #887942; font-size: 23px; line-height: 32px; } .worship-actions { flex: none; display: flex; justify-content: space-around; align-items: center; padding: 18px 12px calc(24px + env(safe-area-inset-bottom)); background: #faf2d4; } .worship-actions .worship-action { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 68px; height: 108px; padding: 0; margin: 0; border-radius: 34px; font-size: 27px; line-height: 34px; color: #3f5141; background: linear-gradient(110deg,#c3d7c7,#e1eadc); box-shadow: inset 2px 1px 5px rgba(255,255,255,.7),0 3px 8px rgba(107,125,92,.08); } @@ -16,11 +15,7 @@ .tg-panel-statue { flex: none; width: 130px; height: 150px; } .tg-panel-copy { flex: 1; } .tg-panel-heading-line { display: block; font-size: 25px; font-weight: 600; line-height: 1.8; } -.tg-merit-stats { margin-top: 22px; padding: 20px 24px; background: rgba(255,255,255,.25); border-radius: 12px; } -.tg-merit-stat { display: block; font-size: 23px; line-height: 1.8; } -.tg-offering-options { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 20px; margin: 30px 0; } -.tg-offering-options .tg-offering-option { margin: 0; padding: 14px; border: 2px solid transparent; border-radius: 16px; color: #47614d; background: #fffdf4; font-size: 30px; line-height: 44px; } -.tg-offering-options .tg-offering-option.chosen { border-color: #567f60; background: #edf4e8; } +.tg-worship-stats { display: flex; flex-wrap: wrap; gap: 20px; margin-top: 22px; padding: 20px 24px; background: rgba(255,255,255,.25); border-radius: 12px; font-size: 23px; line-height: 1.8; } .tg-waiting-dots { font-size: 64px; text-align: center; line-height: 1.8; letter-spacing: 18px; color: #7c9a7f; } .tiangong-detail-page.invitation { height: auto; min-height: 100vh; overflow: visible; } .invitation .tg-sacred-scene { flex: none; height: 730px; } diff --git a/src/pages/tiangong-detail/index.tsx b/src/pages/tiangong-detail/index.tsx index 44ec8c9..59637fd 100644 --- a/src/pages/tiangong-detail/index.tsx +++ b/src/pages/tiangong-detail/index.tsx @@ -1,70 +1,84 @@ -import { useState } from 'react' +import { useRef, useState } from 'react' import Taro, { useRouter } from '@tarojs/taro' import { Button, Image, Input, Text, Textarea, View } from '@tarojs/components' import { TiangongHeader, TiangongModal, TiangongMockNote } from '../../components/TiangongUI' import { DeityBindingFlow } from '../../components/DeityBinding' import { useTiangongSnapshot } from '../../hooks/useTiangongSnapshot' -import { getDeity, tiangongMock } from '../../services/tiangong' +import { getDeity, isTiangongDemo, tiangongMock, tiangongSession } from '../../services/tiangong' +import { TiangongPending } from '../../components/TiangongPending' +import { hasLoginAccess } from '../../services/loginAccess' +import { useRequiredLogin } from '../../hooks/useRequiredLogin' +import { TiangongLoginGate } from '../../components/TiangongLoginGate' +import { useIncenseRitual } from '../../hooks/useIncenseRitual' import clouds from '../../assets/tiangong/proto-clouds.jpg' import altar from '../../assets/tiangong/proto-altar.jpg' import altarLit from '../../assets/tiangong/proto-altar-lit.jpg' import './index.scss' -type Modal = '' | 'wish' | 'dedicate' | 'offering' | 'send-off' | 'stand' | 'waiting' | 'invited' | 'insufficient' | 'result' +type Modal = '' | 'wish' | 'dedicate' | 'send-off' | 'stand' | 'waiting' | 'invited' | 'result' export default function TiangongDetailPage() { + const auth = useRequiredLogin() + if (auth.status !== 'allowed' || !hasLoginAccess()) return + return +} + +function TiangongDetailContent() { const router = useRouter() const deity = getDeity(router.params.id) const snapshot = useTiangongSnapshot() + const ritual = useIncenseRitual(deity?.id) const [bindingCardOpen, setBindingCardOpen] = useState(false) const [modal, setModal] = useState('') const [wish, setWish] = useState('') const [name, setName] = useState('') const [blessing, setBlessing] = useState('') - const [amount, setAmount] = useState('') const [error, setError] = useState('') const [result, setResult] = useState('') const [busy, setBusy] = useState(false) - const close = () => { setModal(''); setError('') } + const working = useRef(false) + const demo = isTiangongDemo() + const close = () => { if (!working.current) { setModal(''); setError('') } } const open = (next: Modal) => { setError(''); setModal(next) } const back = () => { if (Taro.getCurrentPages().length > 1) Taro.navigateBack() else Taro.switchTab({ url: '/pages/fortune/index' }) } if (!deity) return 未找到该神仙,请返回天宫选择。 + if (!snapshot.ready) return snapshot.error ? {snapshot.error} : const state = snapshot.deities[deity.id] + if (!state.available) return 该神仙暂未配置,请返回天宫选择。 const invited = state.unlocked - const perform = (action: () => void) => { - if (busy) return - setBusy(true) - try { action() } catch (cause) { setError(cause instanceof Error ? cause.message : '演示操作失败,请重试') } - finally { setBusy(false) } + const perform = async (action: () => void | Promise) => { + if (working.current) return + working.current = true; setBusy(true); setError('') + try { await action() } catch (cause) { setError(cause instanceof Error ? cause.message : '操作失败,请重试') } + finally { working.current = false; setBusy(false) } } const showResult = (text: string) => { setResult(text); open('result') } - const incense = () => perform(() => { tiangongMock.incense(deity.id); Taro.showToast({ title: '模拟上香,次数 +1', icon: 'none' }) }) - const confirmOffering = () => perform(() => { - const response = tiangongMock.offering(deity.id, Number(amount)) - if (response.insufficient) open('insufficient') - else showResult(`已模拟选择 ${response.amount} 爱心。本次不扣减余额、不增加真实功德。`) - }) const showBindingCard = () => { close(); setBindingCardOpen(true) } - const submitWish = () => perform(() => { tiangongMock.wish(deity.id, wish); setWish(''); showResult('模拟祈愿已完成,内容未上传、未保存。') }) - const submitDedication = () => perform(() => { tiangongMock.dedicate(deity.id, name, blessing); setName(''); setBlessing(''); showResult('模拟回向已完成,姓名和祝愿未上传、未保存。') }) + const submitWish = () => perform(async () => { await tiangongSession.wish(deity.id, wish); setWish(''); showResult(demo ? '模拟祈愿已完成,内容未上传、未保存。' : '祈愿已记录。') }) + const submitDedication = () => perform(async () => { await tiangongSession.dedicate(deity.id, name, blessing); setName(''); setBlessing(''); showResult(demo ? '模拟回向已完成,姓名和祝愿未上传、未保存。' : '回向已记录。') }) const panelHeader = (title: string, subtitle: string) => {title}{subtitle} const dialogActions = (confirm: string, action: () => void, danger = false, disabled = false) => return - + + + + } /> - {invited && <>上香 {state.incenseCount}} + {invited && 上香 {state.incenseCount}{ritual.pending > 0 ? ' · 提交中' : ''}} {invited && } {invited ? {['上香', '祈愿', '回向', '送神', '返回'].map(action => )} : @@ -72,34 +86,27 @@ export default function TiangongDetailPage() { {deity.introduction || '原型未提供这位神仙的独立介绍,待确认后补充。'} {deity.introduction && 原型介绍与“禄星”名称不一致,文案待确认。} {deity.missingStatue && 原型未提供独立详情神像,暂使用同一神仙的列表原图。} - - 仅演示恭请流程,不扣费、不建立真实供奉关系。 + + {demo && 仅演示恭请流程,不扣费、不建立真实供奉关系。} } - {modal && + {modal && {} : close} variant={['stand', 'waiting'].includes(modal) ? 'plain' : ''}> {modal === 'wish' && <> {panelHeader(`向【${deity.ritualName}】祈愿`, '诚心许愿,助您愿望实现!')} 您的愿望