feat: refine login flows and integrate tiangong worship audio
This commit is contained in:
1 parent
bc7bbc52c8
commit
38fc4187ea
49 files changed
+1786
-224
No files matched your search
@@ -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 不模拟成真实微信授权。
|
||||
@@ -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`。未以浏览器检查替代真机听感和远端联调。
|
||||
@@ -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`。
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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, [])
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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 <LoginGate/)
|
||||
assert.doesNotMatch(read('src/pages/login/index.tsx'), /brand-mark|一触即吉|那吉/)
|
||||
})
|
||||
|
||||
test('首页头像点击使用主Tab导航前往我的页面', () => {
|
||||
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/)
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
+13
-13
@@ -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']) {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
Binary file not shown.
@@ -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 = <View className='form-card picker-card birthday-card'>
|
||||
<Text className='field-label'>出生日期</Text>
|
||||
<Text className={`field-value ${value ? '' : 'placeholder'}`}>{value ? calendar === 'lunar' ? lunarLabel : solarLabel : '请选择'}</Text>
|
||||
<Text className='chevron'>›</Text>
|
||||
</View>
|
||||
return <View className='birthday-picker'>
|
||||
<View className='birthday-calendar-tabs'>
|
||||
{(['solar', 'lunar'] as const).map(type => <Button key={type} className={`birthday-calendar-tab ${calendar === type ? 'active' : ''}`} aria-label={type === 'solar' ? '选择阳历' : '选择农历'} onClick={() => onCalendarChange(type)}>{type === 'solar' ? '阳历' : '农历'}</Button>)}
|
||||
</View>
|
||||
{calendar === 'solar' ? <Picker className='form-picker' mode='date' value={value || today} start={MIN_BIRTHDAY} end={today} onChange={event => onChange(String(event.detail.value))}>{field}</Picker>
|
||||
: <Picker className='form-picker' mode='multiSelector' range={model.range} value={model.indices}
|
||||
onColumnChange={event => 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}</Picker>}
|
||||
{value && <Text className='birthday-equivalent'>{calendar === 'lunar' ? `对应阳历:${solarLabel}` : `对应农历:${lunarLabel}`}</Text>}
|
||||
</View>
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 <View className='binding-gate'>
|
||||
return <View className='binding-gate' catchMove>
|
||||
<View className='binding-gate-content'>
|
||||
{lock && <View className='binding-lock'><View /></View>}
|
||||
<Text>须绑定神仙</Text><Text>开启你的守护之旅</Text>
|
||||
@@ -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 <View className='binding-dialog-mask' catchMove><View className='binding-picker' role='dialog' aria-label='神仙信息'>
|
||||
<Text className='binding-confirm-title'>{deity.name}</Text>
|
||||
<Image className='binding-card-art' src={deity.icon} mode='aspectFit' />
|
||||
<Text className='binding-picker-note'>{state.unlocked ? `已守护 ${state.days} 天 · 持有记录 ${state.holdingId}` : '请通过 NFC 绑定神仙。此处不模拟建立真实绑定关系。'}</Text>
|
||||
<Button className='binding-outline binding-picker-cancel' onClick={onClose}>关闭</Button>
|
||||
</View></View>
|
||||
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<DeityId | null>(initialDeity || null)
|
||||
const snapshot = useTiangongSnapshot()
|
||||
if (!isTiangongDemo()) return <View className='binding-dialog-mask' catchMove><View className='binding-picker' role='dialog' aria-label='绑定神仙说明'><Text className='binding-confirm-title'>碰一碰,绑定神仙</Text><Text className='binding-picker-note'>请通过 NFC 绑定神仙,本次不在页面中模拟真实设备绑定。</Text><Button className='binding-outline binding-picker-cancel' onClick={onClose}>关闭</Button></View></View>
|
||||
if (selected) return <DeityCardDialog deityId={selected} mode='bind' onClose={onClose} />
|
||||
const available = deityCatalog.filter(item => !snapshot.deities[item.id].unlocked)
|
||||
return <View className='binding-dialog-mask' catchMove><View className='binding-picker' role='dialog' aria-label='模拟 NFC 识别'>
|
||||
|
||||
@@ -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 <View className='tiangong-login-gate' catchMove>
|
||||
<View className='tiangong-login-card'>
|
||||
<Text className='tiangong-login-title'>{status === 'checking' ? '正在确认登录状态…' : status === 'error' ? '登录状态暂时无法确认' : '登录后开启守护之旅'}</Text>
|
||||
<Text className='tiangong-login-copy'>{status === 'checking' ? '请稍候' : status === 'error' ? '请检查网络后重试。' : '微信登录 / 注册,查看你的专属内容'}</Text>
|
||||
{status !== 'checking' && <Button className='tiangong-login-action' onClick={status === 'error' ? retry : openLogin}>{status === 'error' ? '重新加载' : '登录 / 注册'}</Button>}
|
||||
{!active && <Button className='tiangong-login-home' onClick={() => switchMainTab('home')}>返回首页</Button>}
|
||||
</View>
|
||||
{active && <AppTabBar active={active} />}
|
||||
</View>
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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 <TiangongPending tab={tab} />
|
||||
return <LoginGate status={status} retry={retry} active={tab ? 'fortune' : undefined} />
|
||||
}
|
||||
@@ -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)); }
|
||||
}
|
||||
@@ -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 <View className={`tg-pending ${tab ? 'catalog' : 'detail'}`} aria-busy='true'>
|
||||
<TiangongHeader title='天宫' onBack={tab ? undefined : back} />
|
||||
<View className='tg-pending-content' aria-hidden='true'>
|
||||
<View className='tg-pending-silhouette' /><View className='tg-pending-base' />
|
||||
</View>
|
||||
{tab && <AppTabBar active='fortune' />}
|
||||
</View>
|
||||
}
|
||||
@@ -1,6 +1,17 @@
|
||||
.tg-header { flex: none; background: #fafaf8; color: #2e3530; }
|
||||
.tg-header-row { height: 88px; position: relative; display: flex; align-items: center; justify-content: center; font-size: 30px; font-weight: 600; }
|
||||
.tg-header .tg-back { position: absolute; left: 22px; top: 0; width: 80px; height: 88px; font-size: 54px; line-height: 80px; color: #4e6457; }
|
||||
.tg-header .tg-header-accessory { position: absolute; left: 108px; top: 0; width: 88px; height: 88px; }
|
||||
.tg-header .tg-sound-toggle { display: flex; align-items: center; justify-content: center; width: 88px; height: 88px; margin: 0; padding: 0; color: #4e6457; background: transparent; }
|
||||
.tg-header .tg-sound-toggle:active { opacity: .6; }
|
||||
.tg-speaker-icon { position: relative; width: 44px; height: 42px; }
|
||||
.tg-speaker-body { position: absolute; left: 1px; top: 15px; width: 9px; height: 13px; border-radius: 2px; background: currentColor; }
|
||||
.tg-speaker-cone { position: absolute; left: 8px; top: 5px; width: 0; height: 14px; border-top: 10px solid transparent; border-bottom: 10px solid transparent; border-right: 16px solid currentColor; }
|
||||
.tg-speaker-wave { position: absolute; left: 27px; top: 10px; width: 11px; height: 23px; border: 3px solid currentColor; border-left: 0; border-top-color: transparent; border-bottom-color: transparent; border-radius: 50%; }
|
||||
.tg-speaker-slash { display: none; position: absolute; left: 30px; top: 9px; width: 3px; height: 26px; border-radius: 2px; background: currentColor; transform: rotate(-35deg); }
|
||||
.tg-speaker-icon.muted { color: #9a9e96; }
|
||||
.tg-speaker-icon.muted .tg-speaker-wave { display: none; }
|
||||
.tg-speaker-icon.muted .tg-speaker-slash { display: block; }
|
||||
.tg-mock-note { display: block; padding: 13px 12px; font-size: 19px; line-height: 1.5; text-align: center; color: #7c8073; background: #f7f6ed; }
|
||||
.tg-modal-mask { position: fixed; z-index: 50; inset: 0; padding: calc(24px + env(safe-area-inset-top)) 28px calc(24px + env(safe-area-inset-bottom)); box-sizing: border-box; display: flex; align-items: center; justify-content: center; background: rgba(28,37,29,.43); }
|
||||
.tg-modal { position: relative; width: 100%; max-width: 650px; max-height: 100%; overflow-y: auto; box-sizing: border-box; padding: 52px 42px 40px; border-radius: 22px; background: #d5e5d9; color: #304439; box-shadow: 0 25px 70px rgba(30,42,28,.18); animation: tg-modal-in .18s ease-out; }
|
||||
@@ -23,3 +34,4 @@
|
||||
.tg-error { display: block; margin-top: 18px; font-size: 22px; line-height: 1.5; color: #a32d25; }
|
||||
@keyframes tg-modal-in { from { transform: translateY(12px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
|
||||
@media (prefers-reduced-motion: reduce) { .tg-modal { animation: none; } }
|
||||
.tg-data-note { display: block; margin: 24px; color: #786c48; font-size: 25px; line-height: 1.6; text-align: center; }
|
||||
@@ -2,11 +2,12 @@ import { ReactNode } from 'react'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { Button, Text, View } from '@tarojs/components'
|
||||
import './TiangongUI.scss'
|
||||
import { isTiangongDemo } from '../services/tiangong'
|
||||
|
||||
export function TiangongHeader({ title, onBack }: { title: string; onBack?: () => void }) {
|
||||
export function TiangongHeader({ title, onBack, leftAction }: { title: string; onBack?: () => void; leftAction?: ReactNode }) {
|
||||
const top = process.env.TARO_ENV === 'weapp' ? Taro.getSystemInfoSync().statusBarHeight || 0 : 0
|
||||
return <View className='tg-header' style={{ paddingTop: `${top}px` }}><View className='tg-header-row'>
|
||||
{onBack && <Button className='tg-back' aria-label='返回天宫' onClick={onBack}>‹</Button>}<Text>{title}</Text>
|
||||
{onBack && <Button className='tg-back' aria-label='返回天宫' onClick={onBack}>‹</Button>}{leftAction && <View className='tg-header-accessory'>{leftAction}</View>}<Text>{title}</Text>
|
||||
</View></View>
|
||||
}
|
||||
export function TiangongModal({ title, onClose, children, variant = '' }: { title: string; onClose: () => void; children: ReactNode; variant?: string }) {
|
||||
@@ -14,4 +15,4 @@ export function TiangongModal({ title, onClose, children, variant = '' }: { titl
|
||||
<Button className='tg-modal-close' aria-label='关闭弹窗' onClick={onClose}>×</Button>{children}
|
||||
</View></View>
|
||||
}
|
||||
export function TiangongMockNote() { return <Text className='tg-mock-note'>Mock 原型演示 · 不扣费、不连接设备 · 刷新重置</Text> }
|
||||
export function TiangongMockNote() { return isTiangongDemo() ? <Text className='tg-mock-note'>Mock 原型演示 · 不扣费、不连接设备 · 刷新重置</Text> : null }
|
||||
@@ -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<ReturnType<typeof createIncenseRitual> | 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)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<LoginAccessStatus>(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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 <TiangongLoginGate status={auth.status === 'allowed' ? 'denied' : auth.status} retry={auth.retry} tab />
|
||||
return <FortuneContent />
|
||||
}
|
||||
|
||||
function FortuneContent() {
|
||||
const snapshot = useTiangongSnapshot()
|
||||
const items = deityCatalog.map(item => ({ ...item, ...snapshot.deities[item.id] }))
|
||||
const [selected, setSelected] = useState<DeityId | null>(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 <View className='tiangong-page'>
|
||||
<TiangongHeader title='天宫' /><TiangongMockNote />
|
||||
{snapshot.loading && !snapshot.ready && <Text className='tg-data-note'>正在加载神仙列表…</Text>}
|
||||
{snapshot.error && <View className='tg-data-note'><Text>{snapshot.error}</Text><Button onClick={() => tiangongSession.refresh(true).catch(() => {})}>重新加载</Button></View>}
|
||||
<View className='deity-grid'>
|
||||
{items.map(item => <View className='deity-card-wrap' key={item.id}>
|
||||
<Button className={`deity-card ${item.unlocked ? 'unlocked' : 'locked'}`} aria-label={`${item.name},${item.unlocked ? '已恭请' : '未恭请'}`} onClick={() => item.unlocked ? open(item.id) : setSelected(selected === item.id ? null : item.id)}>
|
||||
<Image className='deity-image' src={item.icon} mode='aspectFill' /><Text className='deity-name'>{item.name}</Text>
|
||||
<Button disabled={item.available === false} className={`deity-card ${item.unlocked ? 'unlocked' : 'locked'}`} aria-label={`${item.name},${item.unlocked ? '已恭请' : '未恭请'}`} onClick={() => item.unlocked ? open(item.id) : setSelected(selected === item.id ? null : item.id)}>
|
||||
<Image className='deity-image' src={resolveAssetUrl(item.iconUrl) || item.icon} mode='aspectFill' /><Text className='deity-name'>{item.name}{item.available === false && snapshot.ready ? '(暂未配置)' : ''}</Text>
|
||||
</Button>
|
||||
{selected === item.id && !item.unlocked && <View className='deity-invite-prompt'>
|
||||
<Button className='deity-prompt-close' aria-label='关闭恭请提示' onClick={() => setSelected(null)}>×</Button>
|
||||
@@ -29,11 +40,9 @@ export default function FortunePage() {
|
||||
</View>}
|
||||
</View>)}
|
||||
</View>
|
||||
<Button className='tg-demo-settings' onClick={() => setSettings(true)}>Mock 演示设置</Button>
|
||||
{isTiangongDemo() && <Button className='tg-demo-settings' onClick={() => setSettings(true)}>Mock 演示设置</Button>}
|
||||
{settings && <TiangongModal title='Mock 演示设置' onClose={() => setSettings(false)} variant='plain'>
|
||||
<Text className='tg-modal-title'>Mock 演示设置</Text><Text className='tg-modal-copy'>当前模拟爱心:{balance}</Text>
|
||||
<Text className='tg-modal-caption'>仅切换演示余额,不是充值。刷新网页或重新启动小程序后恢复初始样例。</Text>
|
||||
<View className='tg-modal-actions'>{([0, 100] as const).map(value => <Button className='tg-button' key={value} onClick={() => tiangongMock.setDemoBalance(value)}>设为 {value}</Button>)}</View>
|
||||
<Text className='tg-modal-title'>Mock 演示设置</Text><Text className='tg-modal-caption'>仅重置本次演示,不修改真实供奉记录。</Text>
|
||||
<View className='tg-modal-actions'><Button className='tg-button' onClick={() => { tiangongMock.reset(); setSelected(null); setSettings(false) }}>重置全部演示</Button></View>
|
||||
</TiangongModal>}
|
||||
<AppTabBar active='fortune' />
|
||||
|
||||
@@ -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 <LoginGate active='home' />
|
||||
|
||||
if (loading || !overview) {
|
||||
const loggedOut = !isFrontendPreview() && !isAvatarDemo() && !getToken()
|
||||
return <View className='home-page'>
|
||||
<View className='home-empty'>
|
||||
<Image src={water} mode='aspectFit' className='home-empty-image' />
|
||||
<Text>{loggedOut ? '登录后开启你的守护之旅' : loading ? '正在准备你的每日参考…' : loadFailed ? '个人资料加载失败,请重试' : '请先完善有效的出生日期和时辰'}</Text>
|
||||
{!loading && <Button onClick={loggedOut ? () => Taro.navigateTo({ url: '/pages/login/index' }) : loadFailed ? refreshProfile : () => Taro.navigateTo({ url: '/pages/profile-edit/index' })}>{loggedOut ? '登录 / 注册' : loadFailed ? '重新加载' : '完善出生信息'}</Button>}
|
||||
<Text>{loading ? '正在准备你的每日参考…' : loadFailed ? '个人资料加载失败,请重试' : '请先完善有效的出生日期和时辰'}</Text>
|
||||
{!loading && <Button onClick={loadFailed ? refreshProfile : () => Taro.navigateTo({ url: '/pages/profile-edit/index' })}>{loadFailed ? '重新加载' : '完善出生信息'}</Button>}
|
||||
</View>
|
||||
<AppTabBar active='home' />
|
||||
</View>
|
||||
@@ -121,11 +125,11 @@ export default function HomePage() {
|
||||
}
|
||||
|
||||
const activeDay = overview.days[dayOffset + 1]
|
||||
return <View className='home-page'>
|
||||
return <View className={`home-page ${unlocked ? '' : 'binding-viewport-locked'}`} catchMove={!unlocked && !bindingOpen}>
|
||||
{isFrontendPreview() && <Text className='home-preview-note'>前端预览 · 示例资料 · 不保存个人资料</Text>}
|
||||
{isAvatarDemo() && <Text className='home-preview-note'>已登录演示 · 头像仅本地保存 · 刷新重置</Text>}
|
||||
<View className='home-day-switch'>
|
||||
<View className='home-avatar-orb'><UserAvatar src={profile.avatarUrl} className='home-avatar-image' fallback={<Image src={water} mode='aspectFit' className='home-water-image' />} /></View>
|
||||
<Button className='home-avatar-orb' aria-label='前往我的页面' onClick={() => switchMainTab('my', 'home')}><UserAvatar src={profile.avatarUrl} className='home-avatar-image' fallback={<Image src={water} mode='aspectFit' className='home-water-image' />} /></Button>
|
||||
<View className='home-day-tabs'>{overview.days.map((day, index) => <Button key={day.label} hoverClass='none' className={`home-day-tab ${index === dayOffset + 1 ? 'active' : ''}`} onClick={() => setDayOffset((index - 1) as -1 | 0 | 1)}>
|
||||
<Text className='home-day-label'>{day.label}</Text><Text className='home-day-status'>{day.status}</Text>
|
||||
</Button>)}</View>
|
||||
|
||||
+15
-78
@@ -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; }
|
||||
}
|
||||
+14
-12
@@ -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 (
|
||||
<View className='login-page soft-bg'>
|
||||
<View className='brand-mark'>那吉</View>
|
||||
<Text className='login-title'>一触即吉</Text>
|
||||
<Text className='login-subtitle'>微信登录后绑定手机号,开启你的守护之旅</Text>
|
||||
{!supported && <Text className='login-platform-note'>微信登录 / 注册请在微信开发者工具或微信小程序中完成。H5 不伪造微信凭证;已有会话可继续联调资料和头像接口。</Text>}
|
||||
<View className='login-page'>
|
||||
<View className='login-card'>
|
||||
<Text className='login-title'>{sessionKey ? '绑定手机号' : '登录后开启守护之旅'}</Text>
|
||||
<Text className='login-subtitle'>{sessionKey ? '首次注册需验证手机号,完成后即可继续' : '微信登录 / 注册,查看你的专属内容'}</Text>
|
||||
{!supported && <Text className='login-platform-note'>请在微信开发者工具或微信小程序中完成登录 / 注册。</Text>}
|
||||
{isFrontendPreview() && <Text className='login-platform-note'>当前为纯前端预览,不会请求注册接口。</Text>}
|
||||
<Button className='gold-button login-button' disabled={!supported || !!busy} loading={busy === 'wx'} onClick={wxLogin}>{sessionKey ? '重新微信登录' : '微信登录 / 注册'}</Button>
|
||||
{!sessionKey && <Button className='login-button' disabled={!supported || !!busy} loading={busy === 'wx'} onClick={wxLogin}>微信登录 / 注册</Button>}
|
||||
{sessionKey && <View className='login-form'>
|
||||
<Input className='input-pill' type='number' maxlength={11} placeholder='手机号' value={phone} onInput={(e) => { setPhone(String(e.detail.value)); setCaptcha(''); setMockCode('') }} />
|
||||
<View className='code-row'>
|
||||
@@ -80,8 +80,10 @@ export default function LoginPage() {
|
||||
<Button className='send-code' disabled={!!busy || countdown > 0} onClick={sendSms}>{countdown > 0 ? `${countdown}s` : '获取验证码'}</Button>
|
||||
</View>
|
||||
{mockCode && <Text className='mock-code'>开发验证码:{mockCode}(后端返回,非真实短信)</Text>}
|
||||
<Button className='gold-button login-button' disabled={!!busy} loading={busy === 'bind'} onClick={bindPhone}>绑定手机号并继续</Button>
|
||||
<Button className='login-button' disabled={!!busy} loading={busy === 'bind'} onClick={bindPhone}>绑定手机号并继续</Button>
|
||||
<Button className='login-retry' disabled={!!busy} onClick={wxLogin}>重新微信登录</Button>
|
||||
</View>}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -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 ? (
|
||||
<View className='my-page element-detail'>
|
||||
<View className={`my-page element-detail ${unlocked ? '' : 'binding-viewport-locked'}`} catchMove={!unlocked && !bindingOpen}>
|
||||
{previewNote}
|
||||
<Button className='element-back' onClick={() => setMode('home')}>‹</Button>
|
||||
<View className='element-empty'>
|
||||
@@ -139,7 +140,7 @@ export default function MyPage() {
|
||||
{renderBindingDialogs()}
|
||||
</View>
|
||||
) : (
|
||||
<View className='my-page element-detail'>
|
||||
<View className={`my-page element-detail ${unlocked ? '' : 'binding-viewport-locked'}`} catchMove={!unlocked && !bindingOpen}>
|
||||
{previewNote}
|
||||
<Button className='element-back' onClick={() => setMode('home')}>‹</Button>
|
||||
<View className={`element-protected ${unlocked ? '' : 'element-locked'}`}>
|
||||
@@ -202,9 +203,10 @@ export default function MyPage() {
|
||||
|
||||
if (!profile) {
|
||||
const loggedOut = !isFrontendPreview() && !isAvatarDemo() && !getToken()
|
||||
if (loggedOut) return <LoginGate active='my' />
|
||||
return <View className='my-page my-home'><View className='element-empty'>
|
||||
<Text>{loggedOut ? '登录后查看个人信息' : profileError ? '个人资料加载失败,请重试' : '个人资料加载中…'}</Text>
|
||||
{loggedOut ? <Button onClick={() => go('/pages/login/index')}>登录 / 注册</Button> : profileError && <Button onClick={refreshProfile}>重新加载</Button>}
|
||||
<Text>{profileError ? '个人资料加载失败,请重试' : '个人资料加载中…'}</Text>
|
||||
{profileError && <Button onClick={refreshProfile}>重新加载</Button>}
|
||||
</View>{renderTabs()}</View>
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
<View className='profile-loading'>加载中...</View>
|
||||
) : loadError || !profile ? <View className='profile-load-error'>
|
||||
<Text>{loadError || '个人信息加载失败'}</Text><Button onClick={loadProfile}>重新加载</Button>
|
||||
<Button onClick={() => Taro.redirectTo({ url: '/pages/login/index' })}>登录 / 注册</Button>
|
||||
<Button onClick={openLogin}>登录 / 注册</Button>
|
||||
</View> : (
|
||||
<View className='edit-content'>
|
||||
{isFrontendPreview() && <Text className='profile-preview-notice'>纯前端预览 · 不保存个人资料或头像</Text>}
|
||||
{isAvatarDemo() && <Text className='profile-preview-notice'>已登录演示 · 选择图片并保存可查看三处头像同步,不上传真实存储;刷新页面重置。</Text>}
|
||||
<Button className='avatar-button' onClick={() => setAvatarSheetOpen(true)}>
|
||||
<Button className='avatar-button' aria-label='选择头像' disabled={saving}
|
||||
openType={process.env.TARO_ENV === 'weapp' ? 'chooseAvatar' : undefined}
|
||||
onChooseAvatar={process.env.TARO_ENV === 'weapp' ? handleWechatAvatar : undefined}
|
||||
onClick={process.env.TARO_ENV === 'weapp' ? undefined : () => setAvatarSheetOpen(true)}>
|
||||
<UserAvatar src={avatarPreview} className='avatar-image' fallback={
|
||||
<View className='edit-water-mascot'>
|
||||
<View className='edit-mascot-eye left' />
|
||||
@@ -246,15 +250,9 @@ export default function ProfileEditPage() {
|
||||
))}
|
||||
</View>
|
||||
|
||||
<Picker className='form-picker' mode='date' value={form.birthday} end={today} onChange={(event) => setForm({ ...form, birthday: String(event.detail.value) })}>
|
||||
<View className='form-card picker-card'>
|
||||
<Text className='field-label'>出生日期</Text>
|
||||
<Text className={`field-value ${form.birthday ? '' : 'placeholder'}`}>
|
||||
{form.birthday ? `${dateParts[0]} 年 ${dateParts[1]} 月 ${dateParts[2]} 日` : '请选择'}
|
||||
</Text>
|
||||
<Text className='chevron'>›</Text>
|
||||
</View>
|
||||
</Picker>
|
||||
<BirthdayPicker value={form.birthday} calendar={form.birthdayCalendar}
|
||||
onChange={birthday => setForm({ ...form, birthday })}
|
||||
onCalendarChange={birthdayCalendar => setForm({ ...form, birthdayCalendar })} />
|
||||
|
||||
<Picker
|
||||
className='form-picker'
|
||||
@@ -299,13 +297,9 @@ export default function ProfileEditPage() {
|
||||
</View>
|
||||
)}
|
||||
|
||||
{avatarSheetOpen && (
|
||||
{process.env.TARO_ENV !== 'weapp' && avatarSheetOpen && (
|
||||
<View className='avatar-mask' onClick={() => setAvatarSheetOpen(false)}>
|
||||
<View className='avatar-sheet' onClick={(event) => event.stopPropagation()}>
|
||||
{process.env.TARO_ENV === 'weapp' && <Button className='avatar-action wechat-avatar-action' openType='chooseAvatar' onChooseAvatar={handleWechatAvatar}>
|
||||
<Text>用微信头像</Text>
|
||||
{avatarPreview && <Image className='wechat-avatar-preview' src={avatarPreview} mode='aspectFill' />}
|
||||
</Button>}
|
||||
<Button className='avatar-action' onClick={() => chooseFrom('album')}>从相册选择</Button>
|
||||
<Button className='avatar-action' onClick={() => chooseFrom('camera')}>拍照</Button>
|
||||
<View className='sheet-gap' />
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 <TiangongLoginGate status={auth.status === 'allowed' ? 'denied' : auth.status} retry={auth.retry} />
|
||||
return <TiangongDetailContent />
|
||||
}
|
||||
|
||||
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<Modal>('')
|
||||
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 <View className='tg-invalid'><TiangongHeader title='天宫' onBack={back} /><Text>未找到该神仙,请返回天宫选择。</Text><Button onClick={back}>返回天宫</Button></View>
|
||||
if (!snapshot.ready) return snapshot.error ? <View className='tg-invalid'><TiangongHeader title='天宫' onBack={back} /><Text>{snapshot.error}</Text><Button onClick={() => { tiangongSession.refresh(true).catch(() => {}) }}>重试</Button></View> : <TiangongPending />
|
||||
const state = snapshot.deities[deity.id]
|
||||
if (!state.available) return <View className='tg-invalid'><TiangongHeader title='天宫' onBack={back} /><Text>该神仙暂未配置,请返回天宫选择。</Text><Button onClick={back}>返回天宫</Button></View>
|
||||
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<void>) => {
|
||||
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) => <View className='tg-panel-heading'><Image className='tg-panel-statue' src={deity.statue} mode='aspectFit' /><View className='tg-panel-copy'><Text className='tg-panel-heading-line'>{title}</Text><Text className='tg-panel-heading-line'>{subtitle}</Text></View></View>
|
||||
const dialogActions = (confirm: string, action: () => void, danger = false, disabled = false) => <View className='tg-modal-actions'><Button className='tg-button' onClick={close}>取消</Button><Button className={`tg-button ${danger ? 'danger' : 'primary'} ${busy || disabled ? 'is-disabled' : ''}`} disabled={busy || disabled} onClick={action}>{confirm}</Button></View>
|
||||
|
||||
return <View className={`tiangong-detail-page ${invited ? 'worship' : 'invitation'}`}>
|
||||
<TiangongHeader title={invited ? '敬神' : '请神'} onBack={back} /><TiangongMockNote />
|
||||
<TiangongHeader title={invited ? '敬神' : '请神'} onBack={back} leftAction={
|
||||
<Button className='tg-sound-toggle' aria-label={ritual.soundEnabled ? '关闭上香声音' : '开启上香声音'} aria-pressed={ritual.soundEnabled} onClick={ritual.toggleSound}>
|
||||
<View className={`tg-speaker-icon ${ritual.soundEnabled ? '' : 'muted'}`}><View className='tg-speaker-body' /><View className='tg-speaker-cone' /><View className='tg-speaker-wave' /><View className='tg-speaker-slash' /></View>
|
||||
</Button>
|
||||
} /><TiangongMockNote />
|
||||
<View className='tg-sacred-scene'>
|
||||
<View className='tg-statue-space' style={{ backgroundImage: `url(${clouds})` }}>
|
||||
<Image className={`tg-statue ${deity.missingStatue ? 'catalog-art' : ''}`} src={deity.statue} mode='aspectFit' />
|
||||
{invited && <><Button className='tg-merit-entry' aria-label='打开功德爱心' onClick={() => open('offering')}>功德</Button><Text className='tg-incense-count'>上香 {state.incenseCount}</Text></>}
|
||||
{invited && <Text className='tg-incense-count'>上香 {state.incenseCount}{ritual.pending > 0 ? ' · 提交中' : ''}</Text>}
|
||||
</View>
|
||||
{invited && <View className={`tg-altar-frame ${state.lit ? 'lit' : ''}`}><Image className='tg-altar' src={state.lit ? altarLit : altar} mode='scaleToFill' /></View>}
|
||||
</View>
|
||||
{invited ? <View className='worship-actions'>
|
||||
{['上香', '祈愿', '回向', '送神', '返回'].map(action => <Button className='worship-action' key={action} aria-label={action} onClick={() => {
|
||||
if (action === '上香') incense()
|
||||
if (action === '上香') ritual.incense()
|
||||
else if (action === '返回') back()
|
||||
else if (action === '送神' && !demo) showResult('送神解绑流程尚未接入,本次未变更供奉关系。')
|
||||
else open(action === '祈愿' ? 'wish' : action === '回向' ? 'dedicate' : 'send-off')
|
||||
}}><Text>{action[0]}</Text><Text>{action[1]}</Text></Button>)}
|
||||
</View> : <View className='tg-invite-description'>
|
||||
@@ -72,34 +86,27 @@ export default function TiangongDetailPage() {
|
||||
<Text className='tg-introduction'>{deity.introduction || '原型未提供这位神仙的独立介绍,待确认后补充。'}</Text>
|
||||
{deity.introduction && <Text className='tg-content-note'>原型介绍与“禄星”名称不一致,文案待确认。</Text>}
|
||||
{deity.missingStatue && <Text className='tg-content-note'>原型未提供独立详情神像,暂使用同一神仙的列表原图。</Text>}
|
||||
<Button className='tg-invite-main' onClick={() => open(snapshot.balance === 0 ? 'insufficient' : 'stand')}>请神</Button>
|
||||
<Text className='tg-content-note'>仅演示恭请流程,不扣费、不建立真实供奉关系。</Text>
|
||||
<Button className='tg-invite-main' onClick={() => demo ? open('stand') : showResult('请先通过 NFC 绑定该神仙。本页不模拟建立真实供奉关系。')}>请神</Button>
|
||||
{demo && <Text className='tg-content-note'>仅演示恭请流程,不扣费、不建立真实供奉关系。</Text>}
|
||||
</View>}
|
||||
{modal && <TiangongModal title={{ wish: '祈愿', dedicate: '回向', offering: '功德爱心', 'send-off': '送神确认', stand: '支架确认', waiting: '等待支架连接', invited: '请神成功', insufficient: '爱心余额不足', result: '模拟操作结果' }[modal]} onClose={close} variant={['stand', 'waiting', 'insufficient'].includes(modal) ? 'plain' : ''}>
|
||||
{modal && <TiangongModal title={{ wish: '祈愿', dedicate: '回向', 'send-off': '送神确认', stand: '支架确认', waiting: '等待支架连接', invited: '请神成功', result: '操作结果' }[modal]} onClose={busy ? () => {} : close} variant={['stand', 'waiting'].includes(modal) ? 'plain' : ''}>
|
||||
{modal === 'wish' && <>
|
||||
{panelHeader(`向【${deity.ritualName}】祈愿`, '诚心许愿,助您愿望实现!')}
|
||||
<Text className='tg-form-label'>您的愿望</Text><Textarea className='tg-textarea' aria-label='您的愿望' placeholder='请输入您的愿望' value={wish} onInput={e => setWish(e.detail.value)} maxlength={500} fixed />
|
||||
{dialogActions('祈愿', submitWish)}
|
||||
</>}
|
||||
{modal === 'dedicate' && <>
|
||||
{panelHeader('愿以此功德,普及于一切。', '请神保平安,消灾延福寿。')}
|
||||
<View className='tg-merit-stats'><Text className='tg-merit-stat'>善信缘主:You(示例)</Text><Text className='tg-merit-stat'>所修功德</Text><Text className='tg-merit-stat'>供奉天数:{state.days}</Text><Text className='tg-merit-stat'>累计功德:{state.merit}</Text><Text className='tg-merit-stat'>上香次数:{state.incenseCount}</Text></View>
|
||||
{panelHeader('将美好祝愿,送给牵挂的人。', '请填写回向对象与祝愿。')}
|
||||
<View className='tg-worship-stats'><Text>供奉天数:{state.days}</Text><Text>上香次数:{state.incenseCount}</Text></View>
|
||||
<Text className='tg-form-label'>特别回向给</Text><Input className='tg-input' aria-label='回向对象' placeholder='请输入 ta 的名字' value={name} onInput={e => setName(e.detail.value)} maxlength={50} />
|
||||
<Text className='tg-form-label'>愿 TA</Text><Textarea className='tg-textarea' aria-label='回向祝愿' placeholder='请输入祝愿' value={blessing} onInput={e => setBlessing(e.detail.value)} maxlength={500} fixed />
|
||||
{dialogActions('回向', submitDedication)}
|
||||
</>}
|
||||
{modal === 'offering' && <>
|
||||
<Text className='tg-modal-title'>布施捐款,随喜爱心</Text><Text className='tg-modal-copy'>当前爱心:{snapshot.balance}(模拟)</Text>
|
||||
<View className='tg-offering-options'>{[9, 99, 168].map(value => <Button className={`tg-offering-option ${amount === String(value) ? 'chosen' : ''}`} key={value} onClick={() => setAmount(String(value))}>{value}</Button>)}<Button className='tg-offering-option' onClick={() => setError('原型未定义随机区间,请先选择固定档位或输入数量。')}>随机</Button></View>
|
||||
<Text className='tg-form-label'>爱心数量</Text><Input className='tg-input' type='number' aria-label='爱心数量' placeholder='请输入爱心数量' value={amount} onInput={e => setAmount(e.detail.value)} />
|
||||
<Text className='tg-modal-caption'>仅演示档位和余额不足提示,不进行真实捐款或扣款。</Text>{dialogActions('确定', confirmOffering)}
|
||||
</>}
|
||||
{modal === 'send-off' && <><Text className='tg-modal-title'>确认恭送{deity.ritualName}?</Text><Text className='tg-modal-copy'>上奏天恩,感谢神灵保佑</Text><Text className='tg-modal-caption'>送神将解绑该神仙,并从「我的天宫」移除。该神仙今年还剩 {state.remainingUnbinds} 次解绑机会。</Text>{state.remainingUnbinds === 0 && <Text className='tg-error'>今年解绑次数已用尽,暂不可送神解绑。</Text>}<Text className='tg-modal-caption'>仅改变本次 Mock 会话状态,不连接真实设备。</Text>{dialogActions('确定', () => perform(() => { tiangongMock.sendOff(deity.id); close(); back() }), true, state.remainingUnbinds === 0)}</>}
|
||||
{modal === 'stand' && <><Text className='tg-modal-title'>请碰一碰您购买的支架</Text><Text className='tg-modal-caption'>当前是 Mock,不会读取 NFC、蓝牙或手机设备。</Text>{dialogActions('确定', () => open('waiting'))}</>}
|
||||
{modal === 'waiting' && <><Text className='tg-modal-title'>请碰一碰您购买的手机支架</Text><View className='tg-waiting-dots'>···</View><Text className='tg-modal-caption'>模拟等待中,不会自动假装设备连接成功。下一步核对神仙并确认绑定。</Text>{dialogActions('模拟连接成功', showBindingCard)}</>}
|
||||
{modal === 'invited' && <><Text className='tg-modal-title'>请神成功,功德无量</Text><Text className='tg-modal-copy'>请敬神!</Text><Text className='tg-modal-caption'>模拟恭请成功,未扣款、未连接真实支架。</Text><View className='tg-modal-actions'><Button className='tg-button primary' onClick={close}>进入敬神</Button></View></>}
|
||||
{modal === 'insufficient' && <><Text className='tg-modal-title'>爱心余额不足,请充值!</Text><Text className='tg-modal-caption'>这是原型提示。恭请费用待确认;本次不提供真实充值。</Text>{dialogActions('确定', () => showResult('充值服务尚未接入。可回到天宫的 Mock 演示设置调整示例余额。'))}</>}
|
||||
{modal === 'result' && <><Text className='tg-modal-title'>Mock 演示结果</Text><Text className='tg-modal-copy'>{result}</Text><View className='tg-modal-actions'><Button className='tg-button primary' onClick={close}>知道了</Button></View></>}
|
||||
{modal === 'invited' && <><Text className='tg-modal-title'>请神成功</Text><Text className='tg-modal-copy'>请敬神!</Text><Text className='tg-modal-caption'>模拟恭请成功,未扣款、未连接真实支架。</Text><View className='tg-modal-actions'><Button className='tg-button primary' onClick={close}>进入敬神</Button></View></>}
|
||||
{modal === 'result' && <><Text className='tg-modal-title'>{demo ? 'Mock 演示结果' : '操作结果'}</Text><Text className='tg-modal-copy'>{result}</Text><View className='tg-modal-actions'><Button className='tg-button primary' onClick={close}>知道了</Button></View></>}
|
||||
{error && <Text className='tg-error'>{error}</Text>}
|
||||
</TiangongModal>}
|
||||
{bindingCardOpen && <DeityBindingFlow initialDeity={deity.id} onClose={() => {
|
||||
|
||||
@@ -17,6 +17,7 @@ export function updateAvatarDemoProfile(input: Partial<UserProfile>) {
|
||||
if (profile.remainingEdits <= 0) throw new Error('演示资料修改次数已用尽,刷新页面可重置')
|
||||
const fields = ['nickname', 'gender', 'birthday', 'birthHour', 'province', 'city', 'district'] as const
|
||||
fields.forEach(key => { if (typeof input[key] === 'string') profile[key] = input[key] })
|
||||
if (input.birthdayCalendar === 'solar' || input.birthdayCalendar === 'lunar') profile.birthdayCalendar = input.birthdayCalendar
|
||||
profile.remainingEdits--
|
||||
return getAvatarDemoProfile()
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ export const frontendPreviewProfile: UserProfile = {
|
||||
nickname: 'You',
|
||||
gender: '男',
|
||||
birthday: '1998-06-18',
|
||||
birthdayCalendar: 'solar',
|
||||
birthHour: '辰时',
|
||||
province: '河南省',
|
||||
city: '洛阳市',
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import Taro from '@tarojs/taro'
|
||||
import { api, getToken } from './request'
|
||||
import { finishAccountLogin } from './account'
|
||||
import { isFrontendPreview } from './frontendPreview'
|
||||
import { isAvatarDemo } from './avatarDemo'
|
||||
|
||||
// Explicit H5 sandboxes never authenticate against or mutate the real backend.
|
||||
export const hasLoginAccess = () => isFrontendPreview() || isAvatarDemo() || !!getToken()
|
||||
let loginNavigation: Promise<void> | undefined
|
||||
let registrationSession = ''
|
||||
export const getRegistrationSession = () => registrationSession
|
||||
export const clearRegistrationSession = () => { registrationSession = '' }
|
||||
|
||||
export async function beginWechatLogin() {
|
||||
if (process.env.TARO_ENV !== 'weapp' || isFrontendPreview() || isAvatarDemo()) throw new Error('请在微信开发者工具或微信小程序中登录')
|
||||
registrationSession = ''
|
||||
const login = await Taro.login()
|
||||
if (!login.code) throw new Error('微信未返回登录凭证,请重试')
|
||||
return api.wxLogin(login.code)
|
||||
}
|
||||
|
||||
export function openLogin(): Promise<void> {
|
||||
if (loginNavigation) return loginNavigation
|
||||
const task = Promise.resolve().then(async () => {
|
||||
// The entry click starts wx.login; no branded intermediate page.
|
||||
if (process.env.TARO_ENV !== 'weapp') throw new Error('请在微信开发者工具或微信小程序中登录')
|
||||
Taro.showLoading({ title: '正在登录', mask: true })
|
||||
try {
|
||||
const res = await beginWechatLogin()
|
||||
if (res.phoneBound) {
|
||||
if (!res.token || !res.user) throw new Error('登录响应不完整,请重试')
|
||||
await finishAccountLogin({ token: res.token, user: res.user })
|
||||
} else {
|
||||
if (!res.sessionKey) throw new Error('未获取到登录会话,请重试')
|
||||
registrationSession = res.sessionKey
|
||||
await Taro.navigateTo({ url: '/pages/login/index' })
|
||||
}
|
||||
} finally { Taro.hideLoading() }
|
||||
})
|
||||
.catch((error) => { Taro.showToast({ title: error instanceof Error ? error.message : '登录失败,请重试', icon: 'none' }) })
|
||||
.finally(() => { if (loginNavigation === task) loginNavigation = undefined })
|
||||
loginNavigation = task
|
||||
return task
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import Taro from '@tarojs/taro'
|
||||
import { frontendPreviewProfile, isFrontendPreview } from './frontendPreview'
|
||||
import { createResourceCache } from '../utils/resourceCache'
|
||||
import { getAvatarDemoProfile, isAvatarDemo, saveAvatarDemoImage, updateAvatarDemoProfile } from './avatarDemo'
|
||||
import type { BirthdayCalendar } from '../utils/birthdayCalendar'
|
||||
|
||||
export const BASE_URL = (typeof API_BASE_URL === 'undefined' ? 'http://101.43.139.56/hera' : API_BASE_URL).replace(/\/+$/, '')
|
||||
const profileCache = createResourceCache<UserProfile>(30000)
|
||||
@@ -26,6 +27,7 @@ export function clearLogin() {
|
||||
}
|
||||
function normalizeProfile(profile: UserProfile): UserProfile {
|
||||
return { ...profile, nickname: profile.nickname || '', birthday: profile.birthday || '',
|
||||
birthdayCalendar: profile.birthdayCalendar === 'lunar' ? 'lunar' : 'solar',
|
||||
birthHour: profile.birthHour || '', province: profile.province || '', city: profile.city || '',
|
||||
district: profile.district || '', gender: profile.gender || '未知', avatarUrl: profile.avatarUrl || '' }
|
||||
}
|
||||
@@ -46,6 +48,12 @@ export function getCachedProfile(): UserProfile | null {
|
||||
return profile ? cloneProfile(profile) : null
|
||||
}
|
||||
|
||||
// Only a still-fresh profile returned for this token can skip a route's check UI.
|
||||
export function getFreshCachedProfile(): UserProfile | null {
|
||||
const profile = profileCache.peekFresh(profileKey())
|
||||
return profile ? cloneProfile(profile) : null
|
||||
}
|
||||
|
||||
type ApiResult<T> = {
|
||||
code: number
|
||||
message: string
|
||||
@@ -182,6 +190,7 @@ export type UserProfile = {
|
||||
phone?: string
|
||||
gender: string
|
||||
birthday: string
|
||||
birthdayCalendar?: BirthdayCalendar
|
||||
birthHour: string
|
||||
province: string
|
||||
city: string
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import Taro from '@tarojs/taro'
|
||||
import { hasLoginAccess, openLogin } from './loginAccess'
|
||||
|
||||
export type MainTab = 'home' | 'fortune' | 'my'
|
||||
let switching = false
|
||||
|
||||
// Shared by the three cached pages, so repeated taps cannot build a route stack.
|
||||
export async function switchMainTab(page: MainTab, active?: MainTab) {
|
||||
if (page === active || switching) return
|
||||
if (switching || (page === active && (page !== 'fortune' || hasLoginAccess()))) return
|
||||
switching = true
|
||||
try {
|
||||
if (page === 'fortune' && !hasLoginAccess()) { await openLogin(); return }
|
||||
await Taro.switchTab({ url: `/pages/${page}/index` })
|
||||
} catch {
|
||||
Taro.showToast({ title: '页面切换失败,请重试', icon: 'none' })
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { request } from './request'
|
||||
import { getToken, request, subscribeProfile } from './request'
|
||||
import { isFrontendPreview } from './frontendPreview'
|
||||
import { isAvatarDemo } from './avatarDemo'
|
||||
import { createTiangongStore, RemoteDeity } from '../utils/tiangongStore'
|
||||
import { createTiangongMock, DeityId } from '../utils/tiangongMock'
|
||||
import wealth from '../assets/tiangong/proto-wealth.jpg'
|
||||
import fortune from '../assets/tiangong/proto-fortune.jpg'
|
||||
@@ -18,6 +21,32 @@ export const deityCatalog: Deity[] = [
|
||||
]
|
||||
export const getDeity = (id: string | undefined) => deityCatalog.find(item => item.id === id)
|
||||
export const tiangongMock = createTiangongMock()
|
||||
export const isTiangongDemo = () => isFrontendPreview() || isAvatarDemo()
|
||||
const live = createTiangongStore({
|
||||
key: getToken,
|
||||
resolve: name => deityCatalog.find(item => item.name === name || item.ritualName === name)?.id,
|
||||
load: () => request<RemoteDeity[]>('/api/tiangong/items'),
|
||||
offer: (holdingId, type, content) => request<{ id: number }>('/api/user/virtual-items/offerings', { method: 'POST', data: { userVirtualItemId: holdingId, offeringType: type, content } }),
|
||||
dedicate: (holdingId, name, content) => request<{ id: number }>('/api/user/virtual-items/dedications', { method: 'POST', data: { userVirtualItemId: holdingId, name, content } })
|
||||
})
|
||||
export const tiangongSession = {
|
||||
snapshot: () => isTiangongDemo() ? tiangongMock.snapshot() : live.snapshot(),
|
||||
refresh: (force = false) => isTiangongDemo() ? Promise.resolve() : live.refresh(force),
|
||||
subscribe: (listener: () => void) => {
|
||||
const demo = tiangongMock.subscribe(listener), remote = live.subscribe(listener)
|
||||
const account = subscribeProfile(() => { listener(); tiangongSession.refresh().catch(() => {}) })
|
||||
return () => { demo(); remote(); account() }
|
||||
},
|
||||
incense: (id: DeityId) => {
|
||||
if (!isTiangongDemo()) return live.incense(id)
|
||||
const startsRound = !tiangongMock.snapshot().deities[id].lit
|
||||
tiangongMock.incense(id)
|
||||
return { startsRound }
|
||||
},
|
||||
refreshIncense: (id: DeityId) => isTiangongDemo() ? tiangongMock.refreshIncense(id) : live.refreshIncense(),
|
||||
wish: async (id: DeityId, text: string) => { if (isTiangongDemo()) tiangongMock.wish(id, text); else await live.wish(id, text) },
|
||||
dedicate: async (id: DeityId, name: string, text: string) => { if (isTiangongDemo()) tiangongMock.dedicate(id, name, text); else await live.dedicate(id, name, text) }
|
||||
}
|
||||
export const listMockDeities = () => { const snapshot = tiangongMock.snapshot(); return deityCatalog.map(item => ({ ...item, ...snapshot.deities[item.id] })) }
|
||||
|
||||
export type DeityBindingInfo = {
|
||||
@@ -30,17 +59,12 @@ export type DeityBindingInfo = {
|
||||
}
|
||||
export type UnbindResult = { binding: DeityBindingInfo; hasAnyBoundDeity: boolean }
|
||||
|
||||
// 未接入,不会自动请求。GET沿用现有路径;所有POST及钱包/支架路径为拟定契约,待后端确认。
|
||||
// 其余绑定/送神契约尚未接入,不会自动请求。实际供奉接口见 tiangongSession。
|
||||
export const tiangongRemoteContract = {
|
||||
list: () => request<unknown[]>('/api/tiangong/items'),
|
||||
detail: (id: DeityId) => request<unknown>(`/api/tiangong/items/${id}`),
|
||||
invite: (id: DeityId, bindingId: string) => request<unknown>(`/api/tiangong/items/${id}/invite`, { method: 'POST', data: { bindingId } }),
|
||||
incense: (id: DeityId) => request<unknown>(`/api/tiangong/items/${id}/incense`, { method: 'POST' }),
|
||||
wish: (id: DeityId, text: string) => request<unknown>(`/api/tiangong/items/${id}/wishes`, { method: 'POST', data: { text } }),
|
||||
dedicate: (id: DeityId, name: string, text: string) => request<unknown>(`/api/tiangong/items/${id}/dedications`, { method: 'POST', data: { name, text } }),
|
||||
sendOff: (id: DeityId) => request<unknown>(`/api/tiangong/items/${id}/send-off`, { method: 'POST' }),
|
||||
offering: (id: DeityId, amount: number) => request<unknown>(`/api/tiangong/items/${id}/offerings`, { method: 'POST', data: { amount } }),
|
||||
wallet: () => request<unknown>('/api/tiangong/wallet'),
|
||||
binding: (bindingId: string) => request<DeityBindingInfo>(`/api/tiangong/bindings/${encodeURIComponent(bindingId)}`),
|
||||
// 拟定接口:服务端需验证NFC凭证、一物一账号及年度额度。Mock不会调用。
|
||||
resolveBinding: (nfcCredential: string) => request<DeityBindingInfo>('/api/tiangong/bindings/resolve', { method: 'POST', data: { nfcCredential } }),
|
||||
|
||||
Vendored
+5
@@ -8,6 +8,11 @@ declare module '*.png' {
|
||||
export default src
|
||||
}
|
||||
|
||||
declare module '*.m4a' {
|
||||
const src: string
|
||||
export default src
|
||||
}
|
||||
|
||||
declare module 'china-area-data' {
|
||||
const data: Record<string, Record<string, string>>
|
||||
export default data
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Lunar, LunarMonth, LunarUtil, LunarYear, Solar } from 'lunar-typescript'
|
||||
|
||||
export type BirthdayCalendar = 'solar' | 'lunar'
|
||||
export type LunarBirthday = { year: number; month: number; day: number }
|
||||
// Same supported birth-year boundary as the zodiac and five-element calculators.
|
||||
export const MIN_BIRTHDAY = '1900-01-01'
|
||||
export const beijingToday = () => new Date(Date.now() + 28800000).toISOString().slice(0, 10)
|
||||
|
||||
export function solarToLunarBirthday(value: string): LunarBirthday | null {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return null
|
||||
const date = new Date(`${value}T00:00:00Z`)
|
||||
if (Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== value) return null
|
||||
try {
|
||||
const lunar = Solar.fromYmd(...value.split('-').map(Number) as [number, number, number]).getLunar()
|
||||
return { year: lunar.getYear(), month: lunar.getMonth(), day: lunar.getDay() }
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
export function lunarBirthdayToSolar(value: LunarBirthday): string | null {
|
||||
if (![value.year, value.month, value.day].every(Number.isInteger) || value.year < 1 || value.year > 9998) return null
|
||||
try {
|
||||
const month = LunarMonth.fromYm(value.year, value.month)
|
||||
if (!month || value.day < 1 || value.day > month.getDayCount()) return null
|
||||
return Lunar.fromYmd(value.year, value.month, value.day).getSolar().toYmd()
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
export function formatLunarBirthday(solar: string): string {
|
||||
const value = solarToLunarBirthday(solar)
|
||||
if (!value) return ''
|
||||
const lunar = Lunar.fromYmd(value.year, value.month, value.day)
|
||||
return `${value.year}年 ${lunar.getMonthInChinese()}月${lunar.getDayInChinese()}`
|
||||
}
|
||||
|
||||
const monthNames = ['正', '二', '三', '四', '五', '六', '七', '八', '九', '十', '冬', '腊']
|
||||
|
||||
export function lunarBirthdayModel(input: LunarBirthday, maxSolar = beijingToday()) {
|
||||
const min = solarToLunarBirthday(MIN_BIRTHDAY), max = solarToLunarBirthday(maxSolar)
|
||||
if (!min || !max || maxSolar < MIN_BIRTHDAY) throw new Error('生日选择范围无效')
|
||||
const years = Array.from({ length: max.year - min.year + 1 }, (_, i) => min.year + i)
|
||||
const year = Math.max(min.year, Math.min(max.year, input.year))
|
||||
const serial = (solar: string) => Date.parse(`${solar}T00:00:00Z`) / 86400000
|
||||
const minDay = serial(MIN_BIRTHDAY), maxDay = serial(maxSolar)
|
||||
const months = LunarYear.fromYear(year).getMonthsInYear().map(month => {
|
||||
const number = month.getMonth()
|
||||
const first = serial(Solar.fromJulianDay(month.getFirstJulianDay()).toYmd())
|
||||
const start = Math.max(1, minDay - first + 1)
|
||||
const end = Math.min(month.getDayCount(), maxDay - first + 1)
|
||||
const days = Array.from({ length: Math.max(0, end - start + 1) }, (_, i) => start + i)
|
||||
return { value: number, label: `${number < 0 ? '闰' : ''}${monthNames[Math.abs(number) - 1]}月`, days }
|
||||
}).filter(month => month.days.length > 0)
|
||||
const month = months.find(item => item.value === input.month)
|
||||
|| months.find(item => item.value === Math.abs(input.month)) || months[0]
|
||||
const day = Math.max(month.days[0], Math.min(month.days[month.days.length - 1], input.day))
|
||||
const selection = { year, month: month.value, day }
|
||||
const indices = [years.indexOf(year), months.indexOf(month), month.days.indexOf(day)]
|
||||
const range = [years.map(value => `${value}年`), months.map(item => item.label), month.days.map(value => LunarUtil.DAY[value])]
|
||||
return { selection, indices, range, years, months, days: month.days }
|
||||
}
|
||||
|
||||
export function changeLunarBirthdayColumn(model: ReturnType<typeof lunarBirthdayModel>, column: number, index: number, maxSolar = beijingToday()) {
|
||||
const next = { ...model.selection }
|
||||
if (column === 0) next.year = model.years[index] ?? next.year
|
||||
if (column === 1) next.month = model.months[index]?.value ?? next.month
|
||||
if (column === 2) next.day = model.days[index] ?? next.day
|
||||
return lunarBirthdayModel(next, maxSolar).selection
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
export const INCENSE_DURATION_MS = 15000
|
||||
|
||||
export type RitualAudio = {
|
||||
play: () => void; stop: () => void; destroy: () => void; volume: number
|
||||
onEnded: (callback: () => void) => void
|
||||
onError: (callback: () => void) => void
|
||||
}
|
||||
|
||||
type Options = {
|
||||
getDeadline: () => number
|
||||
recordIncense: () => void | { startsRound: boolean } | Promise<void | { startsRound: boolean }>
|
||||
refresh: () => void
|
||||
soundEnabled: () => boolean
|
||||
createAudio: () => RitualAudio
|
||||
onAudioError: () => void
|
||||
now?: () => number
|
||||
schedule?: (callback: () => void, delay: number) => ReturnType<typeof setTimeout>
|
||||
cancel?: (timer: ReturnType<typeof setTimeout>) => void
|
||||
}
|
||||
|
||||
// The visual deadline follows the last tap. The audio belongs to the first tap
|
||||
// of a round and has its own cap, so extending the ritual never restarts sound.
|
||||
export function createIncenseRitual(options: Options) {
|
||||
const now = options.now || Date.now
|
||||
const schedule: NonNullable<Options['schedule']> = options.schedule || ((callback, delay) => setTimeout(callback, delay))
|
||||
const cancel = options.cancel || clearTimeout
|
||||
let visualTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let audioTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let audio: RitualAudio | undefined
|
||||
let disposed = false
|
||||
let completed = true, audioDeadline = 0
|
||||
|
||||
const releaseAudio = () => {
|
||||
if (audioTimer !== undefined) cancel(audioTimer)
|
||||
audioTimer = undefined
|
||||
const current = audio
|
||||
audio = undefined // Late events from an old context cannot touch the new one.
|
||||
if (current) {
|
||||
try { current.stop() } finally { current.destroy() }
|
||||
}
|
||||
}
|
||||
const stopAudio = () => {
|
||||
try { releaseAudio() } catch { /* Already stopped/destroyed by the platform. */ }
|
||||
}
|
||||
const armVisualTimer = () => {
|
||||
if (visualTimer !== undefined) cancel(visualTimer)
|
||||
visualTimer = undefined
|
||||
const remaining = options.getDeadline() - now()
|
||||
if (remaining <= 0 || disposed) return
|
||||
visualTimer = schedule(() => {
|
||||
visualTimer = undefined
|
||||
if (disposed) return
|
||||
if (options.getDeadline() > now()) { armVisualTimer(); return }
|
||||
completed = true
|
||||
stopAudio()
|
||||
options.refresh()
|
||||
}, remaining)
|
||||
}
|
||||
const playOnce = () => {
|
||||
stopAudio()
|
||||
let current: RitualAudio | undefined
|
||||
const fail = () => {
|
||||
if (disposed || (current && audio !== current)) return
|
||||
stopAudio()
|
||||
completed = true
|
||||
options.onAudioError()
|
||||
}
|
||||
try {
|
||||
current = options.createAudio()
|
||||
audio = current
|
||||
// Muting only changes volume: the same context keeps advancing, even
|
||||
// when a round starts muted. Enabling sound never restarts playback.
|
||||
current.volume = options.soundEnabled() ? 1 : 0
|
||||
audioDeadline = now() + INCENSE_DURATION_MS
|
||||
current.onEnded(() => { if (audio === current) { completed = true; stopAudio() } })
|
||||
current.onError(fail)
|
||||
audioTimer = schedule(() => { if (!disposed && audio === current) { completed = true; stopAudio() } }, INCENSE_DURATION_MS)
|
||||
current.play()
|
||||
} catch { fail() }
|
||||
}
|
||||
return {
|
||||
sync: armVisualTimer,
|
||||
tap() {
|
||||
if (disposed) return
|
||||
const alreadyActive = options.getDeadline() > now()
|
||||
const finish = (result: void | { startsRound: boolean }) => {
|
||||
if (disposed) return
|
||||
armVisualTimer()
|
||||
if (result ? result.startsRound : !alreadyActive) {
|
||||
completed = false
|
||||
if (options.getDeadline() > now()) playOnce()
|
||||
}
|
||||
}
|
||||
const result = options.recordIncense() // Only acknowledged server writes count.
|
||||
if (result && 'then' in result) return result.then(finish)
|
||||
finish(result as void | { startsRound: boolean })
|
||||
},
|
||||
mute() {
|
||||
if (!audio) return
|
||||
try { audio.volume = 0 } catch { completed = true; stopAudio() }
|
||||
},
|
||||
resume() {
|
||||
if (disposed || completed || !audio || !options.soundEnabled()) return
|
||||
// Timer callbacks can be delayed in a backgrounded browser/webview.
|
||||
if (options.getDeadline() <= now() || audioDeadline <= now()) { completed = true; stopAudio(); return }
|
||||
try { audio.volume = 1 } catch { completed = true; stopAudio(); options.onAudioError() }
|
||||
},
|
||||
dispose() {
|
||||
disposed = true
|
||||
if (visualTimer !== undefined) cancel(visualTimer)
|
||||
visualTimer = undefined
|
||||
stopAudio()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { UserProfile } from '../services/request'
|
||||
|
||||
export type EditableProfile = Pick<UserProfile, 'nickname' | 'gender' | 'birthday' | 'birthHour' | 'province' | 'city' | 'district'>
|
||||
export type EditableProfile = Pick<UserProfile, 'nickname' | 'gender' | 'birthday' | 'birthHour' | 'province' | 'city' | 'district'> & { birthdayCalendar: 'solar' | 'lunar' }
|
||||
export const toEditableProfile = (profile: UserProfile): EditableProfile => ({
|
||||
nickname: profile.nickname || '', gender: ['男', '女'].includes(profile.gender) ? profile.gender : '',
|
||||
birthday: profile.birthday || '', birthHour: profile.birthHour || '', province: profile.province || '',
|
||||
birthday: profile.birthday || '', birthdayCalendar: profile.birthdayCalendar === 'lunar' ? 'lunar' : 'solar',
|
||||
birthHour: profile.birthHour || '', province: profile.province || '',
|
||||
city: profile.city || '', district: profile.district || ''
|
||||
})
|
||||
export const isProfileComplete = (profile: EditableProfile) => Object.values(toEditableProfile(profile as UserProfile)).every(Boolean)
|
||||
@@ -12,6 +13,7 @@ export const hasPersonalChanges = (profile: UserProfile, form: EditableProfile)
|
||||
return Object.keys(original).some(key => original[key] !== form[key])
|
||||
}
|
||||
export function validateProfileForm(form: EditableProfile) {
|
||||
if (!['solar', 'lunar'].includes(form.birthdayCalendar)) throw new Error('请选择阳历或农历')
|
||||
if (!form.nickname.trim() || form.nickname.trim().length > 20) throw new Error('请输入1至20字的昵称')
|
||||
if (!isProfileComplete(form)) throw new Error('请完整填写个人信息')
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(form.birthday)) throw new Error('请选择有效的出生日期')
|
||||
|
||||
@@ -16,6 +16,7 @@ export function createResourceCache<T>(ttl: number, now = Date.now) {
|
||||
return {
|
||||
clear() { revision++; value = undefined; expires = 0; pending = undefined },
|
||||
peek(next: string) { select(next); return value },
|
||||
peekFresh(next: string) { select(next); return now() < expires ? value : undefined },
|
||||
seed(next: string, data: T) {
|
||||
select(next)
|
||||
revision++
|
||||
|
||||
+21
-10
@@ -1,22 +1,34 @@
|
||||
import { INCENSE_DURATION_MS } from './incenseRitual'
|
||||
|
||||
export const DEITY_IDS = ['wealth', 'fortune', 'longevity', 'wenchang', 'matchmaker'] as const
|
||||
export type DeityId = typeof DEITY_IDS[number]
|
||||
export type WorshipState = { unlocked: boolean; days: number; merit: number; incenseCount: number; lit: boolean; remainingUnbinds: number }
|
||||
export type TiangongSnapshot = { balance: number; deities: Record<DeityId, WorshipState> }
|
||||
export type WorshipState = { unlocked: boolean; days: number; incenseCount: number; lit: boolean; incenseUntil: number; remainingUnbinds: number; available?: boolean; holdingId?: number; iconUrl?: string; description?: string }
|
||||
export type TiangongSnapshot = { deities: Record<DeityId, WorshipState>; ready?: boolean; loading?: boolean; error?: string }
|
||||
export const emptyTiangongSnapshot = (): TiangongSnapshot => ({ deities: Object.fromEntries(DEITY_IDS.map(id => [id, {
|
||||
unlocked: false, days: 0, incenseCount: 0, lit: false, incenseUntil: 0, remainingUnbinds: 0, available: false
|
||||
}])) as Record<DeityId, WorshipState>, ready: false, loading: false, error: '' })
|
||||
export const isDeityId = (id: unknown): id is DeityId => DEITY_IDS.includes(id as DeityId)
|
||||
export const hasBoundDeity = (snapshot: TiangongSnapshot) => DEITY_IDS.some(id => snapshot.deities[id].unlocked)
|
||||
|
||||
// 仅当前运行会话;不读取真实账户,不保存祈愿或姓名。
|
||||
export function createTiangongMock() {
|
||||
export function createTiangongMock(now = Date.now) {
|
||||
let state: TiangongSnapshot
|
||||
const listeners = new Set<() => void>()
|
||||
const reset = () => {
|
||||
state = { balance: 100, deities: Object.fromEntries(DEITY_IDS.map(id => [id, {
|
||||
state = { ready: true, deities: Object.fromEntries(DEITY_IDS.map(id => [id, {
|
||||
unlocked: id === 'wealth', days: id === 'wealth' ? 4 : 0,
|
||||
merit: id === 'wealth' ? 99 : 0, incenseCount: id === 'wealth' ? 59 : 0, lit: false, remainingUnbinds: 2
|
||||
incenseCount: id === 'wealth' ? 59 : 0, lit: false, incenseUntil: 0, remainingUnbinds: 2, available: true
|
||||
}])) as Record<DeityId, WorshipState> }
|
||||
}
|
||||
reset()
|
||||
const snapshot = (): TiangongSnapshot => JSON.parse(JSON.stringify(state))
|
||||
const snapshot = (): TiangongSnapshot => {
|
||||
// Also reconcile after background timer throttling or returning to a page.
|
||||
DEITY_IDS.forEach(id => {
|
||||
const deity = state.deities[id]
|
||||
if (deity.lit && deity.incenseUntil <= now()) { deity.lit = false; deity.incenseUntil = 0 }
|
||||
})
|
||||
return JSON.parse(JSON.stringify(state))
|
||||
}
|
||||
const get = (id: DeityId) => { if (!isDeityId(id)) throw new Error('未找到该神仙'); return state.deities[id] }
|
||||
const owned = (id: DeityId) => { const deity = get(id); if (!deity.unlocked) throw new Error('您还未恭请此神'); return deity }
|
||||
const publish = () => { listeners.forEach(listener => listener()); return snapshot() }
|
||||
@@ -26,6 +38,7 @@ export function createTiangongMock() {
|
||||
deity.remainingUnbinds--
|
||||
deity.unlocked = false
|
||||
deity.lit = false
|
||||
deity.incenseUntil = 0
|
||||
return publish()
|
||||
}
|
||||
return {
|
||||
@@ -33,11 +46,9 @@ export function createTiangongMock() {
|
||||
subscribe: (listener: () => void) => { listeners.add(listener); return () => { listeners.delete(listener) } },
|
||||
invite: (id: DeityId) => { get(id).unlocked = true; return publish() },
|
||||
unbind, sendOff: unbind,
|
||||
incense: (id: DeityId) => { const deity = owned(id); deity.incenseCount += 1; deity.lit = true; return publish() },
|
||||
incense: (id: DeityId) => { const deity = owned(id); deity.incenseCount += 1; deity.lit = true; deity.incenseUntil = now() + INCENSE_DURATION_MS; return publish() },
|
||||
refreshIncense: (id: DeityId) => { get(id); return publish() },
|
||||
wish: (id: DeityId, text: string) => { owned(id); if (!text.trim()) throw new Error('请先输入您的愿望'); return { mock: true as const } },
|
||||
dedicate: (id: DeityId, name: string, text: string) => { owned(id); if (!name.trim() || !text.trim()) throw new Error('请填写回向对象和祝愿'); return { mock: true as const } },
|
||||
// 无真实扣减/奖励;这些业务口径尚未定义。
|
||||
offering: (id: DeityId, amount: number) => { owned(id); if (!Number.isSafeInteger(amount) || amount <= 0) throw new Error('请输入正整数爱心数'); return { mock: true as const, insufficient: amount > state.balance, amount } },
|
||||
setDemoBalance: (balance: 0 | 100) => { state.balance = balance; return publish() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { DeityId, emptyTiangongSnapshot, TiangongSnapshot } from './tiangongMock'
|
||||
import { INCENSE_DURATION_MS } from './incenseRitual'
|
||||
|
||||
export type RemoteDeity = { id: number; name: string; holdingId?: number; active: boolean; incenseCount: number; days: number; iconUrl?: string; description?: string }
|
||||
type Dependencies = {
|
||||
key: () => string; load: () => Promise<RemoteDeity[]>; resolve: (name: string) => DeityId | undefined
|
||||
offer: (holdingId: number, type: 2 | 3, content?: string) => Promise<{ id: number }>
|
||||
dedicate: (holdingId: number, name: string, content: string) => Promise<{ id: number }>
|
||||
now?: () => number
|
||||
}
|
||||
|
||||
// A live store never seeds sample ownership. Writes use the server's holding ID,
|
||||
// update counts only after acknowledgement, and are never automatically retried.
|
||||
export function createTiangongStore(deps: Dependencies) {
|
||||
const now = deps.now || Date.now
|
||||
let key = '', generation = 0, writes = 0, activeWrites = 0, expires = 0
|
||||
let state = emptyTiangongSnapshot(), pending: Promise<void> | undefined
|
||||
const listeners = new Set<() => void>()
|
||||
const select = () => {
|
||||
const next = deps.key()
|
||||
if (next !== key) { key = next; generation++; state = emptyTiangongSnapshot(); pending = undefined; expires = 0; writes = 0; activeWrites = 0 }
|
||||
}
|
||||
const snapshot = (): TiangongSnapshot => {
|
||||
select()
|
||||
Object.values(state.deities).forEach(item => { if (item.lit && item.incenseUntil <= now()) { item.lit = false; item.incenseUntil = 0 } })
|
||||
return JSON.parse(JSON.stringify(state))
|
||||
}
|
||||
const publish = () => { listeners.forEach(listener => listener()); return snapshot() }
|
||||
const assertSession = (version: number) => { select(); if (!key || generation !== version) throw new Error('登录状态已变更,请重新进入天宫') }
|
||||
const owned = (id: DeityId) => {
|
||||
select()
|
||||
const item = state.deities[id]
|
||||
if (!key || !item.unlocked || !item.holdingId) throw new Error('请先绑定对应的神仙')
|
||||
return item.holdingId
|
||||
}
|
||||
const refresh = (force = false): Promise<void> => {
|
||||
select()
|
||||
if (!key || activeWrites > 0 || (!force && state.ready && now() < expires)) return Promise.resolve()
|
||||
if (pending) return pending
|
||||
const version = generation, readWrites = writes
|
||||
state.loading = true; state.error = ''; publish()
|
||||
const task = Promise.resolve().then(deps.load).then(items => {
|
||||
assertSession(version)
|
||||
if (!Array.isArray(items)) throw new Error('神仙列表响应格式不正确')
|
||||
if (readWrites !== writes) return // A late GET cannot undo confirmed taps.
|
||||
const next = emptyTiangongSnapshot()
|
||||
items.forEach(item => {
|
||||
const id = deps.resolve(item.name)
|
||||
if (!id) return
|
||||
const old = state.deities[id]
|
||||
const active = item.active === true && Number.isSafeInteger(item.holdingId) && item.holdingId! > 0
|
||||
next.deities[id] = { ...next.deities[id], available: true, unlocked: active,
|
||||
holdingId: item.holdingId, days: Math.max(0, item.days || 0), incenseCount: Math.max(0, item.incenseCount || 0),
|
||||
iconUrl: item.iconUrl, description: item.description,
|
||||
incenseUntil: active && old.holdingId === item.holdingId ? old.incenseUntil : 0,
|
||||
lit: active && old.holdingId === item.holdingId && old.incenseUntil > now() }
|
||||
})
|
||||
state = { ...next, ready: true }; expires = now() + 15000
|
||||
}).catch(error => {
|
||||
select()
|
||||
if (generation === version) state.error = error instanceof Error ? error.message : '神仙资料加载失败'
|
||||
throw error
|
||||
}).finally(() => {
|
||||
if (generation === version) { state.loading = false; if (pending === task) pending = undefined; publish() }
|
||||
})
|
||||
pending = task
|
||||
return task
|
||||
}
|
||||
const checkedWrite = async (work: () => Promise<{ id: number }>) => {
|
||||
const version = generation
|
||||
// Invalidate reads as soon as a write starts: a GET may already include the
|
||||
// committed record before its POST acknowledgement reaches this client.
|
||||
writes++; activeWrites++; expires = 0
|
||||
try {
|
||||
const result = await work()
|
||||
assertSession(version)
|
||||
if (!Number.isSafeInteger(result?.id) || result.id <= 0) throw new Error('服务端未返回有效记录,请刷新核对后再试')
|
||||
} finally { if (generation === version) activeWrites-- }
|
||||
}
|
||||
return {
|
||||
snapshot, refresh,
|
||||
subscribe: (listener: () => void) => { listeners.add(listener); return () => { listeners.delete(listener) } },
|
||||
refreshIncense: () => publish(),
|
||||
async incense(id: DeityId) {
|
||||
const holdingId = owned(id), clickedAt = now()
|
||||
await checkedWrite(() => deps.offer(holdingId, 2))
|
||||
const item = state.deities[id], startsRound = item.incenseUntil <= now()
|
||||
if (!item.unlocked || item.holdingId !== holdingId) return { startsRound: false }
|
||||
item.incenseCount++
|
||||
item.incenseUntil = Math.max(item.incenseUntil, clickedAt + INCENSE_DURATION_MS)
|
||||
item.lit = item.incenseUntil > now(); publish()
|
||||
return { startsRound: startsRound && item.lit }
|
||||
},
|
||||
async wish(id: DeityId, text: string) {
|
||||
const holdingId = owned(id)
|
||||
if (!text.trim() || text.length > 500) throw new Error('请输入1至500字的愿望')
|
||||
await checkedWrite(() => deps.offer(holdingId, 3, text.trim()))
|
||||
},
|
||||
async dedicate(id: DeityId, name: string, text: string) {
|
||||
const holdingId = owned(id)
|
||||
if (!name.trim() || name.trim().length > 50 || !text.trim() || text.length > 500) throw new Error('请填写有效的回向对象和祝愿')
|
||||
await checkedWrite(() => deps.dedicate(holdingId, name.trim(), text.trim()))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user