feat: refine login flows and integrate tiangong worship audio

This commit is contained in:
zhouzhibo committed 2026-10-18 23:35:28 +08:00
1 parent bc7bbc52c8
commit 38fc4187ea
49 files changed
+1786 -224

No files matched your search

+45
View File
@@ -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 不模拟成真实微信授权。
+51
View File
@@ -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`。未以浏览器检查替代真机听感和远端联调。
+47
View File
@@ -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`。
+1
View File
@@ -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",
+29
View File
@@ -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 = []
+71
View File
@@ -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')
})
+97
View File
@@ -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, [])
})
+241
View File
@@ -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)
})
+86
View File
@@ -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/)
})
+5 -2
View File
@@ -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
View File
@@ -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']) {
+214
View File
@@ -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')
})
+91
View File
@@ -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.
+38
View File
@@ -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>
}
+6
View File
@@ -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; }
+9 -2
View File
@@ -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 识别'>
+18
View File
@@ -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>
}
+20
View File
@@ -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; }
}
+8
View File
@@ -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} />
}
+7
View File
@@ -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)); }
}
+17
View File
@@ -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>
}
+12
View File
@@ -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; }
+4 -3
View File
@@ -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 }
+65
View File
@@ -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)) }
}
}
}
+50
View File
@@ -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 }
}
+9 -5
View File
@@ -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
}
+17 -8
View File
@@ -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' />
+9 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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>
)
}
+6 -4
View File
@@ -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>
}
+8 -12
View File
@@ -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;
+13 -19
View File
@@ -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' />
+1 -6
View File
@@ -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; }
+42 -35
View File
@@ -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={() => {
+1
View File
@@ -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()
}
+1
View File
@@ -11,6 +11,7 @@ export const frontendPreviewProfile: UserProfile = {
nickname: 'You',
gender: '男',
birthday: '1998-06-18',
birthdayCalendar: 'solar',
birthHour: '辰时',
province: '河南省',
city: '洛阳市',
+44
View File
@@ -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
}
+9
View File
@@ -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
+3 -1
View File
@@ -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' })
+31 -7
View File
@@ -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 } }),
+5
View File
@@ -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
+67
View File
@@ -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
}
+115
View File
@@ -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()
}
}
}
+4 -2
View File
@@ -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('请选择有效的出生日期')
+1
View File
@@ -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
View File
@@ -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() }
}
}
+105
View File
@@ -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()))
}
}
}