feat: refine tiangong flows and integrate profile avatars

This commit is contained in:
zhouzhibo committed 2026-10-17 22:44:09 +08:00
1 parent 4608dbd5cf
commit 5be2ca6b50
65 files changed
+2097 -530

No files matched your search

+1
View File
@@ -3,3 +3,4 @@ dist/
dist-h5/
.swc/
.DS_Store
project.private.config.json
+13 -1
View File
@@ -16,4 +16,16 @@ npm install
npm run dev:weapp
```
后端默认请求 `http://localhost:8080`,可在 `src/services/request.ts` 修改。
后端默认请求 `http://127.0.0.1:8080`,可通过构建环境变量 `HERA_API_BASE_URL` 指定测试地址:
```bash
HERA_API_BASE_URL=https://your-test-api.example.com npm run build:weapp
```
H5 真实接口模式不要携带 `?preview=1`;该参数仅用于只读素材预览,不登录、不保存或上传。
微信注册需在微信开发者工具/小程序中完成,H5 不生成假微信登录凭证。
资料及头像联调、接口和验收说明见 [账号联调说明](docs/account-backend-integration.md)。
微信构建后自动执行 WXSS 兼容检查(也可单独运行 `npm run check:wxss`)。
本机存在微信开发者工具的 `wcsc` 时会额外编译全部 WXSS,避免 Taro 构建通过但微信样式编译失败。
其他安装位置可通过 `WECHAT_WXSS_COMPILER` 指定编译器路径;未安装时只做静态检查并明确提示。
+3 -1
View File
@@ -12,7 +12,9 @@ export default defineConfig(async () => ({
sourceRoot: 'src',
outputRoot: process.env.TARO_ENV === 'h5' ? 'dist-h5' : 'dist',
plugins: ['@tarojs/plugin-framework-react'],
defineConstants: {},
defineConstants: {
API_BASE_URL: JSON.stringify((process.env.HERA_API_BASE_URL || 'http://127.0.0.1:8080').replace(/\/+$/, ''))
},
copy: {
patterns: [],
options: {}
+66
View File
@@ -0,0 +1,66 @@
# 注册、资料与头像后端联调
## 本次接通范围
- 微信登录 → 新用户绑定手机号 → 完善资料;已注册用户直接恢复后端账号。
- 编辑页从后端回显,只有个人字段变更才提交资料更新。
- 头像通过现有后端文件存储服务上传。首页左上角、我的、编辑页共用头像组件和资料状态,成功保存后同步更新。
- 未填写生日、时辰、地区的新账号保持空值,不使用预览资料冒充真实账号。
- 登录凭证失效后清除本地会话;失效头像链接通过资料接口刷新,不在前端签名 COS 文件。
- 天宫绑定等已有隔离 Mock 未改成真实后端业务。本次没有提交或推送代码。
## 本地入口与启动
- 前端:`http://127.0.0.1:4173/#/pages/my/index`,真实模式不带 `preview=1`。
- 后端:`http://127.0.0.1:8080`,已按用户授权使用现有本地配置启动。
- `?preview=1` 保留只读样例,不保存资料、不上传头像、不调用注册接口。
- `?avatarDemo=1` 为用户要求的 H5 已登录头像演示,可直接进入我的、选择本地图片并保存,首页/我的/编辑页同步;不创建登录 token,不请求后端或 COS,刷新后重置,不影响真实登录缓存。演示入口:`http://127.0.0.1:4173/?avatarDemo=1#/pages/my/index`。
- 导入前端项目根目录的 `project.config.json`,小程序目录为 `dist/`。修改源码后运行 `npm run build:weapp`,H5 运行 `npm run build:h5`。
- 构建前可设置 `HERA_API_BASE_URL`;真机的 `127.0.0.1` 指向手机本身,必须换成手机可访问的后端地址。
- H5 不具备小程序 `wx.login` 能力,不使用伪造 code 或临时开发 JWT 绕过注册。首次真实登录请用微信开发者工具或微信。
## 开发者工具验收
1. 导入项目并编译。在“我的”或首页点“登录 / 注册”,执行微信登录。新微信用户应看到手机号、验证码输入;老用户应直接恢复对应账号。
2. 新用户输入自己的手机号,点“获取验证码”。现有后端返回开发验证码,界面明确标记“非真实短信”;填入后绑定,进入“完善注册资料”。填写昵称、性别、生日、时辰、省市区并保存,应进入“我的”,再次进入编辑页数据应一致。
3. 点我的头部进入编辑页,依次测试微信头像、相册、拍照;取消选择不提交。选择后仅本页预览,点保存才上传;保存成功后首页、我的、编辑页应显示同一张头像,重新编译/重进后仍由后端回显。
4. 只修改头像:网络面板应出现 `POST /api/user/avatar`,不出现 `PUT /api/user/profile`,资料修改次数不减少。头像最大 5MB,格式限 JPG/PNG/WebP,服务端负责最终校验。
5. 只修改个人字段:保存应出现一次资料 PUT。什么都不改直接保存不消耗次数。连续点击保存不能重复提交。
6. 同时修改头像和资料:先上传头像再保存资料。上传失败时不得提交资料 PUT;若头像成功但资料失败,应明确提示部分成功,重试不重复上传已经保存的头像。
7. 清除登录缓存或使用失效凭证,进入真实模式应提示登录,不展示其他账号或假样例。已登录头像加载失效时应向资料接口重新取链接;网络不可用时保留默认头像,不无限重试。
8. 首页、我的及编辑页在窄屏、常见机型和底部安全区检查头像裁切、表单与导航;真机检查相册/相机权限、取消和失败提示。
## 本次使用的全部账号接口
统一业务响应为 `{ code, message, data }`,成功 `code=0`。资料与上传需要 `Authorization: Bearer <token>`;失效登录 HTTP 401。
| 方法与路径 | 参数 | 返回及用途 |
| --- | --- | --- |
| POST `/api/auth/wx-login` | JSON `{code}`,由真实 `wx.login` 获取 | 新用户 `{sessionKey,phoneBound:false}`;老用户包含 `{phoneBound:true,token,user}` |
| POST `/api/auth/sms` | JSON `{phone}` | `{mockCode}`,当前开发验证码,不是真实短信服务 |
| POST `/api/auth/bind-phone` | JSON `{sessionKey,phone,captcha}` | `{token,user}`,建立/绑定正式账号后进入资料补全或我的 |
| GET `/api/user/profile` | Bearer token | 完整资料,包含 `avatarUrl`、`remainingEdits`,供回显与头像链接续取 |
| PUT `/api/user/profile` | JSON `{nickname,gender,birthday,birthHour,province,city,district}` | 完整资料;受现有后端半年 5 次限制,不附带头像字段 |
| POST `/api/user/avatar` | Bearer token,multipart 字段 `file` | 完整资料;后端调用既有 COS 存储并关联账号,不消耗资料修改次数 |
| GET `/file/access?token=…` | 后端返回的文件专用临时 token,由图片加载访问 | 302 跳转私有 COS 签名地址;不是登录 token,不在前端拼装签名 |
接口均为现有真实接口,不是本次拟定的假接口。数据库保存 `cos://` 对象引用,前端只使用后端返回的访问 URL,不持久化临时签名为对象身份。
## 已验证与边界
- 前端自动测试 58 项通过(包含日主五行入口图标、隔离头像演示、编辑页样式作用域及 WXSS 兼容回归测试),TypeScript 检查、H5 和微信构建通过。13 个产物 WXSS 均通过本机微信开发者工具自带 `wcsc` 的原生编译检查;这不替代真实登录/上传验收。
- 后端干净构建默认执行的 17 项单元/隔离测试通过:注册/资料/头像控制器链路、账号 CRUD、仅当前表启动、头像限额与格式、401、文件访问、COS 和 Redis 配置测试。4 项需要显式启用的外部集成测试在默认构建中跳过。业务写入测试使用隔离 H2,微信客户端及实际 COS 上传使用测试替身,不代表已向真实 COS 上传成功。另有 1 项当前配置数据库的只读 Mapper 集成检查通过,不读取真实账号记录或写入数据库。
- 已运行的本地后端:无登录和无效凭证请求均返回 401;资料 PUT 跨域预检返回 200 并允许 Authorization、Content-Type。前端入口 HTTP 200。
- 真实联调阶段浏览器验收曾被额度限制阻止;后续用户要求的 H5 已登录演示已在浏览器验证:从相册选择项目测试图片、保存、我的/首页/重新进入编辑页均成功加载同一本地头像。截图保存在 `/private/tmp/hera-avatar-demo-my.png`。这不是实际 COS 上传证据;真实微信授权、真实账号写入和实际 COS 上传/展示仍须完成上述开发者工具验收。
- 本地 API 为 HTTP,现有文件公开入口也可能为 HTTP。上线和真机需确认 HTTPS、合法请求/上传/下载域名、图片重定向域名,以及文件入口和签发端使用一致的配置。不能仅凭后端启动成功认定真实文件入口已经可用。
- 头像文件入口本地联调已修复:仅在后端被 Git 忽略的 `.hera-local.yml` 中将 `hera.cos.public-base-url` 覆盖为 `http://127.0.0.1:8080/file/` 并重启。使用用户本次报错对应的已有头像,只读验证本地入口返回 302、私有 COS HTTPS 图片读取返回 200;没有上传、删除文件或修改存储桶权限。远程 `101.43.139.56/file/access` 探测返回 COS 格式的 403 `AccessDenied`,远程反向代理配置未修改;上线需将文件网关请求转发后端签名入口,而非直接转发 COS 的 `/access` 对象路径。开发者工具重新编译/重进我的,以获取新头像 URL;手机真机不能使用电脑的 `127.0.0.1`。
- 资料 PUT 与头像上传是两个独立接口,部分成功会明确告知,不宣称原子事务;网络超时后服务端是否已提交需重新读取确认,后端当前没有请求幂等键。
- 修复“进入编辑页再返回,默认头像/我的元素变形”:H5 懒加载的编辑页 CSS 曾以全局 `.water-mascot` 覆盖我的页同名图标。编辑页样式现全部限定 `.edit-page`,并使用独立的 `edit-water-mascot` 类,避免双向样式冲突;不涉及头像数据或接口变更。
- 根据用户确认的数据库结构,移除 `user_account.nfc_uid` 对应的实体字段、账号 Mapper 读写与新建表定义,不执行补列或删除数据库字段。`user_virtual_item` 中独立的绑定字段不在本次账号字段移除范围。已重新部署本地后端;实际微信登录仍需开发者工具重新获取 code,不能重放已使用的 code。
- 按系统尚未上线、只采用当前表结构的要求,删除历史用户迁移源码及其测试,解除业务启动对迁移组件的依赖,不再自动补列、修改旧表或搬迁旧账号。保留首次注册和注册后再次登录的正常流程;数据库已有表与记录不删除。干净构建已确认产物没有旧迁移类,清理后的本地后端已重启。
## 与原型差异及待确认
- 保留原有页面视觉与头像选择入口。新增登录失效、资料加载失败、注册补全、上传错误和开发验证码的必要状态提示,不以样例资料掩盖失败。
- 待确认上线短信供应商及验证码策略;现有开发验证码不能作为生产短信能力。
- 待确认测试/生产 HTTPS API 和文件访问域名,真实微信账号与 COS 联调需用户在开发者工具授权验收。未改动生产域名或伪造注册凭证。
+84
View File
@@ -0,0 +1,84 @@
# 1017 绑定 / 解绑页面交付
## 已确认口径
- 绑定就是恭请,两者共用状态;任意一位已绑定即可解锁首页、生肖周运和元素报告。
- 卡片使用财神、禄星、寿星、文昌星、月老对应的现有原型图片,不使用“山鬼花钱”;界面统称“神仙”。
- 每位神仙 Mock 初始剩余 2 次解绑机会,每次成功解绑减 1,取消不扣减,重新绑定不恢复额度。0 次时不可解绑,送神也不能绕过限制。
- 仅做前端 Mock,未启动或修改后端,不申请 NFC 权限,不连接设备、不扣款、不绑定真实微信号。
## 页面与入口
1. “我的 → 我的天宫”按实际 Mock 绑定状态生成数量及神仙卡片。无绑定时显示空态和绑定入口;已绑定时点卡片打开详情,右上角“••• → 解绑神仙”打开二次确认。
2. 解绑最后一位:首页摘要及维度区域模糊遮罩、元素报告模糊遮罩,显示“须绑定神仙 / 开启你的守护之旅 / 绑定神仙 / 联系我们”;生肖弹窗只显示生肖及绑定提示,不显示周运结果。
3. 点击“绑定神仙”先打开明确标注的模拟 NFC 选择器,再打开对应神仙确认卡;关闭/取消均不绑定,点击“确认绑定”才修改 Mock 状态。确认后原页面即时解锁,不需要刷新。
4. 天宫原有请神流程保留支架提醒和模拟等待,“模拟连接成功”后增加核对神仙的确认绑定卡;确认绑定后进入原有“请神成功 / 敬神”流程。
5. 与之前的缓存 Tab 兼容,绑定、解绑、上香、重置会通知已挂载的页面,数量和门禁不会因页面缓存失去同步。
## 验收步骤
H5:`http://127.0.0.1:4173/?preview=1#/pages/my/index`。微信开发者工具重新编译项目 `dist` 目录后,从“我的”开始同样操作;真实资料读取仍需要原有后端及登录状态,H5 示例模式不需要后端。
| 点击位置 | 期望结果 |
| --- | --- |
| 初次启动 → 我的 → 我的天宫 → 财神 | 初始绑定 1 位,显示财神、DEMO 唯一 ID、示例守护天数与更多按钮 |
| 更多 → 解绑神仙 → 取消 → 再次打开 | 仍绑定财神,剩余次数仍为 2 |
| 更多 → 解绑神仙 → 确定解绑 | 财神从“我的天宫”移除,数量变为 0,模拟提示成功 |
| 首页 / 我的元素 / 我的生肖 | 分别显示首页遮罩、元素锁定遮罩、生肖绑定提示;不允许操作模糊区域的排序按钮 |
| 元素锁定页 → 绑定神仙 → 财神 → 关闭卡片 | 仍锁定,不绑定、不扣次数 |
| 绑定神仙 → 财神 → 确认绑定 | 元素报告即时解锁;首页、周运同步解锁;“我的天宫”恢复财神 |
| 财神卡片 → 更多 → 解绑 | 剩余次数是 1;再次解绑后是 0 |
| 再次绑定财神 → 更多 → 解绑 | 显示 0 次、禁用确认按钮并提示次数用尽;可取消或关闭 |
| 天宫 → 财神 → 送神(0 次) | 同样禁止送神解绑,不存在另一入口绕过限制 |
| 已有财神时,再绑定禄星 → 解绑禄星 | 数量先变 2 再变 1;首页仍解锁;财神额度不受影响 |
| 天宫 → 未恭请神仙 → 恭请 → 请神 → 支架确认 → 模拟连接成功 | 出现对应神仙确认绑定卡,关闭不绑定;确认后成功进入敬神 |
刷新网页/重启小程序会重置整个 Mock 会话(包括次数、绑定状态),不是生产系统的年度重置。也可用“天宫 → Mock 演示设置 → 重置全部演示”重置示例。
## 与原型的差异 / Mock 边界
- 依据文件夹里的 5 张截图实现。未提供独立金卡装饰、吊坠、飘带素材,金色边框/挂环使用 CSS 重建,没有把整张截图当可交互页面;神仙图片复用现有天宫原图,部分图片保留原图浅色底。
- 按用户确认,金币“山鬼花钱”替换为对应神仙;保留现有“天宫”导航名称,不恢复截图中的“灵境”。
- 真实碰一碰用显式的“Mock 模拟 NFC 识别”选择代替,展示 `DEMO-…-001` ID;一物一微信号、跨账号归属校验必须由后端完成,不能用本地状态代替。
- 只演示解绑次数递减和禁用;真实年度计数、幂等、并发扣减、安全校验均未实现。守护天数及功德沿用现有示例,未擅自新增清零/返还规则。
- “联系我们”沿用原有企业微信待配置提示,没有伪造客服接通。
- 页面锁定是 Mock 的界面状态,不是服务端安全权限控制;正式上线必须在服务端控制报告访问与绑定归属。
## 接口清单
本次绑定/解绑/天宫操作均调用隔离的 `tiangongMock`,不发业务网络请求。真实接口仅预留于 `src/services/tiangong.ts` 的 `tiangongRemoteContract`,没有在页面调用。
| 状态 | 方法 / 路径 | 用途 |
| --- | --- | --- |
| 既有;H5 preview 使用示例,不请求后端 | GET `/api/user/profile` | 首页及我的资料;资料修改/头像接口沿用,不在本次绑定流程调用 |
| 既有前端路径,天宫本次不调用 | GET `/api/tiangong/items` | 神仙列表、绑定/恭请状态,供收藏与解锁状态使用 |
| 既有前端路径,天宫本次不调用 | GET `/api/tiangong/items/{id}` | 神仙详情、介绍、统计 |
| 拟定,待联调 | POST `/api/tiangong/items/{id}/invite` | 确认绑定/恭请;请求 `bindingId` |
| 拟定,待联调 | GET `/api/tiangong/bindings/{bindingId}` | 绑定信息、唯一 ID、守护天数、剩余次数 |
| 本次新增拟定契约,待确认 | POST `/api/tiangong/bindings/resolve` | 校验 NFC 凭证并返回待绑定信息;请求 `nfcCredential` |
| 本次新增拟定契约,待确认 | POST `/api/tiangong/bindings/{bindingId}/unbind` | 原子解绑并返回剩余次数、是否仍有绑定神仙 |
| 拟定,待联调 | POST `/api/tiangong/items/{id}/send-off` | 送神;应与解绑共享服务端额度和归属检查 |
| 拟定,待联调 | POST `/api/tiangong/items/{id}/incense` | 上香 |
| 拟定,待联调 | POST `/api/tiangong/items/{id}/wishes` | 祈愿,`text` |
| 拟定,待联调 | POST `/api/tiangong/items/{id}/dedications` | 回向,`name`、`text` |
| 拟定,待联调 | POST `/api/tiangong/items/{id}/offerings` | 爱心功德档位,`amount`;不是支付接口 |
| 拟定,待联调 | GET `/api/tiangong/wallet` | 爱心余额 |
绑定返回类型 `DeityBindingInfo`:`bindingId`、`deityId`、`uniqueId`、`bound`、`guardedDays`、`remainingUnbinds`。解绑返回 `UnbindResult`:`binding` 和 `hasAnyBoundDeity`。路径与字段均未假定已被后端实现。
## 后续真实联调待确认
1. NFC 标签内容、识别协议、微信端入口、服务端签名验证方式;已被他人绑定、凭证失效、设备不支持时的提示与恢复流程。
2. 正式年度额度何时重置、按什么时区;解绑后守护天数、功德、上香记录如何保留或清零。当前仅沿用已有 Mock 记录,不自行设定新规则。
3. 企业微信客服参数,以及绑定/解绑接口鉴权、幂等键、并发冲突与错误码。
这些问题不阻碍当前已经确认的 Mock 演示,但正式接入前需要明确。
## 验证记录
- 41 项自动化测试通过,覆盖最后一位解绑重新锁定、任意绑定解锁、逐神仙额度、重绑不恢复次数、0 次时解绑/送神都被拒绝、订阅通知及原有首页/生肖/元素规则。
- TypeScript 检查、H5 和微信小程序代码构建通过;仍有既有 vendor/chunk 体积提示,没有编译错误。未进行微信真机 NFC 或帧率验收。
- 浏览器实际走通取消解绑、最后一位解绑、首页/生肖/元素锁定、元素页重绑即时解锁、天宫原有请神流程确认绑定、周运恢复、两位绑定的独立列表、单独解绑保留另一位、2→1→0 后重绑仍为0、解绑按钮置灰和送神入口同样禁用。
- 浏览器 CSS 视口 320×448、340×531、432×932 检查卡片、确认弹窗、首页与元素遮罩,未见横向溢出;小屏卡片关闭按钮与确认/取消可见。已恢复临时视口设置。
- 截图:`/private/tmp/hera-bind-confirm.png`、`/private/tmp/hera-binding-card.png`、`/private/tmp/hera-unbind-confirm.png`、`/private/tmp/hera-unbind-exhausted.png`、`/private/tmp/hera-home-unbound.png`、`/private/tmp/hera-element-unbound.png`、`/private/tmp/hera-zodiac-unbound.png`。
- 未自动提交或推送代码;未启动或修改后端。
+37
View File
@@ -0,0 +1,37 @@
# 我的元素:按日主五行显示图标
## 已确认口径
入口复用当前元素报告的 `masterElement`,不按五行最高占比或喜用元素选择。
| 日主天干 | 元素 | 图标 |
| --- | --- | --- |
| 甲、乙 | 木 | 绿色叶片 |
| 丙、丁 | 火 | 橙色火焰 |
| 戊、己 | 土 | 棕色石块 |
| 庚、辛 | 金 | 金色圆币 |
| 壬、癸 | 水 | 蓝色水滴 |
保留“我的元素”标题和原有入口布局,图形加元素小标识;个人默认头像及上传头像不参与这套映射。
资料缺失或不能计算有效报告时,显示中性问号,点开沿用原有完善出生信息提示,不虚构默认五行。
## 更新与接口
- 继续使用既有资料 GET `/api/user/profile` 提供的生日和出生时辰,在前端生成同一份报告。
- 编辑资料保存仍使用 PUT `/api/user/profile`。保存回传资料通过现有订阅更新报告与入口图标,无需新接口。
- 仅日主五行发生变化时图标才变化。同五行的不同生日,以及只改昵称、头像或其他非排盘字段,不要求更换图标。
- 不修改后端报告、计算规则或个人头像;没有新增后端接口,也没有操作真实用户资料。
## 验收
1. 微信开发者工具重新编译,进入我的,查看元素入口图标。点开报告,确认图标与“日主”属性一致,而非最大百分比。
2. 无需为测试消耗真实账号的半年修改额度。使用 H5 隔离演示入口 `http://127.0.0.1:4173/?avatarDemo=1#/pages/my/index` 验证修改联动。
3. 演示时辰保持辰时,生日从 `1998-06-18` 起依次改为 `20`、`22`、`24`、`26` 日并保存,分别应显示火、土、金、水、木;默认头像保持不变。演示仅在本地保存,刷新重置。
4. 进入编辑页后不修改直接返回,元素图标不应变形;入口图标与生肖图标保持对齐,底部导航不遮挡。
## 已完成检查
- 58 项前端测试、TypeScript 检查、H5/微信构建通过。
- 13 个产物 WXSS 通过微信开发者工具自带编译器检查。
- 浏览器隔离演示实际验证五个日期的保存切换,以及入口与日主详情一致、窄屏对齐;没有将模拟资料写入真实后端。
- 截图:`/private/tmp/hera-day-master-fire.png`、`/private/tmp/hera-day-master-earth.png`、`/private/tmp/hera-day-master-metal.png`、`/private/tmp/hera-day-master-water.png`、`/private/tmp/hera-day-master-wood.png`。
+30
View File
@@ -0,0 +1,30 @@
# 财神背景与主导航优化(2026-10-17)
## 修改
- 神像区独立使用等比云纹背景,不再把背景拉伸到供桌。H5 `aspectFit` 内部图片显式居中,长屏神像落在供桌上方。
- 供桌使用固定比例裁切容器去掉素材白色侧边,未点香/已点香共用同一位置,切换时不移动。
- 首页、天宫、我的均注册为真实 Tab,统一 `switchTab`,不再混用入栈和重定向。H5 隐藏框架自带栏并恢复完整高度,只展示现有自绘导航。
- 页面实例保留,首页日期选择、我的生日显示状态不因切换重置;重复点击防止重入。
- 资料增加 30 秒内存缓存与并发请求合并,过期后台刷新时保留快照,失败提示正在使用上次资料。账号隔离;资料/头像保存成功后用服务器返回值更新缓存,编辑页强制读取最新资料。
## 验收
H5 打开 `http://127.0.0.1:4173/?preview=1#/pages/fortune/index`。微信开发者工具重新编译 `dist` 后按相同步骤操作(真实资料需要已有后端与登录状态,本次不启动后端)。
1. 天宫 → 财神:神像、香炉处于页面中轴;背景不横向拉伸,供桌两侧没有白色窄条。点击上香:供桌点亮但位置不跳动,五个按钮完整显示。
2. 首页 → 选明日 → 天宫 → 我的 → 首页:仍选中明日;无重复导航,不反复出现整页资料加载占位。
3. 我的 → 显示生日 → 天宫 → 我的:生日显示状态保留,点击开关不进入编辑页。
4. 在真实联调环境保存生日/时辰后返回:首页、我的使用新资料重算;预览模式仍不保存,不假装保存成功。
5. 小屏、长屏反复切换三个入口并返回详情:底部不被裁切,没有横向滚动;连续点击不叠加路由。
## 边界与接口
本次无新增业务接口,无后端修改。沿用 `GET /api/user/profile`、`PUT /api/user/profile`、`POST /api/user/avatar`,仅调整前端缓存;登录成功跳转同步改为 Tab 跳转。天宫仍是原有隔离 Mock,不扣款、不连接设备;未确认分支及拟定接口继续见 [天宫交付说明](./tiangong-delivery.md)。
## 验证记录
- 37 项自动化测试、TypeScript 检查、H5 与微信代码构建通过;仍存在既有 vendor/chunk 体积警告。
- 浏览器确认三个 Tab 各保留一个 DOM 页面实例,隐藏页面不显示;首页“明日”、我的生日显示状态切回后保留,框架默认导航隐藏。
- 340×531、320×568、432×932 CSS 视口检查财神,神像图像中轴与容器中轴一致,供桌切换前后同尺寸同位置,五个动作完整,没有横向溢出。
- 不将浏览器验证与构建通过等同于微信真机验收;未做真机帧率测量,首次资源加载仍受设备与网络影响。
+97
View File
@@ -0,0 +1,97 @@
# 天宫 Mock 原型交付
本次来源:[墨刀 → 洪贞小程序 → 天宫](https://modao.cc/proto/yx6d0Jptt7nctvrsIevgd0/sharing?view_mode=read_only)。不使用“洪贞app”画布。
## 入口与运行
- 本地预览:[天宫](http://127.0.0.1:4173/?preview=1#/pages/fortune/index)。只能在运行预览服务的电脑上使用。
- 微信开发者工具导入仓库根目录,`project.config.json` 指向 `dist/`。先运行小程序构建,再从首页或我的底部点“天宫”;也可设置编译启动页为 `pages/fortune/index`。
- 天宫模块当前始终使用隔离 Mock 服务,不是请求后端失败后偷偷兜底;已有首页/个人资料接口不改变。
- 初始:财神已恭请;其余四位未恭请。示例爱心 100;财神供奉 4 天、功德 99、上香 59 次。其余神仙初始统计为零,均为样例,非真实账户。
- 状态仅保存在当前 JS 运行会话;同一会话内往返页面同步。刷新 H5、重启小程序或点“重置全部演示”恢复样例。愿望与姓名不上传、不持久化,提交后清空。
## 验收步骤与预期
| 操作 | 预期 |
| --- | --- |
| 首页/我的 → 底部天宫 | 三列五个原型图像卡片,暖白背景、浅绿名称条;底部首页/天宫/我的文字对齐、不裁切 |
| 财神卡片 | 直接进入敬神;云纹背景、红衣财神、供桌和五个竖向按钮,不是整页截图 |
| 上香 | 供桌出现水果、香,59 变 60;爱心和功德不擅自扣减/增加 |
| 祈愿 → 空提交 | 显示非空提示,不提交;输入测试愿望后提交只显示 Mock 结果,无真实上传 |
| 回向 | 显示当前神仙供奉天数、功德、次数;姓名/祝愿完整填写后可模拟提交;关闭或取消可回到详情 |
| 功德 → 9/99/168 | 选中态、输入值同步。100 余额选择 168 → 余额不足;确定只解释充值未接入,不打开支付 |
| 功德 → 9 → 确定 | 只显示模拟选择结果,余额仍为 100,不产生捐款或功德奖励 |
| 功德 → 随机 | 显示随机区间待确认提示,不生成自创金额 |
| 送神 → 取消 | 保留当前已恭请状态 |
| 送神 → 确定 | 返回天宫,对应卡片转未恭请;其他神仙不变;保留演示历史统计,不假设清零规则 |
| 禄星等未恭请卡片 | 卡片内出现“您还未恭请此神 / 是否恭请 / 恭请”,点 × 关闭;恭请进入对应请神页 |
| 请神 → 确定 | 支架提醒 → 模拟等待;取消不改变恭请状态;无 NFC/蓝牙权限请求 |
| 等待 → 模拟连接成功 | 成功弹窗 → 进入对应敬神;返回列表该神仙变彩色。不会把禄星错误显示为财神 |
| 天宫 → Mock 演示设置 → 设为 0 | 未恭请神仙点请神可验收余额不足的演示路径;这不是定义了真实恭请价格 |
| Mock 演示设置 → 重置全部演示 | 恢复初始卡片、100 爱心、59 上香次数 |
| 直接打开详情且 id 无效 | 显示未找到并可返回,不默认冒充财神 |
| 窄屏/矮屏、长回向表单 | 弹窗受视口和安全区约束,内容可滚动,按钮不裁切;底部导航/五个动作可操作 |
## 与原型/逻辑的差异
1. 加入明确 Mock 提示、返回/关闭入口、基本空值保护、操作结果说明及演示设置,仅为安全验收;真实生产文案可联调后收敛。
2. 原型未恭请提示的卡片内样式已还原;供桌分层素材、上香图像切换、计数 +1、祈愿/回向/送神弹窗来自实际演示点击。
3. 原型功德弹窗、两个余额不足弹窗、成功提示在总览可读。演示中点击顶部数字、香炉、可见功德热点均未能打开功德弹窗,因此本版新增明确“功德”入口使其可验收,不声称这一入口位置已经由原型跳转确认。
4. 原型支架等待会自动推进/退出;本版改为明确“模拟连接成功”,可取消,不假装连接了设备。未接入 NFC、蓝牙、支架、支付、捐款、充值。
5. 只有财神、禄星的独立详情神像可从当前画布读取。寿星/文昌星/月老使用各自列表原图并在请神页标明,不复用财神冒充;这三张完整详情神像仍待提供。
6. 原型“禄星”介绍正文实际写“上清灵宝天尊”。原文保留并标记不一致,其他四位不自编介绍;神仙画像/名称口径待用户确认。
7. 当前“善信缘主”用 You(示例),不把原型内王开运当作真实用户。天数、累计功德为演示数据,未定义真实计时与奖励算法。
8. 无随机区间、请神价格、上香扣减和送神重置规则,因此不实现这些真实业务;只提供已知余额不足/成功的可控演示。
9. 全部新图像来自墨刀当前已观察到的原始文件,在本地缩放压缩,不依赖运行时墨刀图床;未生成替代神像。
## 全部接口清单
实际页面仅调用 `services/tiangong.ts` 的 `listMockDeities`、`tiangongMock.snapshot/invite/incense/wish/dedicate/sendOff/offering/setDemoBalance/reset`,没有真实天宫网络请求。
服务层保留了 `tiangongRemoteContract`,从未在页面调用;不是已上线或已联调的能力。沿用既有请求层的鉴权方式。以下 `id` 是神仙 id,`bindingId` 是未来设备绑定记录 id。
| 状态 | 方法 | 路径 | 用途/请求字段 |
| --- | --- | --- | --- |
| 前端原已有,未验证后端 | GET | `/api/tiangong/items` | 神仙列表、原图及用户恭请状态 |
| 前端原已有,未验证后端 | GET | `/api/tiangong/items/{id}` | 神仙详情、介绍与统计 |
| 拟定,待确认 | POST | `/api/tiangong/items/{id}/invite` | 恭请,`bindingId` |
| 拟定,待确认 | POST | `/api/tiangong/items/{id}/incense` | 上香 |
| 拟定,待确认 | POST | `/api/tiangong/items/{id}/wishes` | 祈愿,`text` |
| 拟定,待确认 | POST | `/api/tiangong/items/{id}/dedications` | 回向,`name`、`text` |
| 拟定,待确认 | POST | `/api/tiangong/items/{id}/send-off` | 送神 |
| 拟定,待确认 | POST | `/api/tiangong/items/{id}/offerings` | 爱心功德,`amount`,非真实支付接口 |
| 拟定,待确认 | GET | `/api/tiangong/wallet` | 爱心余额 |
| 拟定,待确认 | GET | `/api/tiangong/bindings/{bindingId}` | 读取绑定/连接状态,不代替真实硬件实现 |
以上拟定路径、返回字段、幂等键、错误码、原子扣款和硬件授权协议都必须经后端确认;不新增猜测的充值接口。
## 待确认问题
2026-10-17 更新:绑定即恭请、每位初始 2 次解绑、重新绑定不恢复额度、0 次禁止解绑已由用户确认,并已补充到 Mock。绑定/解绑与门禁新说明以 [1017 交付](./deity-binding-delivery.md) 为准;以下保留原交付的其他待确认项。
1. 禄星名称、详情画像与“灵宝天尊”正文应统一为哪一位?财神用“武财神”的原型称谓是否准确?请提供另外三位详情神像与五位正式介绍。
2. 原型“功德”弹窗正式入口在哪里?是否保留当前显式入口?
3. 恭请是否消耗爱心,各神价格是多少?上香频率、消耗、功德奖励如何计算?送神是否清零统计、解除支架绑定、需要二次验证?
4. 随机爱心的范围、可用金额规则是什么?真实充值/捐款是否本期接入及其合规、支付流程是什么?
5. 支架通过 NFC、蓝牙还是其他协议?超时/失败/重复绑定/取消应该如何处理?
6. 祈愿和回向文本长度、是否必填、隐私存储、保留时长和审核规则是什么?目前仅做非空前端保护,不落库。
## 素材记录
导出工具为浏览器 pageAssets,原文件 `/uploads7/images/…`:
- 五个列表图:`184914331/v2_tm2xlt.png`(财神)、`184914339/v2_tm2xnq.png`(禄星)、`184914344/v2_tm2xp4.png`(寿星)、`184914388/v2_tm2y7b.png`(文昌星)、`184914396/v2_tm2y9p.png`(月老)。
- 详情神像:`184951976/v2_tm4prs.png`(财神)、`184951979/v2_tm4ps7.png`(禄星)。
- 云纹:`184328087/v2_tlb2ag.png`;原始响应实际编码为 JPEG,已更正本地后缀。
- 供桌:`184328109/v2_tlb2f3.jpg`,点香供桌:`184328108/v2_tlb2f2.jpg`。
- `scripts/optimize-tiangong-assets.cjs` 仅作构建时缩放/压缩,保留神像透明度,需 sharp;不进行绘制或替换内容。
## 验证记录
- 32 项测试(包含 7 项新天宫测试)通过;TypeScript 检查通过。
- H5 与微信小程序代码构建通过。打包成功不等于微信真机/上传审核通过,尚无微信开发者工具真机验收。
- 新素材约 520 KB,小程序构建文件实际合计约 1.79 MB;构建仍有既有 vendor/chunk 性能提示,无编译错误。
- 浏览器实际走通未恭请、模拟绑定、成功进入禄星详情、上香、祈愿空值/有效提交、回向有效提交、功德 168 余额不足、未接入充值说明、送神取消/确定和返回列表变灰。
- 实测浏览器 CSS 视口 320×568、432×932 无横向溢出,导航文字/五个动作基线一致;320×448 的长回向弹窗受视口约束并可滚动取消。此项不代表所有微信真机已经验收。
- 还验收了演示余额设为 0、寿星恭请余额不足路径;缺少独立神像/介绍时展示说明,不错误复用财神。
- 不自动提交或推送代码,未启动后端。素材导出调用异常耗时,任务实际交付晚于计划时间。
+8 -2
View File
@@ -3,12 +3,18 @@
"version": "0.0.1",
"private": true,
"scripts": {
"test:elements": "node --test scripts/fiveElements.test.cjs",
"test:account": "node --test scripts/accountIntegration.test.cjs scripts/frontendPreview.test.cjs",
"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: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",
"test:home": "node --test scripts/homeOverview.test.cjs",
"dev:weapp": "taro build --type weapp --watch",
"build:weapp": "taro build --type weapp"
"build:weapp": "taro build --type weapp && node scripts/checkWxss.cjs",
"check:wxss": "node scripts/checkWxss.cjs",
"build:h5": "taro build --type h5"
},
"dependencies": {
"@babel/runtime": "^7.25.6",
+189
View File
@@ -0,0 +1,189 @@
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 fullProfile = { id: 9, nickname: '测试用户', gender: '女', birthday: '1998-06-18', birthHour: '辰时',
province: '北京市', city: '北京市', district: '朝阳区', remainingEdits: 4, avatarUrl: '', elementReport: null }
test('编辑页所有编译样式均限定页面,默认头像不复用我的元素水滴类', () => {
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/pages/profile-edit/index.scss') }).css.toString()
let checked = 0
postcss.parse(css).walkRules(rule => {
rule.selectors.forEach(selector => {
assert.match(selector, /^\.edit-page(?:\s|[.:#>+~\[]|$)/, `编辑页样式泄漏: ${selector}`)
checked++
})
})
assert.ok(checked > 40)
assert.match(css, /\.edit-page \.edit-water-mascot/)
assert.doesNotMatch(css, /\.water-mascot\b/)
const source = fs.readFileSync(path.join(root, 'src/pages/profile-edit/index.tsx'), 'utf8')
assert.match(source, /className='edit-water-mascot'/)
assert.doesNotMatch(source, /className='water-mascot'/)
})
function setup(token = 'test-session') {
let profile = { ...fullProfile }
const storage = { token }
const calls = [], routes = []
const taro = { default: {
getStorageSync: key => storage[key],
setStorageSync: (key, value) => { storage[key] = value },
removeStorageSync: key => { delete storage[key] },
request: async options => {
calls.push(options)
if (options.method === 'PUT') profile = { ...profile, ...options.data, remainingEdits: profile.remainingEdits - 1 }
return { statusCode: 200, data: { code: 0, data: { ...profile } } }
},
uploadFile: async options => {
calls.push(options)
profile = { ...profile, avatarUrl: 'https://files.example.test/file/access?token=test-file-token' }
return { statusCode: 200, data: JSON.stringify({ code: 0, data: profile }) }
},
switchTab: async options => { routes.push(options.url) },
redirectTo: async options => { routes.push(options.url) }
} }
const cache = {}
function load(name) {
if (cache[name]) return cache[name]
const exports = {}
cache[name] = exports
vm.runInNewContext(ts.transpileModule(fs.readFileSync(path.join(root, `src/${name}.ts`), 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2019 }
}).outputText, { exports, process: { env: { TARO_ENV: 'h5' } }, window: { location: { search: '' } }, URLSearchParams,
require: id => id === '@tarojs/taro' ? taro : load(path.posix.normalize(path.posix.join(path.posix.dirname(name), id))) })
return exports
}
return { service: load('services/request'), editing: load('utils/profileEditing'), account: load('services/account'),
taro: taro.default, storage, calls, routes }
}
test('无登录凭证不发送个人资料请求;401清除缓存并同步所有订阅页面', async () => {
const missing = setup('')
await assert.rejects(missing.service.api.profile(), /请先登录/)
assert.equal(missing.calls.length, 0)
const ctx = setup()
await ctx.service.api.profile()
const updates = []
const unsubscribe = ctx.service.subscribeProfile(profile => updates.push(profile))
ctx.taro.request = async () => ({ statusCode: 401, data: { code: 401, message: '请重新登录' } })
await assert.rejects(ctx.service.api.profile(true), /失效|登录/)
assert.equal(ctx.storage.token, undefined)
assert.equal(ctx.service.getCachedProfile(), null)
assert.equal(updates.at(-1), null)
unsubscribe()
})
test('头像经专用COS后端接口上传,Bearer字段正确,禁止H5跨域Cookie', async () => {
const ctx = setup()
const updates = []
ctx.service.subscribeProfile(profile => updates.push(profile))
const profile = await ctx.service.api.uploadAvatar('blob:local-avatar')
const upload = ctx.calls[0]
assert.equal(upload.url, 'http://127.0.0.1:8080/api/user/avatar')
assert.equal(upload.name, 'file')
assert.equal(upload.filePath, 'blob:local-avatar')
assert.equal(upload.header.Authorization, 'Bearer test-session')
assert.equal(upload.header['content-type'], undefined)
assert.equal(upload.withCredentials, false)
assert.equal(profile.avatarUrl, updates.at(-1).avatarUrl)
assert.equal(ctx.service.getCachedProfile().avatarUrl, profile.avatarUrl)
assert.equal((await ctx.service.api.profile()).avatarUrl, profile.avatarUrl)
assert.equal(ctx.calls.length, 1)
})
test('上传失败不覆盖已有头像;HTML 401也清理会话', async () => {
const ctx = setup()
await ctx.service.api.profile()
ctx.taro.uploadFile = async () => ({ statusCode: 400, data: JSON.stringify({ code: 400, message: '仅支持 JPG、PNG 或 WebP 头像' }) })
await assert.rejects(ctx.service.api.uploadAvatar('bad.gif'), /仅支持/)
assert.equal(ctx.service.getCachedProfile().avatarUrl, '')
ctx.taro.uploadFile = async () => ({ statusCode: 401, data: '<html>unauthorized</html>' })
await assert.rejects(ctx.service.api.uploadAvatar('test.png'), /失效|登录/)
assert.equal(ctx.storage.token, undefined)
})
test('新注册空资料保持空值,老用户直接保存token进入我的', async () => {
const ctx = setup('')
await ctx.account.finishAccountLogin({ token: 'new-session', user: { ...fullProfile, birthday: null, birthHour: null,
gender: '未知', province: null, city: null, district: null } })
assert.equal(ctx.storage.token, 'new-session')
assert.equal(ctx.service.getCachedProfile().birthday, '')
assert.equal(ctx.editing.toEditableProfile(ctx.service.getCachedProfile()).gender, '')
assert.equal(ctx.routes.at(-1), '/pages/profile-edit/index?mode=register')
await ctx.account.finishAccountLogin({ token: 'returning-session', user: fullProfile })
assert.equal(ctx.routes.at(-1), '/pages/my/index')
assert.equal(ctx.service.getCachedProfile().id, 9)
})
test('单独更新头像不调用资料PUT,且不受资料额度为0限制', async () => {
const ctx = setup()
let personalWrites = 0
const initial = { ...fullProfile, remainingEdits: 0 }
const result = await ctx.editing.saveProfileEdits(initial, ctx.editing.toEditableProfile(initial), 'avatar.png', {
uploadAvatar: async () => ({ ...initial, avatarUrl: '/file/access?token=fixture' }),
updateProfile: async () => { personalWrites++; return initial }
}, () => {})
assert.equal(personalWrites, 0)
assert.equal(result.profile.remainingEdits, 0)
assert.equal(result.changed, true)
})
test('头像上传失败不消费个人资料额度;先上传再保存且只PUT一次', async () => {
const ctx = setup()
const form = { ...ctx.editing.toEditableProfile(fullProfile), nickname: '新昵称' }
const order = []
const writer = {
uploadAvatar: async () => { order.push('upload'); throw new Error('COS不可用') },
updateProfile: async () => { order.push('put'); return { ...fullProfile, ...form, remainingEdits: 3 } }
}
await assert.rejects(ctx.editing.saveProfileEdits(fullProfile, form, 'avatar.png', writer, () => {}), /COS/)
assert.deepEqual(order, ['upload'])
order.length = 0
writer.uploadAvatar = async () => { order.push('upload'); return { ...fullProfile, avatarUrl: '/avatar.png' } }
await ctx.editing.saveProfileEdits(fullProfile, form, 'avatar.png', writer, () => order.push('sync'))
assert.deepEqual(order, ['upload', 'sync', 'put'])
})
test('头像成功而资料失败会明确提示;重试不再次上传文件', async () => {
const ctx = setup()
const form = { ...ctx.editing.toEditableProfile(fullProfile), nickname: '新昵称' }
let uploads = 0, writes = 0, uploaded
const writer = {
uploadAvatar: async () => { uploads++; return { ...fullProfile, avatarUrl: '/saved.png' } },
updateProfile: async () => { writes++; throw new Error('资料暂不可用') }
}
await assert.rejects(ctx.editing.saveProfileEdits(fullProfile, form, 'avatar.png', writer, profile => { uploaded = profile }), /头像已更新,个人资料未保存/)
writer.updateProfile = async () => { writes++; return { ...uploaded, ...form } }
await ctx.editing.saveProfileEdits(uploaded, form, '', writer, () => {})
assert.equal(uploads, 1); assert.equal(writes, 2)
})
test('不发送未更改的个人资料;校验非法日期、缺失信息和昵称', async () => {
const ctx = setup()
let writes = 0
const form = ctx.editing.toEditableProfile(fullProfile)
const unchanged = await ctx.editing.saveProfileEdits(fullProfile, form, '', { uploadAvatar: async () => fullProfile,
updateProfile: async () => { writes++; return fullProfile } }, () => {})
assert.equal(unchanged.changed, false); assert.equal(writes, 0)
for (const patch of [{ nickname: '' }, { gender: '' }, { birthday: '2026-02-30' }, { birthday: '2999-01-01' }]) {
assert.throws(() => ctx.editing.validateProfileForm({ ...form, ...patch }))
}
})
test('三个位置共用头像组件,编辑失败不回退假资料,不再使用dev-code登录', () => {
for (const page of ['home', 'my', 'profile-edit']) {
const source = fs.readFileSync(path.join(root, `src/pages/${page}/index.tsx`), 'utf8')
assert.match(source, /UserAvatar/)
assert.doesNotMatch(source, /setProfile\(previewProfile\)/)
}
for (const page of ['home', 'my']) assert.match(fs.readFileSync(path.join(root, `src/pages/${page}/index.tsx`), 'utf8'), /subscribeProfile/)
assert.doesNotMatch(fs.readFileSync(path.join(root, 'src/pages/login/index.tsx'), 'utf8'), /dev-code/)
assert.match(fs.readFileSync(path.join(root, 'src/components/UserAvatar.tsx'), 'utf8'), /api.profile\(true\)/)
})
+54
View File
@@ -0,0 +1,54 @@
const fs = require('node:fs')
const path = require('node:path')
const { spawnSync } = require('node:child_process')
const runner = require.resolve('@tarojs/webpack5-runner')
const postcss = require(require.resolve('postcss', { paths: [runner] }))
const parseSelector = require(require.resolve('postcss-selector-parser', { paths: [runner] }))
const dist = path.resolve(__dirname, '../dist')
function collect(folder) {
return fs.readdirSync(folder, { withFileTypes: true }).flatMap(entry => {
const file = path.join(folder, entry.name)
return entry.isDirectory() ? collect(file) : file.endsWith('.wxss') ? [file] : []
})
}
function validateWxss(css, label) {
postcss.parse(css).walkRules(rule => {
if (rule.parent.type === 'atrule' && /keyframes$/i.test(rule.parent.name)) return
parseSelector(selectors => selectors.walkUniversals(() => {
throw new Error(`${label}:WXSS 不支持通配选择器 ${rule.selector}`)
})).processSync(rule.selector)
parseSelector(selectors => selectors.walkPseudos(pseudo => {
if (/^:(?:first|last|nth|only)-(?:child|of-type)$/.test(pseudo.value)
&& (!pseudo.prev() || pseudo.prev().type === 'combinator')) {
throw new Error(`${label}:WXSS 结构伪类前需明确元素或类名 ${rule.selector}`)
}
})).processSync(rule.selector)
})
}
function main() {
if (!fs.existsSync(path.join(dist, 'app.wxss'))) throw new Error('请先构建微信小程序')
const files = collect(dist)
for (const file of files) validateWxss(fs.readFileSync(file, 'utf8'), path.relative(dist, file))
console.log(`WXSS 选择器兼容检查通过:${files.length} 个文件`)
const compiler = process.env.WECHAT_WXSS_COMPILER
|| '/Applications/wechatwebdevtools.app/Contents/Resources/app.asar.unpacked/node_modules/wcc-exec/wcsc'
if (!fs.existsSync(compiler)) {
if (process.env.WECHAT_WXSS_COMPILER) throw new Error('指定的微信 WXSS 编译器不存在')
console.warn('未找到微信原生 WXSS 编译器:本次仅完成静态检查,仍需开发者工具编译验收。可设置 WECHAT_WXSS_COMPILER。')
} else {
// The native compiler resolves @import from its supplied file collection.
const result = spawnSync(compiler, ['-lc', '-pc', String(files.length), ...files.map(file => `./${path.relative(dist, file)}`)], {
cwd: dist, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024
})
if (result.error) throw result.error
if (result.status !== 0) throw new Error(`微信原生编译失败:${result.stderr.slice(0, 3000)}`)
console.log(`微信原生 WXSS 编译通过:${files.length} 个文件`)
}
}
module.exports = { validateWxss }
if (require.main === module) main()
+74
View File
@@ -0,0 +1,74 @@
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 cache = new Map()
function load(file) {
if (cache.has(file)) return cache.get(file)
const exports = {}
cache.set(file, exports)
const code = ts.transpileModule(fs.readFileSync(file, 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2019, jsx: ts.JsxEmit.ReactJSX }
}).outputText
vm.runInNewContext(code, { exports, require: id => {
if (id.endsWith('.scss')) return {}
if (id === '@tarojs/components') return { View: 'view', Text: 'text' }
if (id.startsWith('.')) return load(path.resolve(path.dirname(file), `${id}.ts`))
return require(id)
} })
return exports
}
const { getDayMasterIcon } = load(path.join(root, 'src/utils/elementIcon.ts'))
const { calculateFiveElements, analyzePillars } = load(path.join(root, 'src/utils/fiveElements.ts'))
const { FiveElementIcon } = load(path.join(root, 'src/components/FiveElementIcon.tsx'))
test('十天干的日主对应五种元素图标,不按最高占比或喜用选择', () => {
const days = ['甲子', '乙丑', '丙寅', '丁卯', '戊辰', '己巳', '庚午', '辛未', '壬申', '癸酉']
const expected = ['wood', 'wood', 'fire', 'fire', 'earth', 'earth', 'metal', 'metal', 'water', 'water']
days.forEach((day, index) => {
const report = analyzePillars(['甲寅', '乙卯', day, '壬子'])
const icon = getDayMasterIcon(report)
assert.equal(icon.kind, expected[index])
assert.equal(icon.label, report.masterElement)
assert.match(icon.color, /^#[0-9a-f]{6}$/)
const component = FiveElementIcon({ report })
assert.equal(component.props.className, `five-element-icon five-element-${expected[index]}`)
assert.equal(component.props['aria-label'], `日主五行:${report.masterElement}`)
})
const report = analyzePillars(['甲寅', '乙卯', '庚申', '甲寅'])
assert.equal([...report.scores].sort((a, b) => b.value - a.value)[0].name, '木')
assert.equal(getDayMasterIcon(report).kind, 'metal')
})
test('真实生日变动同步切换日主图标,数据缺失或非法不默认显示水', () => {
const kinds = new Set()
for (let day = 10; day < 20; day++) {
const report = calculateFiveElements(`1998-06-${day}`, '辰时')
kinds.add(getDayMasterIcon(report).kind)
}
assert.equal(kinds.size, 5)
const first = getDayMasterIcon(calculateFiveElements('1998-06-18', '辰时'))
const changed = getDayMasterIcon(calculateFiveElements('1998-06-20', '辰时'))
assert.notEqual(first.kind, changed.kind)
for (const report of [null, undefined, { masterElement: '未知' }, calculateFiveElements('', '')]) {
assert.equal(getDayMasterIcon(report).kind, 'unknown')
}
assert.equal(FiveElementIcon({ report: null }).props['aria-label'], '完善出生信息后显示日主五行')
})
test('我的元素复用当前报告,个人头像保持独立,图标样式不污染水滴默认头像', () => {
const page = fs.readFileSync(path.join(root, 'src/pages/my/index.tsx'), 'utf8')
assert.match(page, /<FiveElementIcon report=\{elementReport\} \/>/)
assert.match(page, /calculateFiveElements\(profile.birthday, profile.birthHour\)/)
assert.match(page, /\[profile\?\.birthday, profile\?\.birthHour\]/)
assert.match(page, /<UserAvatar src=\{profile.avatarUrl\}[^>]+fallback=\{<WaterMascot \/>\}/)
assert.doesNotMatch(page, /<WaterMascot compact/)
const sass = require(require.resolve('sass', { paths: [require.resolve('@tarojs/webpack5-runner')] }))
const postcss = require(require.resolve('postcss', { paths: [require.resolve('@tarojs/webpack5-runner')] }))
const css = sass.renderSync({ file: path.join(root, 'src/components/FiveElementIcon.scss') }).css.toString()
postcss.parse(css).walkRules(rule => rule.selectors.forEach(selector => assert.match(selector, /^\.five-element-/)))
assert.doesNotMatch(css, /\.water-mascot/)
})
+61 -7
View File
@@ -5,28 +5,36 @@ const path = require('node:path')
const vm = require('node:vm')
const ts = require('typescript')
function loadApi(env, search) {
function loadApi(env, search, initialToken = 'test-token') {
let requests = 0
let uploads = 0
let token = initialToken
let serverProfile = { id: 9, nickname: 'Original', birthday: '1998-06-18' }
const taro = { default: {
getStorageSync: () => '',
request: async () => { requests++; return { data: { code: 0, data: { id: 9 } } } },
uploadFile: async () => { uploads++; return { data: JSON.stringify({ code: 0, data: { id: 9 } }) } }
getStorageSync: () => token,
setStorageSync: (key, value) => { if (key === 'token') token = value },
removeStorageSync: () => { token = '' },
request: async (options) => {
requests++
if (options.method === 'PUT') serverProfile = { ...serverProfile, ...options.data }
return { statusCode: 200, data: { code: 0, data: { ...serverProfile } } }
},
uploadFile: async () => { uploads++; return { statusCode: 200, data: JSON.stringify({ code: 0, data: { id: 9 } }) } }
} }
const cache = {}
function load(name) {
if (cache[name]) return cache[name]
const source = fs.readFileSync(path.resolve(__dirname, `../src/services/${name}.ts`), 'utf8')
const source = fs.readFileSync(path.resolve(__dirname, `../src/${name}.ts`), 'utf8')
const code = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2017 } }).outputText
const exports = {}
cache[name] = exports
vm.runInNewContext(code, {
exports, process: { env: { TARO_ENV: env } }, window: { location: { search } }, URLSearchParams,
require: (id) => id === '@tarojs/taro' ? taro : load('frontendPreview')
require: (id) => id === '@tarojs/taro' ? taro : load(path.posix.normalize(path.posix.join(path.posix.dirname(name), id)))
})
return exports
}
return { api: load('request').api, counts: () => ({ requests, uploads }) }
return { api: load('services/request').api, getToken: () => token, counts: () => ({ requests, uploads }) }
}
test('显式H5预览使用示例资料,阻止后端读取、保存和上传', async () => {
@@ -49,3 +57,49 @@ test('普通H5和微信端不使用预览资料', async () => {
assert.deepEqual(counts(), { requests: 1, uploads: 0 })
}
})
test('首页与我的复用资料,修改生日后立即读取新值,编辑页可强制刷新', async () => {
const { api, counts } = loadApi('h5', '')
await Promise.all([api.profile(), api.profile()])
assert.equal(counts().requests, 1)
await api.updateProfile({ birthday: '2000-02-05' })
const updated = await api.profile()
assert.equal(updated.birthday, '2000-02-05')
assert.equal(counts().requests, 2)
updated.birthday = '1900-01-01'
assert.equal((await api.profile()).birthday, '2000-02-05')
await api.profile(true)
assert.equal(counts().requests, 3)
})
test('已登录头像演示无需token,保存同步且不注册、不上传真实后端,刷新重置', async () => {
const { api, counts, getToken } = loadApi('h5', '?avatarDemo=1', '')
assert.equal((await api.profile()).nickname, '演示用户')
assert.equal((await api.profile()).id, -1)
const uploaded = await api.uploadAvatar('blob:local-photo')
assert.equal(uploaded.avatarUrl, 'blob:local-photo')
assert.equal(uploaded.remainingEdits, 5)
assert.equal((await api.profile(true)).avatarUrl, 'blob:local-photo')
await api.updateProfile({ nickname: '我的演示' })
assert.equal((await api.profile()).nickname, '我的演示')
assert.equal((await api.profile()).remainingEdits, 4)
await assert.rejects(api.uploadAvatar('https://remote.example/photo.png'), /本地图片/)
await assert.rejects(api.wxLogin('fake'), /不请求真实后端/)
await assert.rejects(api.sendSms('13800000000'), /不请求真实后端/)
await assert.rejects(api.bindPhone('fake', '13800000000', '123456'), /不请求真实后端/)
assert.equal(getToken(), '')
assert.deepEqual(counts(), { requests: 0, uploads: 0 })
assert.equal((await loadApi('h5', '?avatarDemo=1').api.profile()).avatarUrl, '')
})
test('头像演示不进入微信端;不影响原只读preview和真实token', async () => {
const real = loadApi('weapp', '?avatarDemo=1')
assert.equal((await real.api.profile()).id, 9)
assert.equal(real.counts().requests, 1)
const preview = loadApi('h5', '?preview=1&avatarDemo=1')
assert.equal((await preview.api.profile()).id, 0)
await assert.rejects(preview.api.uploadAvatar('blob:local'), /纯前端预览/)
const demo = loadApi('h5', '?avatarDemo=1')
await demo.api.uploadAvatar('blob:local')
assert.equal(demo.getToken(), 'test-token')
})
+82
View File
@@ -0,0 +1,82 @@
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, requireImpl = require, globals = {}) {
const source = fs.readFileSync(path.join(root, file), 'utf8')
const exports = {}
vm.runInNewContext(ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2019 } }).outputText,
{ exports, require: requireImpl, ...globals })
return exports
}
const { createResourceCache } = load('src/utils/resourceCache.ts')
test('三个主页面均注册为Tab,切换不使用push/redirect', () => {
let config
load('src/app.config.ts', require, { defineAppConfig: value => { config = value } })
assert.deepEqual(Array.from(config.tabBar.list, item => item.pagePath), ['pages/home/index', 'pages/fortune/index', 'pages/my/index'])
const bar = fs.readFileSync(path.join(root, 'src/components/AppTabBar.tsx'), 'utf8')
assert.doesNotMatch(bar, /navigateTo|redirectTo/)
})
test('连续点击只切换一次,完成或失败后均可继续点击', async () => {
const urls = [], notices = []
let complete
const { switchMainTab } = load('src/services/tabNavigation.ts', () => ({ default: {
switchTab: ({ url }) => { urls.push(url); return new Promise(resolve => { complete = resolve }) },
showToast: value => notices.push(value.title)
} }))
await switchMainTab('home', 'home')
assert.equal(urls.length, 0)
const first = switchMainTab('fortune', 'home')
await switchMainTab('my', 'home')
assert.equal(urls.length, 1)
complete(); await first
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: {
switchTab: () => Promise.reject(new Error('offline')), showToast: value => notices.push(value.title)
} }))
await failing.switchMainTab('my')
await failing.switchMainTab('fortune')
assert.equal(notices.length, 2)
})
test('资料缓存合并并发请求,过期保留快照并重取;失败可重试', async () => {
let now = 0, calls = 0
const cache = createResourceCache(30, () => now)
const loader = async () => { calls++; return { nickname: 'A' } }
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)
now = 31
assert.equal(cache.peek('a').nickname, 'A')
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)
})
test('保存后的新资料不会被旧请求覆盖,不同账号不共用缓存', async () => {
const cache = createResourceCache(30)
let finish
const old = cache.load('a', () => new Promise(resolve => { finish = resolve }))
await Promise.resolve()
cache.seed('a', { nickname: 'New' })
finish({ nickname: 'Old' })
assert.equal((await old).nickname, 'New')
assert.equal(cache.peek('b'), undefined)
let resolveA
const readA = cache.load('a', () => new Promise(resolve => { resolveA = resolve }))
await Promise.resolve()
cache.seed('b', { nickname: 'B' })
resolveA({ nickname: 'A' })
await assert.rejects(readA, /登录状态已变更/)
assert.equal(cache.peek('b').nickname, 'B')
let resolveOldA
const oldA = cache.load('a', () => new Promise(resolve => { resolveOldA = resolve }))
await Promise.resolve()
cache.peek('b'); cache.peek('a')
resolveOldA({ nickname: 'Outdated A' })
await assert.rejects(oldA, /资料状态已变更/)
assert.equal(cache.peek('a'), undefined)
})
+29
View File
@@ -0,0 +1,29 @@
// Technical build-time resampling/compression only; artwork and alpha are preserved.
// Requires sharp (available in the bundled workspace runtime), no network requests.
const path = require('node:path')
const fs = require('node:fs')
const sharp = require('sharp')
const source = process.argv[2]
if (!source) throw new Error('Pass the directory exported by the browser pageAssets capability')
const output = path.resolve(__dirname, '../src/assets/tiangong')
const jobs = [
['5ee867165b3314a2.png', 'wealth', 'icon'],
['d00ddb9d78132e08.png', 'fortune', 'icon'],
['c8acca9ecd8bf75b.png', 'longevity', 'icon'],
['6b935cc3a3f6178e.png', 'wenchang', 'icon'],
['778c1072c4dcdcbe.png', 'matchmaker', 'icon'],
['295b80249bc5355a.png', 'wealth-statue', 'statue'],
['1d88a8dcad0553ef.png', 'fortune-statue', 'statue'],
['c7ef27f2d9dcc38a.png', 'clouds', 'background'],
['f0bcdd293f5f5ef3.jpg', 'altar', 'altar'],
['01b38f88c3e69699.jpg', 'altar-lit', 'altar']
]
;(async () => {
for (const [filename, name, kind] of jobs) {
const asset = sharp(path.join(source, filename))
const destination = path.join(output, `proto-${name}.${kind === 'statue' ? 'png' : 'jpg'}`)
if (kind === 'statue') await asset.resize(640, 640, { fit: 'inside' }).png({ palette: true, colours: 128, quality: 80, effort: 10, compressionLevel: 9 }).toFile(destination)
else await asset.resize({ width: kind === 'icon' ? 384 : kind === 'altar' ? 800 : 540, withoutEnlargement: true }).jpeg({ quality: 76, mozjpeg: true }).toFile(destination)
console.log(path.basename(destination), fs.statSync(destination).size)
}
})().catch(error => { console.error(error); process.exitCode = 1 })
+136
View File
@@ -0,0 +1,136 @@
const test = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const Module = require('node:module')
const ts = require('typescript')
const root = path.resolve(__dirname, '..')
const filename = path.join(root, 'src/utils/tiangongMock.ts')
const loaded = new Module(filename, module)
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
test('天宫仅财神初始已恭请,五位神仙独立且状态快照隔离', () => {
const mock = createTiangongMock()
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)
first.deities.wealth.incenseCount = 999
assert.equal(mock.snapshot().deities.wealth.incenseCount, 59)
assert.equal(isDeityId('not-a-god'), false)
})
test('上香点亮供桌次数+1,不猜测爱心扣减与功德奖励', () => {
const mock = createTiangongMock()
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.throws(() => mock.incense('fortune'), /未恭请/)
})
test('模拟恭请和送神只改变指定神仙,返回列表状态可同步', () => {
const mock = createTiangongMock()
mock.invite('fortune')
mock.incense('fortune')
assert.equal(mock.snapshot().deities.fortune.incenseCount, 1)
mock.sendOff('fortune')
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('档位、非法金额与余额不足可以验证,但不执行任何真实扣款', () => {
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)
})
test('祈愿/回向校验空值,不持久化姓名或祝愿;重置还原样例', () => {
const mock = createTiangongMock()
assert.throws(() => mock.wish('wealth', ' '), /愿望/)
assert.throws(() => mock.dedicate('wealth', '甲', ''), /填写/)
assert.deepEqual(mock.wish('wealth', '测试愿望'), { mock: true })
assert.deepEqual(mock.dedicate('wealth', '测试姓名', '测试祝愿'), { mock: true })
assert.ok(!JSON.stringify(mock.snapshot()).includes('测试'))
mock.invite('fortune')
mock.reset()
assert.equal(mock.snapshot().deities.fortune.unlocked, false)
assert.equal(mock.snapshot().balance, 100)
})
test('未知神仙不回退为财神,页面只导入Mock;预留契约不被调用', () => {
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']) {
const source = fs.readFileSync(path.join(root, `src/pages/${page}/index.tsx`), 'utf8')
assert.ok(!/api\.|tiangongRemoteContract\./.test(source))
}
const mock = createTiangongMock()
assert.throws(() => mock.invite('bad'), /未找到/)
})
test('图像均为本地原型素材,没有错误后缀或带写死数字的整页截图', () => {
for (const name of ['wealth', 'fortune', 'longevity', 'wenchang', 'matchmaker', 'clouds', 'altar', 'altar-lit']) {
const buffer = fs.readFileSync(path.join(root, `src/assets/tiangong/proto-${name}.jpg`))
assert.equal(buffer.subarray(0, 2).toString('hex'), 'ffd8')
}
for (const name of ['wealth', 'fortune']) {
const buffer = fs.readFileSync(path.join(root, `src/assets/tiangong/proto-${name}-statue.png`))
assert.equal(buffer.subarray(1, 4).toString(), 'PNG')
}
const source = fs.readFileSync(path.join(root, 'src/pages/tiangong-detail/index.tsx'), 'utf8')
assert.ok(!source.includes('wealth-detail-scene.jpg'))
})
test('最后一位解绑锁定,任意一位绑定重新解锁;解绑他位不影响已有绑定', () => {
const mock = createTiangongMock()
assert.equal(hasBoundDeity(mock.snapshot()), true)
mock.unbind('wealth')
assert.equal(hasBoundDeity(mock.snapshot()), false)
mock.invite('fortune')
assert.equal(hasBoundDeity(mock.snapshot()), true)
mock.invite('wealth')
mock.unbind('fortune')
assert.equal(hasBoundDeity(mock.snapshot()), true)
})
test('每位初始2次,重复恭请/取消不扣减或重置,耗尽后送神也不能绕过', () => {
const mock = createTiangongMock()
assert.equal(mock.snapshot().deities.wealth.remainingUnbinds, 2)
// Viewing a confirmation/cancelling is read-only.
mock.snapshot(); mock.snapshot()
assert.equal(mock.snapshot().deities.wealth.remainingUnbinds, 2)
mock.unbind('wealth')
assert.equal(mock.snapshot().deities.wealth.remainingUnbinds, 1)
assert.throws(() => mock.unbind('wealth'), /未恭请/)
mock.invite('wealth'); mock.invite('wealth')
assert.equal(mock.snapshot().deities.wealth.remainingUnbinds, 1)
mock.sendOff('wealth')
assert.equal(mock.snapshot().deities.wealth.remainingUnbinds, 0)
mock.invite('wealth')
for (const action of ['unbind', 'sendOff']) assert.throws(() => mock[action]('wealth'), /次数已用尽/)
assert.equal(mock.snapshot().deities.wealth.unlocked, true)
assert.equal(mock.snapshot().deities.fortune.remainingUnbinds, 2)
})
test('绑定、解绑、重置同步通知缓存Tab;退订后不再通知', () => {
const mock = createTiangongMock()
const states = []
const unsubscribe = mock.subscribe(() => states.push(hasBoundDeity(mock.snapshot())))
mock.unbind('wealth'); mock.invite('fortune'); mock.reset()
assert.deepEqual(states, [false, true, true])
unsubscribe(); mock.unbind('wealth')
assert.equal(states.length, 3)
assert.equal(mock.snapshot().deities.wealth.remainingUnbinds, 1)
})
test('绑定门禁和卡片采用神仙用语,真实NFC与解绑契约不被页面调用', () => {
for (const name of ['home', 'my']) {
const source = fs.readFileSync(path.join(root, `src/pages/${name}/index.tsx`), 'utf8')
assert.match(source, /hasBoundDeity/)
assert.match(source, /BindingGate/)
assert.doesNotMatch(source, /吉物|山鬼花钱/)
}
const ui = fs.readFileSync(path.join(root, 'src/components/DeityBinding.tsx'), 'utf8')
assert.match(ui, /disabled=\{state.remainingUnbinds === 0\}/)
assert.doesNotMatch(ui, /getNFCAdapter|tiangongRemoteContract\./)
})
+29
View File
@@ -0,0 +1,29 @@
const test = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const { validateWxss } = require('./checkWxss.cjs')
test('WXSS 检查阻止通配选择器和无明确元素的结构伪类', () => {
assert.throws(() => validateWxss('.zodiac-body > * { flex-shrink: 0; }', 'my'), /通配选择器/)
assert.throws(() => validateWxss('.tg-panel-heading > :last-child { flex: 1; }', 'detail'), /结构伪类/)
assert.doesNotThrow(() => validateWxss('.zodiac-summary, .zodiac-large { flex-shrink: 0; } .card:first-child { margin: 0; }', 'valid'))
})
test('所有业务 SCSS 编译后不含上述微信不兼容选择器', () => {
const sass = require(require.resolve('sass', { paths: [require.resolve('@tarojs/webpack5-runner')] }))
const source = path.resolve(__dirname, '../src')
let count = 0
function check(folder) {
for (const entry of fs.readdirSync(folder, { withFileTypes: true })) {
const file = path.join(folder, entry.name)
if (entry.isDirectory()) check(file)
else if (file.endsWith('.scss')) {
validateWxss(sass.renderSync({ file }).css.toString(), path.relative(source, file))
count++
}
}
}
check(source)
assert.ok(count >= 10)
})
+5 -1
View File
@@ -15,7 +15,7 @@ export default defineAppConfig({
navigationStyle: 'custom',
backgroundColor: '#fffdf7'
},
tabBar: process.env.TARO_ENV === 'h5' ? undefined : {
tabBar: {
custom: true,
color: '#cfcfcf',
selectedColor: '#9a6d0f',
@@ -25,6 +25,10 @@ export default defineAppConfig({
pagePath: 'pages/home/index',
text: '首页'
},
{
pagePath: 'pages/fortune/index',
text: '天宫'
},
{
pagePath: 'pages/my/index',
text: '我的'
+10
View File
@@ -17,6 +17,16 @@ button::after {
border: 0;
}
// H5 uses the same cached Tab routes as WeChat, with our own visual tab bar.
// Taro 3.6 still creates a native H5 bar even when custom:true is configured.
#container > taro-tabbar { display: none !important; }
#container .taro_page.taro_tabbar_page {
max-height: 100vh;
max-height: 100dvh;
}
#app .taro_page { overflow-y: auto; scrollbar-width: none; }
#app .taro_page::-webkit-scrollbar { display: none; }
.page {
min-height: 100vh;
box-sizing: border-box;
Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

+3
View File
@@ -26,7 +26,10 @@
overflow: visible;
}
.app-tabbar-label { display: block; flex: none; line-height: 34px; white-space: nowrap; }
.app-tabbar-item, .app-tabbar-realm { touch-action: manipulation; -webkit-tap-highlight-color: transparent; }
.app-tabbar-item:active, .app-tabbar-realm:active { opacity: .75; }
.app-tabbar .app-tabbar-item.active { color: #fb9b2d; }
.app-tabbar .app-tabbar-realm.active { color: #fb9b2d; }
.app-home-symbol { width: 48px; height: 48px; position: relative; flex: none; }
.app-home-roof { position: absolute; left: 8px; top: 2px; width: 30px; height: 30px; border: 7px solid currentColor; border-right: 0; border-bottom: 0; border-radius: 5px; box-sizing: border-box; transform: rotate(45deg); }
.app-home-body { position: absolute; left: 8px; bottom: 1px; width: 32px; height: 27px; border-radius: 3px 3px 7px 7px; background: currentColor; }
+4 -9
View File
@@ -1,21 +1,16 @@
import Taro from '@tarojs/taro'
import { Button, Image, Text, View } from '@tarojs/components'
import { MainTab, switchMainTab } from '../services/tabNavigation'
import spirit from '../assets/home/spirit.png'
import './AppTabBar.scss'
export function AppTabBar({ active }: { active: 'home' | 'my' }) {
const navigate = (page: 'home' | 'my') => {
if (page === active) return
const url = `/pages/${page}/index`
if (process.env.TARO_ENV === 'weapp') Taro.switchTab({ url })
else Taro.redirectTo({ url })
}
export function AppTabBar({ active }: { active: MainTab }) {
const navigate = (page: MainTab) => switchMainTab(page, active)
return <View className='app-tabbar'>
<Button className={`app-tabbar-item ${active === 'home' ? 'active' : ''}`} onClick={() => navigate('home')}>
<View className='app-home-symbol'><View className='app-home-roof' /><View className='app-home-body'><View /></View></View>
<Text className='app-tabbar-label'>首页</Text>
</Button>
<Button className='app-tabbar-realm' onClick={() => Taro.navigateTo({ url: '/pages/fortune/index' })}>
<Button className={`app-tabbar-realm ${active === 'fortune' ? 'active' : ''}`} onClick={() => navigate('fortune')}>
<View className='app-realm-symbol'><View className='app-spirit-frame'><Image className='app-spirit-image' src={spirit} mode='aspectFit' /></View></View>
<Text className='app-tabbar-label'>天宫</Text>
</Button>
+61
View File
@@ -0,0 +1,61 @@
.binding-dialog-mask { position: fixed; inset: 0; z-index: 60; display: flex; align-items: center; justify-content: center; padding: calc(38px + env(safe-area-inset-top)) 40px calc(28px + env(safe-area-inset-bottom)); box-sizing: border-box; background: rgba(30,26,47,.64); color: #30263f; }
.binding-dialog-stack { width: 100%; max-width: 620px; max-height: 100%; overflow-y: auto; padding-top: 22px; box-sizing: border-box; scrollbar-width: none; }
.binding-gold-card { position: relative; padding: 48px 32px 30px; border: 12px solid #b17920; border-radius: 40px; background: radial-gradient(ellipse at 50% 35%,#ffe0a1,transparent 65%),linear-gradient(155deg,#fff0c9,#ffdf9e 40%,#fff8e8 75%); box-shadow: inset 0 0 0 4px #fff0bb,inset 0 0 0 12px #ceaa62,0 15px 36px rgba(34,20,8,.2); text-align: center; }
.binding-gold-card::before { content: ''; position: absolute; inset: 28px 12px 16px; border: 2px solid rgba(181,133,57,.35); border-radius: 45% 45% 26px 26px; pointer-events: none; }
.binding-card-hook { position: absolute; left: 50%; top: -25px; width: 19px; height: 38px; border-radius: 14px; background: linear-gradient(90deg,#a96d1b,#ffe7a7,#bf842c); transform: translateX(-50%) rotate(8deg); box-shadow: 0 3px 0 4px #fff1c9; }
.binding-card-corner { position: absolute; width: 50px; height: 32px; border: 7px solid #fff0bf; border-radius: 55% 50% 60% 20%; transform: rotate(24deg); }
.binding-card-corner.top { left: -2px; top: 12px; }
.binding-card-corner.bottom { right: 0; bottom: 12px; transform: rotate(204deg); }
.binding-card-title-row { position: relative; display: flex; justify-content: center; align-items: center; gap: 18px; color: #825314; }
.binding-card-name { font-size: 40px; font-weight: 800; line-height: 1.5; }
.binding-card-spark { font-size: 22px; }
.binding-gold-card .binding-more { position: absolute; z-index: 1; right: -20px; top: 0; width: 64px; margin: 0; padding: 8px 10px; font-size: 28px; line-height: 40px; color: #83531a; background: transparent; }
.binding-gold-card .binding-unbind-menu { position: absolute; z-index: 2; right: 20px; top: 100px; width: auto; padding: 18px 24px; border-radius: 14px; background: #fff9eb; color: #a04d36; font-size: 26px; line-height: 40px; box-shadow: 0 6px 20px rgba(115,76,24,.2); }
.binding-art-frame { position: relative; width: 100%; height: 360px; margin: 20px auto; display: flex; align-items: center; justify-content: center; }
.binding-card-art { display: flex; justify-content: center; width: 100%; height: 100%; }
.binding-card-subtitle { display: block; margin-top: 14px; font-size: 29px; line-height: 1.5; font-weight: 700; color: #886227; }
.binding-card-code { display: block; margin-top: 24px; color: #ab8d50; font-size: 22px; line-height: 1.6; overflow-wrap: anywhere; }
.binding-card-note { display: block; margin-top: 9px; color: #ab8d50; font-size: 19px; line-height: 1.6; }
.binding-card-warning { display: block; margin: 20px 0 10px; font-size: 22px; line-height: 1.6; color: #e17470; }
.binding-primary, .binding-outline { min-width: 0; margin: 0; padding: 18px 30px; border-radius: 50px; font-size: 30px; line-height: 44px; font-weight: 600; box-sizing: border-box; }
.binding-dialog-mask .binding-primary, .binding-gate .binding-primary { color: #fff; background: #ff992e; border: 2px solid #ff992e; }
.binding-dialog-mask .binding-outline, .binding-gate .binding-outline { color: #e6a04c; background: rgba(255,255,255,.45); border: 2px solid #e5a04c; }
.binding-dialog-mask .binding-card-confirm { width: 78%; margin: 0 auto; }
.binding-dialog-mask .binding-primary.binding-disabled { opacity: .45; }
.binding-dialog-stack .binding-card-close { display: block; width: 68px; height: 68px; padding: 0; margin: 24px auto 0; border-radius: 50%; border: 3px solid #fff; font-size: 54px; line-height: 59px; color: #fff; background: transparent; }
.binding-guarded { display: flex; justify-content: center; align-items: center; gap: 14px; width: 80%; padding: 14px 10px; box-sizing: border-box; margin: 32px auto 8px; border: 2px solid #adc4a1; border-radius: 40px; color: #68765b; font-size: 28px; line-height: 1.5; }
.binding-shield { width: 32px; height: 38px; border-radius: 10px 10px 17px 17px; background: #64b66e; color: white; font-weight: 900; }
.binding-days { color: #75b42b; }
.binding-confirm-mask { position: absolute; inset: 0; z-index: 2; display: flex; align-items: center; justify-content: center; padding: calc(32px + env(safe-area-inset-top)) 40px calc(32px + env(safe-area-inset-bottom)); box-sizing: border-box; background: rgba(31,25,43,.3); }
.binding-confirm-panel, .binding-picker { position: relative; width: 100%; max-width: 620px; max-height: 100%; overflow-y: auto; padding: 48px 38px; border-radius: 36px; box-sizing: border-box; background: linear-gradient(135deg,#efe0ff,#f6f2ff); }
.binding-confirm-title { display: block; font-size: 34px; font-weight: 800; line-height: 1.6; }
.binding-alert { color: #ef657b; }
.binding-confirm-copy { margin: 42px 0; color: #7e738a; font-size: 25px; line-height: 1.9; }
.binding-confirm-copy > text { display: block; margin-top: 14px; }
.binding-quota { color: #f06772; font-weight: 800; }
.binding-demo-note { display: block; font-size: 20px; color: #9c8aab; line-height: 1.6; }
.binding-confirm-actions { display: flex; gap: 24px; margin-top: 34px; }
.binding-confirm-actions > button { flex: 1; width: auto; padding-left: 12px; padding-right: 12px; }
.binding-error { display: block; margin-top: 16px; color: #c95563; font-size: 23px; line-height: 1.7; }
.binding-picker-note { display: block; margin-top: 20px; font-size: 23px; line-height: 1.8; color: #8b799d; }
.binding-picker-grid { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 22px; margin: 24px 0; }
.binding-picker .binding-picker-item { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 12px; margin: 0; color: #644b7b; background: rgba(255,255,255,.6); border-radius: 18px; font-size: 25px; line-height: 1.5; }
.binding-picker-art { width: 100%; height: 150px; display: flex; justify-content: center; }
.binding-picker .binding-picker-cancel { width: 70%; margin: 32px auto 0; }
.binding-gate { position: absolute; inset: 0; z-index: 2; padding: 28px 0; box-sizing: border-box; display: flex; justify-content: center; align-items: flex-start; background: linear-gradient(180deg,rgba(246,231,255,.12),rgba(248,240,255,.5)); }
.binding-blurred { filter: blur(14px); opacity: .68; pointer-events: none; user-select: none; }
.binding-gate-content { position: sticky; top: calc(120px + env(safe-area-inset-top)); width: 100%; margin-top: 90px; color: #2e263e; text-align: center; }
.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; }
.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; }
@media screen and (max-height: 650px) {
.binding-art-frame { height: 280px; margin: 12px auto; }
.binding-gold-card { padding-top: 30px; padding-bottom: 22px; }
.binding-card-code { margin-top: 14px; }
.binding-guarded { margin-top: 20px; }
.binding-confirm-panel { padding: 32px 26px; }
.binding-confirm-copy { margin: 22px 0; }
}
+94
View File
@@ -0,0 +1,94 @@
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 { 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'>
<View className='binding-gate-content'>
{lock && <View className='binding-lock'><View /></View>}
<Text>须绑定神仙</Text><Text>开启你的守护之旅</Text>
<View className='binding-gate-actions'>
<Button className='binding-primary' onClick={onBind}>绑定神仙</Button>
<Button className='binding-outline' onClick={() => Taro.showToast({ title: '企业微信入口待配置', icon: 'none' })}>联系我们</Button>
</View>
</View>
</View>
}
export function DeityCardDialog({ deityId, mode = 'view', onClose }: { deityId: DeityId; mode?: 'bind' | 'view'; onClose: () => void }) {
const snapshot = useTiangongSnapshot()
const deity = getDeity(deityId)!
const state = snapshot.deities[deityId]
const [menu, setMenu] = useState(false)
const [confirmUnbind, setConfirmUnbind] = useState(false)
const [error, setError] = useState('')
const working = useRef(false)
const perform = (action: () => void) => {
if (working.current) return
working.current = true
try { action(); onClose() } catch (cause) { setError(cause instanceof Error ? cause.message : '模拟操作失败') }
finally { working.current = false }
}
return <View className='binding-dialog-mask' catchMove>
<View className='binding-dialog-stack'>
<View className='binding-gold-card' role='dialog' aria-label={mode === 'bind' ? '确认绑定神仙' : '已绑定神仙详情'}>
<View className='binding-card-hook' /><View className='binding-card-corner top' /><View className='binding-card-corner bottom' />
<View className='binding-card-title-row'><Text className='binding-card-spark'>✦</Text><Text className='binding-card-name'>{deity.name}</Text><Text className='binding-card-spark'>✦</Text>
{mode === 'view' && <Button className='binding-more' aria-label='神仙更多操作' onClick={() => setMenu(!menu)}>•••</Button>}
</View>
{menu && <Button className='binding-unbind-menu' onClick={() => { setMenu(false); setConfirmUnbind(true) }}>解绑神仙</Button>}
<View className='binding-art-frame'><Image className='binding-card-art' src={deity.icon} mode='aspectFit' /></View>
<Text className='binding-card-subtitle'>守护神仙 · {deity.name}</Text>
<Text className='binding-card-code'>唯一 ID:DEMO-{deityId.toUpperCase()}-001</Text>
<Text className='binding-card-note'>Mock 原型演示 · 不连接真实 NFC</Text>
{mode === 'bind' ? <>
<Text className='binding-card-warning'>每位神仙只能绑定一个微信号哦</Text>
<Button className='binding-primary binding-card-confirm' onClick={() => perform(() => {
tiangongMock.invite(deityId)
Taro.showToast({ title: '模拟绑定成功', icon: 'none' })
})}>确认绑定</Button>
</> : <View className='binding-guarded'><View className='binding-shield'>✓</View><Text>已守护 <Text className='binding-days'>{state.days}</Text> 天</Text></View>}
{error && <Text className='binding-error'>{error}</Text>}
</View>
<Button className='binding-card-close' aria-label='关闭神仙卡片' onClick={onClose}>×</Button>
</View>
{confirmUnbind && <View className='binding-confirm-mask'>
<View className='binding-confirm-panel' role='dialog' aria-label='解绑神仙确认'>
<Text className='binding-confirm-title'><Text className='binding-alert'>ⓘ</Text> 确定要解绑吗?</Text>
<View className='binding-confirm-copy'>
<Text>• 解绑后「我的」里面不再展示该神仙</Text>
<Text>• 该神仙今年还剩 <Text className='binding-quota'>{state.remainingUnbinds} 次</Text> 解绑机会</Text>
<Text>• 用神仙碰触手机 NFC 可再次绑定</Text>
</View>
{state.remainingUnbinds === 0 && <Text className='binding-error'>该神仙今年的解绑次数已用尽,暂不可解绑。</Text>}
<Text className='binding-demo-note'>本次仅模拟;取消不会扣减次数。</Text>
<View className='binding-confirm-actions'>
<Button className={`binding-primary ${state.remainingUnbinds === 0 ? 'binding-disabled' : ''}`} disabled={state.remainingUnbinds === 0} onClick={() => perform(() => {
tiangongMock.unbind(deityId)
Taro.showToast({ title: '模拟解绑成功', icon: 'none' })
})}>确定解绑</Button>
<Button className='binding-outline' onClick={() => { setConfirmUnbind(false); setError('') }}>取消</Button>
</View>
{error && <Text className='binding-error'>{error}</Text>}
</View>
</View>}
</View>
}
export function DeityBindingFlow({ initialDeity, onClose }: { initialDeity?: DeityId; onClose: () => void }) {
const [selected, setSelected] = useState<DeityId | null>(initialDeity || null)
const snapshot = useTiangongSnapshot()
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 识别'>
<Text className='binding-confirm-title'>碰一碰,绑定神仙</Text>
<Text className='binding-picker-note'>Mock 演示:选择要模拟识别的神仙。不会开启 NFC、连接设备或绑定真实微信号。</Text>
<View className='binding-picker-grid'>{available.map(deity => <Button key={deity.id} className='binding-picker-item' aria-label={`模拟识别${deity.name}`} onClick={() => setSelected(deity.id)}><Image className='binding-picker-art' src={deity.icon} mode='aspectFit' /><Text>{deity.name}</Text></Button>)}</View>
{!available.length && <Text className='binding-picker-note'>当前示例中的神仙均已绑定。</Text>}
<Button className='binding-outline binding-picker-cancel' onClick={onClose}>取消</Button>
</View></View>
}
+53
View File
@@ -0,0 +1,53 @@
.five-element-icon {
position: relative;
flex: none;
width: 86px;
height: 86px;
.five-element-symbol { position: absolute; width: 68px; height: 68px; left: 9px; top: 5px; }
.five-element-shape { position: absolute; box-shadow: inset 7px 6px 12px rgba(255,255,255,.42), 0 5px 8px rgba(94,72,137,.12); }
.five-element-accent { position: absolute; }
.five-element-eye { position: absolute; top: 42px; width: 5px; height: 7px; border-radius: 50%; background: #635044; }
.five-element-eye-left { left: 22px; }
.five-element-eye-right { left: 38px; }
.five-element-smile { position: absolute; left: 28px; top: 49px; width: 10px; height: 7px; border: 2px solid #635044; border-top: 0; border-radius: 0 0 10px 10px; box-sizing: border-box; }
.five-element-tag { position: absolute; right: 0; bottom: 0; width: 28px; height: 28px; line-height: 28px; font-size: 20px; font-weight: 700; text-align: center; border: 2px solid #fff; border-radius: 50%; background: #fffdf8; box-shadow: 0 2px 5px rgba(92,72,131,.08); }
.five-element-empty { display: block; color: #a99cbe; font-size: 44px; line-height: 86px; text-align: center; font-weight: 600; }
}
.five-element-water {
.five-element-shape { left: 8px; top: 12px; width: 52px; height: 52px; border-radius: 70% 43% 66% 44%; transform: rotate(45deg); background: linear-gradient(145deg,#b0f5ff,#58d6ee 55%,#2ab8e0); }
.five-element-accent { left: 16px; top: 24px; width: 10px; height: 17px; border-radius: 50%; transform: rotate(22deg); background: rgba(255,255,255,.35); }
.five-element-eye { background: #3676ba; }
.five-element-smile { border-color: #3676ba; }
}
.five-element-wood {
.five-element-shape { left: 9px; top: 7px; width: 48px; height: 59px; border-radius: 85% 12% 75% 35%; transform: rotate(-16deg); background: linear-gradient(135deg,#c4f2b0,#7ed58a 50%,#42ae6c); }
.five-element-accent { left: 31px; top: 12px; width: 2px; height: 28px; border-radius: 2px; transform: rotate(22deg); background: rgba(255,255,255,.55); }
.five-element-eye { background: #3b7d51; }
.five-element-smile { border-color: #3b7d51; }
}
.five-element-fire {
.five-element-shape { left: 8px; top: 20px; width: 52px; height: 45px; border-radius: 42% 55% 52% 50%; background: linear-gradient(180deg,#ffb56f,#ff7858); }
.five-element-shape::before { content: ''; position: absolute; left: 5px; top: -17px; width: 28px; height: 35px; border-radius: 85% 12% 70% 35%; transform: rotate(-17deg); background: linear-gradient(135deg,#ffca83,#ff9c62); }
.five-element-shape::after { content: ''; position: absolute; right: 0; top: -9px; width: 21px; height: 28px; border-radius: 80% 15% 60% 30%; transform: rotate(20deg); background: #ffa56b; }
.five-element-accent { left: 20px; top: 34px; width: 28px; height: 29px; border-radius: 75% 28% 55% 50%; background: #ffdc9a; }
.five-element-eye { background: #a65a42; }
.five-element-smile { border-color: #a65a42; }
}
.five-element-earth {
.five-element-shape { left: 6px; top: 21px; width: 57px; height: 44px; border-radius: 30% 38% 24% 28%; transform: rotate(-7deg); background: linear-gradient(145deg,#e8c6a3,#c59064 60%,#b57b50); }
.five-element-accent { left: 16px; top: 29px; width: 9px; height: 5px; border-radius: 50%; transform: rotate(-20deg); background: rgba(255,255,255,.4); }
.five-element-eye { background: #805331; }
.five-element-smile { border-color: #805331; }
}
.five-element-metal {
.five-element-shape { left: 7px; top: 10px; width: 55px; height: 55px; border: 2px solid #e8ba4e; box-sizing: border-box; border-radius: 50%; background: linear-gradient(135deg,#fff0b6,#f7d36b 55%,#e9b743); }
.five-element-accent { left: 14px; top: 17px; width: 41px; height: 41px; border: 2px solid rgba(255,255,239,.65); border-radius: 50%; box-sizing: border-box; }
.five-element-eye { background: #aa7925; }
.five-element-smile { border-color: #aa7925; }
}
+20
View File
@@ -0,0 +1,20 @@
import { Text, View } from '@tarojs/components'
import type { FiveElementReport } from '../utils/fiveElements'
import { getDayMasterIcon } from '../utils/elementIcon'
import './FiveElementIcon.scss'
export function FiveElementIcon({ report }: { report: Pick<FiveElementReport, 'masterElement'> | null }) {
const icon = getDayMasterIcon(report)
return <View className={`five-element-icon five-element-${icon.kind}`} aria-label={icon.kind === 'unknown' ? '完善出生信息后显示日主五行' : `日主五行:${icon.label}`}>
{icon.kind === 'unknown' ? <Text className='five-element-empty'>?</Text> : <>
<View className='five-element-symbol'>
<View className='five-element-shape' />
<View className='five-element-accent' />
<View className='five-element-eye five-element-eye-left' />
<View className='five-element-eye five-element-eye-right' />
<View className='five-element-smile' />
</View>
<Text className='five-element-tag' style={{ color: icon.color }}>{icon.label}</Text>
</>}
</View>
}
+25
View File
@@ -0,0 +1,25 @@
.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-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; }
.tg-modal.plain { max-width: 560px; background: #fffdf8; }
.tg-modal .tg-modal-close { position: absolute; right: 10px; top: 6px; width: 64px; height: 64px; line-height: 58px; font-size: 40px; color: #53695a; }
.tg-modal-title { display: block; font-size: 30px; line-height: 1.6; text-align: center; font-weight: 600; }
.tg-modal-copy { display: block; margin-top: 14px; text-align: center; font-size: 25px; line-height: 1.75; }
.tg-modal-caption { display: block; margin-top: 20px; font-size: 21px; line-height: 1.6; text-align: center; color: #65766a; }
.tg-modal-actions { display: flex; gap: 28px; margin-top: 36px; justify-content: center; flex-wrap: wrap; }
.tg-modal .tg-button { width: auto; flex: 1 1 0; min-width: 0; max-width: 280px; min-height: 64px; margin: 0; padding: 12px 24px; border-radius: 20px; font-size: 25px; line-height: 40px; white-space: normal; background: #fffefa; color: #344c3e; }
.tg-modal .tg-button.primary { background: #557a60; color: #fff; }
.tg-modal .tg-button.danger { background: #db5445; color: #fff; }
.tg-modal .tg-button:active { opacity: .8; }
.tg-modal .tg-button.is-disabled { opacity: .5; }
.tg-form-label { display: block; margin: 28px 0 14px; font-size: 24px; line-height: 1.5; }
.tg-modal .tg-textarea { display: block; width: 100%; height: 190px; box-sizing: border-box; overflow: hidden; border-radius: 14px; background: #fffefa; padding: 20px; font-size: 25px; line-height: 1.7; color: #314238; }
.tg-modal .tg-input { width: 100%; height: 74px; box-sizing: border-box; padding: 0 18px; background: #fffefa; border-radius: 12px; color: #314238; font-size: 25px; }
.tg-textarea textarea { width: 100%; height: 100%; padding: 0; margin: 0; border: 0; resize: none; box-sizing: border-box; font: inherit; color: inherit; }
.tg-input input { height: 100%; padding: 0; font: inherit; color: inherit; }
.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; } }
+17
View File
@@ -0,0 +1,17 @@
import { ReactNode } from 'react'
import Taro from '@tarojs/taro'
import { Button, Text, View } from '@tarojs/components'
import './TiangongUI.scss'
export function TiangongHeader({ title, onBack }: { title: string; onBack?: () => void }) {
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>
</View></View>
}
export function TiangongModal({ title, onClose, children, variant = '' }: { title: string; onClose: () => void; children: ReactNode; variant?: string }) {
return <View className='tg-modal-mask' onClick={onClose} catchMove><View className={`tg-modal ${variant}`} role='dialog' aria-label={title} onClick={e => e.stopPropagation()}>
<Button className='tg-modal-close' aria-label='关闭弹窗' onClick={onClose}>×</Button>{children}
</View></View>
}
export function TiangongMockNote() { return <Text className='tg-mock-note'>Mock 原型演示 · 不扣费、不连接设备 · 刷新重置</Text> }
+25
View File
@@ -0,0 +1,25 @@
import { ReactNode, useEffect, useRef, useState } from 'react'
import { Image } from '@tarojs/components'
import { api, resolveAssetUrl } from '../services/request'
// COS links are short-lived. Renew through the authenticated profile endpoint,
// never sign objects in the client or retry indefinitely on a broken image.
export function UserAvatar({ src, className, fallback }: { src?: string; className: string; fallback: ReactNode }) {
const [failed, setFailed] = useState(false)
const [renewed, setRenewed] = useState<{ source: string; url: string } | null>(null)
const lastAttempt = useRef(0)
const alive = useRef(true)
useEffect(() => { alive.current = true; return () => { alive.current = false } }, [])
useEffect(() => { setFailed(false); setRenewed(null) }, [src])
const url = renewed?.source === src ? renewed.url : resolveAssetUrl(src)
const onError = () => {
setFailed(true)
if (!src || /^(blob:|data:|wxfile:|http:\/\/tmp\/)/.test(src) || Date.now() - lastAttempt.current < 30000) return
lastAttempt.current = Date.now()
api.profile(true).then(profile => {
const next = resolveAssetUrl(profile.avatarUrl)
if (alive.current && next && next !== url) { setRenewed({ source: src, url: next }); setFailed(false) }
}).catch(() => { /* Keep the default avatar when renewal is unavailable. */ })
}
return url && !failed ? <Image className={className} src={url} mode='aspectFill' onError={onError} aria-label='用户头像' /> : <>{fallback}</>
}
+3
View File
@@ -0,0 +1,3 @@
// The visible tab bar is shared by page-level AppTabBar components.
// WeChat still requires a native component when tabBar.custom is true.
Component({})
+1
View File
@@ -0,0 +1 @@
{"component":true}
+1
View File
@@ -0,0 +1 @@
<view class="native-tabbar-placeholder" />
+1
View File
@@ -0,0 +1 @@
.native-tabbar-placeholder { height: 0; }
+16
View File
@@ -0,0 +1,16 @@
import { useEffect, useState } from 'react'
import { useDidShow } from '@tarojs/taro'
import { tiangongMock } 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)
useEffect(() => {
const refresh = () => setSnapshot(tiangongMock.snapshot())
const unsubscribe = tiangongMock.subscribe(refresh)
refresh()
return unsubscribe
}, [])
useDidShow(() => setSnapshot(tiangongMock.snapshot()))
return snapshot
}
+11 -162
View File
@@ -1,162 +1,11 @@
.tiangong-page {
min-height: 100vh;
position: relative;
overflow-x: hidden;
color: #202126;
background: #fff;
}
.tiangong-content {
min-height: 100vh;
padding: calc(72px + env(safe-area-inset-top)) 48px calc(206px + env(safe-area-inset-bottom));
box-sizing: border-box;
background: #fff;
}
.tiangong-title {
display: block;
text-align: center;
color: #26272b;
font-size: 42px;
font-weight: 700;
line-height: 1.25;
}
.tiangong-subtitle {
display: block;
margin: 16px 0 54px;
text-align: center;
color: #9a9da5;
font-size: 24px;
line-height: 1.5;
}
.deity-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
column-gap: 28px;
row-gap: 38px;
}
.deity-card {
min-width: 0;
height: 246px;
padding: 0;
border: 0;
border-radius: 0;
display: flex;
flex-direction: column;
align-items: center;
overflow: hidden;
background: #fff;
box-shadow: none;
}
.deity-image {
width: 100%;
height: 192px;
display: block;
background: #fff;
object-fit: contain;
}
.deity-card.locked .deity-image {
opacity: 0.28;
filter: grayscale(1);
}
.deity-name {
width: 100%;
height: 50px;
margin-top: 4px;
background: #dceddf;
color: #283b32;
font-size: 24px;
line-height: 50px;
text-align: center;
font-weight: 600;
}
.tiangong-tabbar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 2;
height: calc(166px + env(safe-area-inset-bottom));
padding-bottom: env(safe-area-inset-bottom);
border-radius: 28px 28px 0 0;
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 -8px 30px rgba(23, 30, 37, 0.1);
backdrop-filter: blur(12px);
display: flex;
align-items: center;
justify-content: space-around;
box-sizing: border-box;
}
.tabbar-item,
.center-tab {
width: 150px;
height: 132px;
color: #5b5e65;
font-size: 30px;
font-weight: 900;
line-height: 1.1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
}
.center-tab {
margin-top: -82px;
color: #ff9b2a;
}
.center-avatar {
width: 118px;
height: 118px;
border-radius: 64px;
border: 12px solid #f3f6fb;
box-shadow: 0 10px 28px rgba(92, 104, 135, 0.2);
}
.home-tab-icon {
width: 42px;
height: 36px;
border-radius: 0 0 18px 18px;
background: currentColor;
position: relative;
}
.home-tab-icon::before {
content: '';
position: absolute;
left: 5px;
top: -22px;
width: 32px;
height: 32px;
border-radius: 18px 18px 10px 10px;
background: currentColor;
}
.mine-tab-icon {
width: 48px;
height: 32px;
border-radius: 24px 24px 14px 14px;
background: currentColor;
position: relative;
}
.mine-tab-icon::before {
content: '';
position: absolute;
left: 13px;
top: -26px;
width: 22px;
height: 22px;
border-radius: 50%;
background: currentColor;
}
.tiangong-page { min-height: 100vh; padding-bottom: calc(220px + env(safe-area-inset-bottom)); box-sizing: border-box; color: #2d3831; background: #f8f6ed; }
.deity-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 78px 30px; padding: 32px 26px; max-width: 750px; margin: 0 auto; box-sizing: border-box; }
.deity-card-wrap { position: relative; min-width: 0; }
.tiangong-page .deity-card { display: flex; flex-direction: column; width: 100%; margin: 0; padding: 0; border-radius: 4px; background: transparent; overflow: hidden; }
.deity-image { display: block; width: 100%; height: 255px; background: #fffdf4; }
.deity-card.locked .deity-image { opacity: .2; }
.deity-name { display: block; width: 100%; padding: 10px 0; font-size: 25px; font-weight: 500; line-height: 36px; text-align: center; color: #30483b; background: #d4e4d8; }
.deity-invite-prompt { position: absolute; inset: 0 0 56px; padding: 36px 8px 12px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; border-radius: 4px; color: #304337; background: rgba(250,250,241,.94); font-size: 21px; line-height: 1.5; text-align: center; }
.deity-invite-prompt .deity-prompt-close { position: absolute; right: 0; top: 0; width: 48px; height: 48px; line-height: 46px; font-size: 28px; color: #6f7f71; }
.deity-invite-prompt .deity-invite-button { margin: 10px 0 0; padding: 9px 24px; border-radius: 12px; background: #fff; font-size: 23px; line-height: 1.5; color: #405d49; box-shadow: 0 4px 16px rgba(47,72,48,.08); }
.tiangong-page .tg-demo-settings { display: block; margin: 70px auto 0; padding: 12px 18px; width: max-content; color: #7c8073; font-size: 21px; line-height: 1.5; text-decoration: underline; }
+31 -72
View File
@@ -1,82 +1,41 @@
import { useEffect, useState } from 'react'
import { useState } from 'react'
import Taro from '@tarojs/taro'
import { Button, Image, Text, View } from '@tarojs/components'
import avatarAsset from '../../assets/fortune/avatar.jpg'
import fortuneAsset from '../../assets/tiangong/fortune.png'
import longevityAsset from '../../assets/tiangong/longevity.png'
import matchmakerAsset from '../../assets/tiangong/matchmaker.png'
import wealthAsset from '../../assets/tiangong/wealth.png'
import wenchangAsset from '../../assets/tiangong/wenchang.png'
import { api, TiangongItem } from '../../services/request'
import { AppTabBar } from '../../components/AppTabBar'
import { TiangongHeader, TiangongMockNote, TiangongModal } from '../../components/TiangongUI'
import { deityCatalog, tiangongMock } from '../../services/tiangong'
import { useTiangongSnapshot } from '../../hooks/useTiangongSnapshot'
import { DeityId } from '../../utils/tiangongMock'
import './index.scss'
const mockTiangong: TiangongItem[] = [
{ id: 'wealth', name: '财神', imageUrl: wealthAsset, unlocked: true },
{ id: 'fortune', name: '禄星', imageUrl: fortuneAsset, unlocked: false },
{ id: 'longevity', name: '寿星', imageUrl: longevityAsset, unlocked: false },
{ id: 'wenchang', name: '文昌星', imageUrl: wenchangAsset, unlocked: false },
{ id: 'matchmaker', name: '月老', imageUrl: matchmakerAsset, unlocked: false }
]
export default function FortunePage() {
const [items, setItems] = useState<TiangongItem[]>(mockTiangong)
useEffect(() => {
api.tiangong()
.then(setItems)
.catch(() => setItems(mockTiangong))
}, [])
const go = (url: string) => Taro.redirectTo({ url })
return (
<View className='tiangong-page'>
<View className='tiangong-content'>
<Text className='tiangong-title'>天宫</Text>
<Text className='tiangong-subtitle'>供奉神祇,收集属于你的福运</Text>
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 />
<View className='deity-grid'>
{items.map((item) => (
<Button
className={item.unlocked ? 'deity-card unlocked' : 'deity-card locked'}
key={item.id}
onClick={() => {
if (item.unlocked) {
Taro.navigateTo({ url: `/pages/tiangong-detail/index?id=${item.id}` })
return
}
Taro.showToast({
title: `${item.name}暂未解锁`,
icon: 'none'
})
}}
>
<Image className='deity-image' src={item.imageUrl} mode='aspectFit' />
<View className='deity-name'>
<Text>{item.name}</Text>
</View>
{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>
))}
{selected === item.id && !item.unlocked && <View className='deity-invite-prompt'>
<Button className='deity-prompt-close' aria-label='关闭恭请提示' onClick={() => setSelected(null)}>×</Button>
<Text>您还未恭请此神</Text><Text>是否恭请</Text>
<Button className='deity-invite-button' onClick={() => { setSelected(null); open(item.id, true) }}>恭请</Button>
</View>}
</View>)}
</View>
<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>
<View className='tg-modal-actions'><Button className='tg-button' onClick={() => { tiangongMock.reset(); setSelected(null); setSettings(false) }}>重置全部演示</Button></View>
</TiangongModal>}
<AppTabBar active='fortune' />
</View>
<View className='tiangong-tabbar'>
<Button className='tabbar-item' onClick={() => go('/pages/home/index')}>
<View className='home-tab-icon' />
<Text>首页</Text>
</Button>
<Button
className='center-tab active'
onClick={() => Taro.showToast({ title: '天宫', icon: 'none' })}
>
<Image className='center-avatar' src={avatarAsset} mode='aspectFill' />
<Text>天宫</Text>
</Button>
<Button className='tabbar-item' onClick={() => go('/pages/my/index')}>
<View className='mine-tab-icon' />
<Text>我的</Text>
</Button>
</View>
</View>
)
}
+2
View File
@@ -4,6 +4,8 @@
box-sizing: border-box; color: #2c263a; overflow-x: hidden;
background: linear-gradient(180deg,#9d81fb 0,#c6acfb 300px,#eddaff 660px,#f7edff 1120px,#f6f2ff 100%);
}
.home-protected { position: relative; }
.home-locked .binding-gate-content { margin-top: 16vh; }
.home-preview-note { position: absolute; top: calc(110px + env(safe-area-inset-top)); left: 0; right: 0; color: rgba(60,41,104,.65); font-size: 22px; line-height: 1.5; text-align: center; }
.home-day-switch { display: flex; align-items: center; gap: 38px; padding: 0 16px; }
.home-avatar-orb { flex: none; width: 108px; height: 108px; border: 3px solid rgba(255,255,255,.75); border-radius: 50%; box-sizing: border-box; background: #fffef0; display: flex; align-items: center; justify-content: center; box-shadow: 0 10px 20px rgba(115,78,168,.1); overflow: hidden; }
+30 -12
View File
@@ -2,8 +2,13 @@ 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 { api, resolveAssetUrl, UserProfile } from '../../services/request'
import { UserAvatar } from '../../components/UserAvatar'
import { BindingGate, DeityBindingFlow } from '../../components/DeityBinding'
import { useTiangongSnapshot } from '../../hooks/useTiangongSnapshot'
import { hasBoundDeity } from '../../utils/tiangongMock'
import { api, getCachedProfile, getToken, subscribeProfile, UserProfile } from '../../services/request'
import { isFrontendPreview } from '../../services/frontendPreview'
import { isAvatarDemo } from '../../services/avatarDemo'
import { generateHomeOverview, HOME_DIMENSION_IDS, normalizeDimensionOrder } from '../../utils/homeOverview'
import { getBeijingDate } from '../../utils/zodiacWeekly'
import water from '../../assets/home/water.png'
@@ -22,35 +27,40 @@ const ORDER_KEY = 'home.dimension-order.v1'
const directionAngles: Record<string, number> = { 正北: 0, 东北: 45, 正东: 90, 东南: 135, 正南: 180, 西南: 225, 正西: 270, 西北: 315 }
export default function HomePage() {
const [profile, setProfile] = useState<UserProfile | null>(null)
const [loading, setLoading] = useState(true)
const bindingSnapshot = useTiangongSnapshot()
const unlocked = hasBoundDeity(bindingSnapshot)
const [bindingOpen, setBindingOpen] = useState(false)
const [profile, setProfile] = useState<UserProfile | null>(getCachedProfile)
const [loading, setLoading] = useState(() => !getCachedProfile())
const [loadFailed, setLoadFailed] = useState(false)
const [mode, setMode] = useState<'home' | 'sort'>('home')
const [today, setToday] = useState(() => getBeijingDate())
const [dayOffset, setDayOffset] = useState<-1 | 0 | 1>(0)
const [orderedIds, setOrderedIds] = useState<string[]>(HOME_DIMENSION_IDS)
const [avatarFailed, setAvatarFailed] = useState(false)
const requestSequence = useRef(0)
const refreshProfile = () => {
setToday(getBeijingDate())
setLoading(true)
if (!isFrontendPreview() && !isAvatarDemo() && !getToken()) { setProfile(null); setLoading(false); return }
const cached = getCachedProfile()
setProfile(cached)
setLoading(!cached)
setLoadFailed(false)
const sequence = ++requestSequence.current
api.profile().then((data) => {
if (sequence !== requestSequence.current) return
setProfile(data)
setAvatarFailed(false)
}).catch(() => {
if (sequence !== requestSequence.current) return
setProfile(null)
setLoadFailed(true)
if (cached) Taro.showToast({ title: '刷新失败,暂显示上次资料', icon: 'none' })
}).finally(() => {
if (sequence === requestSequence.current) setLoading(false)
})
try { setOrderedIds(normalizeDimensionOrder(Taro.getStorageSync(ORDER_KEY))) } catch { setOrderedIds([...HOME_DIMENSION_IDS]) }
}
useDidShow(refreshProfile)
useEffect(() => subscribeProfile(data => { setProfile(data); setLoading(false); setLoadFailed(false) }), [])
useEffect(() => {
let timer: ReturnType<typeof setTimeout>
@@ -82,17 +92,18 @@ export default function HomePage() {
}
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>{loading ? '正在准备你的每日参考…' : loadFailed ? '个人资料加载失败,请重试' : '请先完善有效的出生日期和时辰'}</Text>
{!loading && <Button onClick={loadFailed ? refreshProfile : () => Taro.navigateTo({ url: '/pages/profile-edit/index' })}>{loadFailed ? '重新加载' : '完善出生信息'}</Button>}
<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>}
</View>
<AppTabBar active='home' />
</View>
}
if (mode === 'sort') {
if (mode === 'sort' && unlocked) {
return <View className='home-page home-sort-page'>
<View className='home-sort-nav'>
<Button onClick={() => setMode('home')}>‹</Button><Text>维度排序</Text><Button onClick={finishSorting}>完成</Button>
@@ -112,8 +123,9 @@ export default function HomePage() {
const activeDay = overview.days[dayOffset + 1]
return <View className='home-page'>
{isFrontendPreview() && <Text className='home-preview-note'>前端预览 · 示例资料 · 不保存个人资料</Text>}
{isAvatarDemo() && <Text className='home-preview-note'>已登录演示 · 头像仅本地保存 · 刷新重置</Text>}
<View className='home-day-switch'>
<View className='home-avatar-orb'><Image src={profile.avatarUrl && !avatarFailed ? resolveAssetUrl(profile.avatarUrl) : water} mode={profile.avatarUrl && !avatarFailed ? 'aspectFill' : 'aspectFit'} className={profile.avatarUrl && !avatarFailed ? 'home-avatar-image' : 'home-water-image'} onError={() => setAvatarFailed(true)} /></View>
<View className='home-avatar-orb'><UserAvatar src={profile.avatarUrl} className='home-avatar-image' fallback={<Image src={water} mode='aspectFit' className='home-water-image' />} /></View>
<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>
@@ -121,6 +133,8 @@ export default function HomePage() {
<View className='home-date-row'><View className='home-solar-date'><Text>{Number(activeDay.date.slice(5, 7))}月</Text><Text className='home-date-number'>{Number(activeDay.date.slice(8, 10))}</Text><Text>日 {activeDay.weekName}</Text></View><Text className='home-lunar-date'>{activeDay.lunarDate}</Text></View>
<View className={`home-protected ${unlocked ? '' : 'home-locked'}`}>
<View className={unlocked ? '' : 'binding-blurred'} aria-hidden={!unlocked}>
<View className='home-summary-card'>
<View className='home-summary-stars'><View /><View /><View /></View>
<Text className='home-summary-copy'>{overview.summary}</Text>
@@ -136,12 +150,16 @@ export default function HomePage() {
<View className='home-lucky-box home-hours-box'><Text className='home-lucky-label'>吉时</Text><View className='home-hours-list'>{overview.luckyHours.length ? overview.luckyHours.map((hour) => <View className='home-hour-chip' key={`${hour.branch}-${hour.time}`}><Text className='home-hour-branch'>{hour.branch}</Text><Text>{hour.time.replace(/:00/g, '')}</Text></View>) : <Text className='home-no-hour'>暂无参考时段</Text>}</View></View>
</View>
<View className='home-dimension-head'><Text>维度解析</Text><Button onClick={() => setMode('sort')}>排序 <View className='home-sort-symbol'><View /><View /></View></Button></View>
<View className='home-dimension-head'><Text>维度解析</Text><Button disabled={!unlocked} onClick={() => unlocked && setMode('sort')}>排序 <View className='home-sort-symbol'><View /><View /></View></Button></View>
<View className='home-dimension-list'>{orderedDimensions.map((item) => <View className={`home-dimension-card dimension-${item.id}`} key={item.id}>
<View className='home-dimension-title-row'><Image className='home-dimension-icon' src={icons[item.icon]} mode='aspectFit' /><Text className='home-dimension-title'>{item.title}</Text><Text className='home-dimension-headline'>{item.headline}</Text><Text className={`home-dimension-tag ${item.suitable ? 'suitable' : 'avoid'}`}>{item.suitable ? '宜' : '忌'}</Text></View>
<Text className='home-dimension-detail'>{item.detail}</Text>
</View>)}</View>
<Text className='home-entertainment-note'>每日简化参考 · 仅作传统文化娱乐参考,不作为现实决策依据</Text>
</View>
{!unlocked && <BindingGate lock={false} onBind={() => setBindingOpen(true)} />}
</View>
<AppTabBar active='home' />
{bindingOpen && <DeityBindingFlow onClose={() => setBindingOpen(false)} />}
</View>
}
+4
View File
@@ -33,6 +33,10 @@
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%;
+59 -21
View File
@@ -1,7 +1,9 @@
import { useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import Taro from '@tarojs/taro'
import { Button, Input, Text, View } from '@tarojs/components'
import { api } from '../../services/request'
import { finishAccountLogin } from '../../services/account'
import { isFrontendPreview } from '../../services/frontendPreview'
import './index.scss'
export default function LoginPage() {
@@ -9,41 +11,77 @@ export default function LoginPage() {
const [phone, setPhone] = useState('')
const [captcha, setCaptcha] = useState('')
const [mockCode, setMockCode] = useState('')
const [busy, setBusy] = useState('')
const [countdown, setCountdown] = useState(0)
const working = useRef(false)
const supported = process.env.TARO_ENV === 'weapp'
const wxLogin = async () => {
const login = await Taro.login()
const res = await api.wxLogin(login.code || 'dev-code')
setSessionKey(res.sessionKey)
Taro.showToast({ title: '微信登录成功', icon: 'none' })
useEffect(() => {
if (!countdown) return
const timer = setTimeout(() => setCountdown(value => Math.max(0, value - 1)), 1000)
return () => clearTimeout(timer)
}, [countdown])
const run = async (action: string, work: () => Promise<void>) => {
if (working.current) return
working.current = true; setBusy(action)
try { await work() }
catch (error) {
const message = error instanceof Error ? error.message : '操作失败,请检查网络后重试'
if (/会话.*(过期|不存在)/.test(message)) { 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)
if (res.phoneBound) {
if (!res.token || !res.user) throw new Error('登录响应不完整,请重试')
await finishAccountLogin({ token: res.token, user: res.user })
return
}
if (!res.sessionKey) throw new Error('未获取到登录会话,请重试')
setSessionKey(res.sessionKey)
Taro.showToast({ title: '请绑定手机号完成注册', icon: 'none' })
})
const sendSms = async () => {
const sendSms = () => run('sms', async () => {
if (!sessionKey) throw new Error('请先微信登录')
if (!/^1\d{10}$/.test(phone)) throw new Error('请输入正确的11位手机号')
if (countdown) return
const res = await api.sendSms(phone)
setMockCode(res.mockCode || '')
Taro.showToast({ title: '验证码已发送', icon: 'none' })
}
setCountdown(60)
Taro.showToast({ title: res.mockCode ? '已返回开发验证码' : '验证码已发送', icon: 'none' })
})
const bindPhone = async () => {
const bindPhone = () => run('bind', async () => {
if (!sessionKey) throw new Error('请先微信登录')
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)
Taro.setStorageSync('token', res.token)
Taro.redirectTo({ url: '/pages/my/index' })
}
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>
<Button className='gold-button login-button' onClick={wxLogin}>微信小程序登录</Button>
<View className='login-form'>
<Input className='input-pill' type='number' maxlength={11} placeholder='手机号' value={phone} onInput={(e) => setPhone(String(e.detail.value))} />
<Text className='login-subtitle'>微信登录后绑定手机号,开启你的守护之旅</Text>
{!supported && <Text className='login-platform-note'>微信登录 / 注册请在微信开发者工具或微信小程序中完成。H5 不伪造微信凭证;已有会话可继续联调资料和头像接口。</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 && <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'>
<Input className='input-pill code-input' type='number' maxlength={6} placeholder='验证码' value={captcha} onInput={(e) => setCaptcha(String(e.detail.value))} />
<Button className='send-code' onClick={sendSms}>发送</Button>
<Button className='send-code' disabled={!!busy || countdown > 0} onClick={sendSms}>{countdown > 0 ? `${countdown}s` : '获取验证码'}</Button>
</View>
{mockCode && <Text className='mock-code'>开发验证码:{mockCode}</Text>}
</View>
<Button className='gold-button login-button' onClick={bindPhone}>绑定手机号</Button>
{mockCode && <Text className='mock-code'>开发验证码:{mockCode}(后端返回,非真实短信)</Text>}
<Button className='gold-button login-button' disabled={!!busy} loading={busy === 'bind'} onClick={bindPhone}>绑定手机号并继续</Button>
</View>}
</View>
)
}
+25 -1
View File
@@ -195,6 +195,20 @@
.palace-section {
margin-top: 42px;
}
.palace-bound-grid { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 22px; margin-top: 28px; }
.my-page .palace-deity-card { width: 100%; min-width: 0; margin: 0; padding: 14px 10px; display: flex; flex-direction: column; align-items: center; gap: 10px; border: 3px solid #f4d28b; border-radius: 20px; box-sizing: border-box; background: linear-gradient(150deg,#ffe2a4,#fff6d7); color: #86602c; font-size: 27px; font-weight: 700; line-height: 1.6; }
.palace-deity-art { display: flex; justify-content: center; width: 100%; height: 210px; }
.my-page .palace-bind-add { flex: none; width: auto; padding: 12px 0 12px 8px; margin: 0 0 0 auto; font-size: 23px; line-height: 1.5; color: #ad7840; }
.palace-empty { display: flex; flex-direction: column; align-items: center; padding: 40px 20px; gap: 22px; margin-top: 24px; border-radius: 24px; background: rgba(255,255,255,.45); font-size: 26px; line-height: 1.5; color: #9b8bad; }
.my-page .palace-empty > button { padding: 16px 42px; border-radius: 40px; background: #ff992e; color: white; font-size: 27px; line-height: 1.5; }
.element-protected { position: relative; }
.element-locked .binding-gate-content { margin-top: 18vh; }
.zodiac-locked-copy { margin-top: 48px; padding: 0 20px; }
.zodiac-modal.zodiac-unbound { height: auto; min-height: 620px; padding-top: 64px; padding-bottom: 50px; overflow: visible; }
.zodiac-unbound .zodiac-scroll { height: auto; flex: none; }
.zodiac-modal.zodiac-unbound .lucky-flower { width: 100px; height: 100px; top: -58px; font-size: 36px; }
.zodiac-unbound .zodiac-confirm { margin-top: 28px; }
.section-title-row { flex-wrap: wrap; }
.section-title-row {
display: flex;
@@ -479,7 +493,17 @@
.zodiac-scroll { width: 100%; height: 0; min-height: 0; flex: 1; margin-top: 18px; }
.zodiac-body { display: flex; flex-direction: column; align-items: center; padding: 0 10px 8px; box-sizing: border-box; }
.zodiac-body > * { flex-shrink: 0; }
.zodiac-body > .zodiac-large,
.zodiac-body > .zodiac-animal-name,
.zodiac-body > .zodiac-calendar-note,
.zodiac-body > .zodiac-week-range,
.zodiac-body > .zodiac-status,
.zodiac-body > .zodiac-summary,
.zodiac-body > .zodiac-date-guide,
.zodiac-body > .zodiac-rules-toggle,
.zodiac-body > .zodiac-rules,
.zodiac-body > .zodiac-week-note,
.zodiac-body > .zodiac-edit { flex-shrink: 0; }
.zodiac-body .zodiac-large { margin-top: 16px; }
.zodiac-title { flex: none; }
.zodiac-week-range { margin-top: 18px; font-size: 23px; color: #8c7b9f; text-align: center; }
+48 -23
View File
@@ -3,13 +3,19 @@ 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 { UserAvatar } from '../../components/UserAvatar'
import { FiveElementIcon } from '../../components/FiveElementIcon'
import { BindingGate, DeityBindingFlow, DeityCardDialog } from '../../components/DeityBinding'
import { useTiangongSnapshot } from '../../hooks/useTiangongSnapshot'
import { deityCatalog } from '../../services/tiangong'
import { DeityId, hasBoundDeity } from '../../utils/tiangongMock'
import { ZodiacMascot } from '../../components/ZodiacMascot'
import { api, resolveAssetUrl, UserProfile } from '../../services/request'
import coinAsset from '../../assets/fortune/coin.jpg'
import { api, getCachedProfile, getToken, subscribeProfile, UserProfile } from '../../services/request'
import { calculateFiveElements } from '../../utils/fiveElements'
import { calculateZodiac } from '../../utils/zodiac'
import { calculateWeeklyZodiacFortune, getBeijingDate, ZODIAC_WEEKLY_RULES } from '../../utils/zodiacWeekly'
import { isFrontendPreview } from '../../services/frontendPreview'
import { isAvatarDemo } from '../../services/avatarDemo'
import './index.scss'
type PageMode = 'home' | 'element'
@@ -20,7 +26,7 @@ const formatBirthday = (birthday: string, visible: boolean) => {
return visible ? `${year}_${month}_${day}` : '****_**_**'
}
const genderSymbol = (gender: string) => (gender === '女' ? '♀' : '♂')
const genderSymbol = (gender: string) => gender === '女' ? '♀' : gender === '男' ? '♂' : ''
const elementTraits: Record<string, string> = {
木: '生长与舒展',
@@ -46,7 +52,12 @@ const WaterMascot = ({ compact = false }: { compact?: boolean }) => (
)
export default function MyPage() {
const [profile, setProfile] = useState<UserProfile | null>(null)
const bindingSnapshot = useTiangongSnapshot()
const unlocked = hasBoundDeity(bindingSnapshot)
const boundDeities = deityCatalog.filter(item => bindingSnapshot.deities[item.id].unlocked)
const [bindingOpen, setBindingOpen] = useState(false)
const [cardId, setCardId] = useState<DeityId | null>(null)
const [profile, setProfile] = useState<UserProfile | null>(getCachedProfile)
const [profileError, setProfileError] = useState(false)
const [mode, setMode] = useState<PageMode>('home')
const [zodiacOpen, setZodiacOpen] = useState(false)
@@ -55,12 +66,12 @@ export default function MyPage() {
const [birthdayVisible, setBirthdayVisible] = useState(false)
const previewNote = isFrontendPreview()
? <Text className='frontend-preview-note'>前端预览 · 示例资料 · 不保存修改</Text>
: null
: isAvatarDemo() ? <Text className='frontend-preview-note'>已登录演示 · 头像仅本地保存 · 刷新重置</Text> : null
const zodiac = useMemo(() => calculateZodiac(profile?.birthday || ''), [profile?.birthday])
const weeklyFortune = useMemo(
() => calculateWeeklyZodiacFortune(profile?.birthday || '', beijingDate),
[profile?.birthday, beijingDate]
() => unlocked ? calculateWeeklyZodiacFortune(profile?.birthday || '', beijingDate) : null,
[profile?.birthday, beijingDate, unlocked]
)
useEffect(() => {
@@ -90,14 +101,18 @@ export default function MyPage() {
const refreshProfile = () => {
setBeijingDate(getBeijingDate())
setProfileError(false)
if (!isFrontendPreview() && !isAvatarDemo() && !getToken()) { setProfile(null); return }
const cached = getCachedProfile()
setProfile(cached)
api.profile()
.then(setProfile)
.catch(() => {
setProfileError(true)
setProfile(null)
if (cached) Taro.showToast({ title: '刷新失败,暂显示上次资料', icon: 'none' })
})
}
useDidShow(refreshProfile)
useEffect(() => subscribeProfile(data => { setProfile(data); setProfileError(false) }), [])
const go = (url: string) => Taro.navigateTo({ url })
@@ -107,6 +122,10 @@ export default function MyPage() {
}
const renderTabs = () => <AppTabBar active='my' />
const renderBindingDialogs = () => <>
{bindingOpen && <DeityBindingFlow onClose={() => setBindingOpen(false)} />}
{cardId && <DeityCardDialog deityId={cardId} onClose={() => setCardId(null)} />}
</>
const renderElement = () => !elementReport ? (
<View className='my-page element-detail'>
@@ -117,11 +136,14 @@ export default function MyPage() {
<Button onClick={() => go('/pages/profile-edit/index')}>完善出生信息</Button>
</View>
{renderTabs()}
{renderBindingDialogs()}
</View>
) : (
<View className='my-page element-detail'>
{previewNote}
<Button className='element-back' onClick={() => setMode('home')}>‹</Button>
<View className={`element-protected ${unlocked ? '' : 'element-locked'}`}>
<View className={unlocked ? '' : 'binding-blurred'} aria-hidden={!unlocked}>
<View className='element-radar'>
<RadarChart scores={elementReport.scores} />
</View>
@@ -170,14 +192,19 @@ export default function MyPage() {
: '当前不划分固定偏忌,避免单一五行过度偏增。'}</Text>
</View>
<Text className='element-method-note'>普通扶抑简化参考算法 v1,仅作传统文化娱乐参考;不含从格、化气、合冲刑害及调候等特殊判断。按北京时间公历、节气月柱与零点换日计算;时辰以起始小时代表,不作真太阳时校正。</Text>
</View>
{!unlocked && <BindingGate onBind={() => setBindingOpen(true)} />}
</View>
{renderTabs()}
{renderBindingDialogs()}
</View>
)
if (!profile) {
const loggedOut = !isFrontendPreview() && !isAvatarDemo() && !getToken()
return <View className='my-page my-home'><View className='element-empty'>
<Text>{profileError ? '个人资料加载失败,请重试' : '个人资料加载中…'}</Text>
{profileError && <Button onClick={refreshProfile}>重新加载</Button>}
<Text>{loggedOut ? '登录后查看个人信息' : profileError ? '个人资料加载失败,请重试' : '个人资料加载中…'}</Text>
{loggedOut ? <Button onClick={() => go('/pages/login/index')}>登录 / 注册</Button> : profileError && <Button onClick={refreshProfile}>重新加载</Button>}
</View>{renderTabs()}</View>
}
@@ -190,14 +217,12 @@ export default function MyPage() {
{previewNote}
<View className='profile-area' onClick={() => go('/pages/profile-edit/index')}>
<View className='profile-avatar-button'>
{profile.avatarUrl
? <Image className='profile-avatar-image' src={resolveAssetUrl(profile.avatarUrl)} mode='aspectFill' />
: <WaterMascot />}
<UserAvatar src={profile.avatarUrl} className='profile-avatar-image' fallback={<WaterMascot />} />
</View>
<View className='profile-info'>
<View className='name-row'>
<Text className='profile-name'>{profile.nickname}</Text>
<Text className='gender-chip'>{genderSymbol(profile.gender)}</Text>
{!!genderSymbol(profile.gender) && <Text className='gender-chip'>{genderSymbol(profile.gender)}</Text>}
</View>
<View className='birthday-row'>
<View className='birthday-icon'>
@@ -237,7 +262,7 @@ export default function MyPage() {
<Text>我的生肖</Text>
</Button>
<Button className='quick-entry' onClick={() => setMode('element')}>
<View className='quick-icon element-icon'><WaterMascot compact /></View>
<View className='quick-icon element-icon'><FiveElementIcon report={elementReport} /></View>
<Text>我的元素</Text>
</Button>
</View>
@@ -245,13 +270,12 @@ export default function MyPage() {
<View className='palace-section'>
<View className='section-title-row'>
<Text className='section-title'>我的天宫</Text>
<Text className='collection-text'>(已收集 <Text>1</Text> 款)</Text>
</View>
<View className='palace-placeholder'>
<Text className='palace-copy'>厚积薄发</Text>
<Image className='palace-coin' src={coinAsset} mode='aspectFit' />
<Text className='palace-name'>山鬼花钱</Text>
<Text className='collection-text'>(已绑定 <Text>{boundDeities.length}</Text> 位)</Text>
{!!boundDeities.length && <Button className='palace-bind-add' onClick={() => setBindingOpen(true)}>+绑定</Button>}
</View>
{boundDeities.length ? <View className='palace-bound-grid'>{boundDeities.map(deity => <Button className='palace-deity-card' key={deity.id} aria-label={`查看${deity.name}绑定详情`} onClick={() => setCardId(deity.id)}>
<Image className='palace-deity-art' src={deity.icon} mode='aspectFit' /><Text>{deity.name}</Text>
</Button>)}</View> : <View className='palace-empty'><Text>暂无已绑定神仙</Text><Button onClick={() => setBindingOpen(true)}>绑定神仙</Button></View>}
</View>
<View className='settings-card'>
@@ -286,16 +310,17 @@ export default function MyPage() {
</View>
{renderTabs()}
{renderBindingDialogs()}
{zodiacOpen && (
<View className='zodiac-mask' onClick={() => setZodiacOpen(false)}>
<View className='zodiac-modal' onClick={(event) => event.stopPropagation()}>
<View className={`zodiac-modal ${unlocked ? '' : 'zodiac-unbound'}`} onClick={(event) => event.stopPropagation()}>
<View className='lucky-flower'>运</View>
<Text className='zodiac-title'>{zodiac ? `${zodiac} · 生肖周运` : '我的生肖'}</Text>
<ScrollView className='zodiac-scroll' scrollY showScrollbar={false}>
<View className='zodiac-body'>
<View className='zodiac-large'><ZodiacMascot zodiac={zodiac} /></View>
{weeklyFortune ? (
{!unlocked ? <Text className='zodiac-summary zodiac-locked-copy'>绑定神仙后查看本周生肖运势</Text> : weeklyFortune ? (
<>
<Text className='zodiac-animal-name'>属{zodiac}</Text>
<Text className='zodiac-calendar-note'>按农历出生年 · 春节换年</Text>
+15 -9
View File
@@ -8,19 +8,21 @@
overflow-x: hidden;
}
.edit-page .nav {
// H5 keeps lazy-loaded page styles after navigation; contain all editor rules.
.edit-page {
.nav {
height: calc(112px + env(safe-area-inset-top));
padding: 0 32px 24px;
align-items: flex-end;
}
.edit-page .nav-title {
.nav-title {
color: #211c3a;
font-size: 40px;
font-weight: 800;
}
.edit-page .back {
.back {
left: 34px;
bottom: 18px;
color: #292244;
@@ -66,7 +68,7 @@
border-radius: 50%;
}
.water-mascot {
.edit-water-mascot {
width: 142px;
height: 178px;
position: relative;
@@ -75,7 +77,7 @@
box-shadow: inset 16px 10px 30px rgba(255, 255, 255, 0.28);
}
.mascot-eye {
.edit-mascot-eye {
position: absolute;
top: 100px;
width: 10px;
@@ -84,10 +86,10 @@
background: #3275ef;
}
.mascot-eye.left { left: 48px; }
.mascot-eye.right { right: 48px; }
.edit-mascot-eye.left { left: 48px; }
.edit-mascot-eye.right { right: 48px; }
.mascot-smile {
.edit-mascot-smile {
position: absolute;
left: 56px;
top: 116px;
@@ -341,7 +343,7 @@
height: 220px;
}
.water-mascot {
.edit-water-mascot {
transform: scale(0.82);
}
@@ -362,3 +364,7 @@
margin-top: 18px;
}
}
.profile-load-error { padding: 160px 35px 50px; display: flex; flex-direction: column; gap: 28px; text-align: center; color: #856c9c; font-size: 28px; line-height: 1.8; }
.profile-load-error > button { width: 70%; padding: 20px; margin: 0 auto; border-radius: 40px; font-size: 28px; line-height: 1.5; color: #fff; background: #ae85d9; }
.profile-preview-notice { display: block; margin: 0 0 24px; color: #9a86ad; font-size: 22px; line-height: 1.5; text-align: center; }
}
+58 -72
View File
@@ -1,9 +1,13 @@
import { useEffect, useState } from 'react'
import Taro from '@tarojs/taro'
import { useEffect, useRef, useState } from 'react'
import Taro, { useRouter } from '@tarojs/taro'
import { Button, Image, Input, Picker, Text, View } from '@tarojs/components'
import areaData from 'china-area-data'
import { NavBar } from '../../components/NavBar'
import { UserAvatar } from '../../components/UserAvatar'
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 './index.scss'
const hours = [
@@ -21,7 +25,6 @@ const hours = [
{ name: '亥时', range: '21:00-23:00' }
]
type EditableProfile = Pick<UserProfile, 'nickname' | 'gender' | 'birthday' | 'birthHour' | 'province' | 'city' | 'district'>
type RegionEntry = [string, string]
const emptyForm: EditableProfile = {
@@ -34,30 +37,6 @@ const emptyForm: EditableProfile = {
district: ''
}
const previewProfile: UserProfile = {
id: 0,
nickname: 'You',
gender: '男',
birthday: '1999-11-06',
birthHour: '戌时',
province: '河南省',
city: '洛阳市',
district: '洛龙区',
points: 0,
remainingEdits: 5,
elementReport: { scores: [], favorable: '', unfavorable: '', dayMaster: '' }
}
const toEditableProfile = (data: UserProfile): EditableProfile => ({
nickname: data.nickname,
gender: data.gender,
birthday: data.birthday,
birthHour: data.birthHour,
province: data.province,
city: data.city,
district: data.district
})
const regionEntries = (parentCode: string): RegionEntry[] => Object.entries(areaData[parentCode] || {})
const findCodeByName = (entries: RegionEntry[], name: string) => entries.find(([, label]) => label === name)?.[0]
@@ -75,40 +54,43 @@ const findRegionCodes = (profile: Pick<EditableProfile, 'province' | 'city' | 'd
return [provinceCode, cityCode, districtCode]
}
const errorMessage = (error: unknown, fallback: string) => error instanceof Error ? error.message : fallback
const errorMessage = (error: unknown, fallback: string) => error instanceof Error ? error.message : (error as { errMsg?: string })?.errMsg || fallback
export default function ProfileEditPage() {
const registering = useRouter().params.mode === 'register'
const alive = useRef(true)
const savingRef = useRef(false)
const [profile, setProfile] = useState<UserProfile | null>(null)
const [form, setForm] = useState<EditableProfile>(emptyForm)
const [avatarPreview, setAvatarPreview] = useState('')
const [pendingAvatar, setPendingAvatar] = useState('')
const [regionCodes, setRegionCodes] = useState<string[]>(findRegionCodes(previewProfile))
const [regionCodes, setRegionCodes] = useState<string[]>(findRegionCodes(emptyForm))
const [avatarSheetOpen, setAvatarSheetOpen] = useState(false)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [loadError, setLoadError] = useState('')
useEffect(() => {
api.profile()
const loadProfile = () => {
setLoading(true)
setLoadError('')
return api.profile(true)
.then((data) => {
if (!alive.current) return
setProfile(data)
setForm(toEditableProfile(data))
setRegionCodes(findRegionCodes(data))
setAvatarPreview(resolveAssetUrl(data.avatarUrl))
})
.catch((error) => {
if (process.env.TARO_ENV === 'h5') {
setProfile(previewProfile)
setForm(toEditableProfile(previewProfile))
setRegionCodes(findRegionCodes(previewProfile))
return
}
Taro.showToast({ title: errorMessage(error, '个人信息加载失败'), icon: 'none' })
if (alive.current) { setProfile(null); setLoadError(errorMessage(error, '个人信息加载失败')) }
})
.finally(() => setLoading(false))
}, [])
.finally(() => { if (alive.current) setLoading(false) })
}
useEffect(() => { alive.current = true; loadProfile(); return () => { alive.current = false } }, [])
const useAvatar = (filePath: string) => {
const useAvatar = (filePath: string, size?: number) => {
if (!filePath) return
if (size && size > 5 * 1024 * 1024) { Taro.showToast({ title: '头像不能超过5MB', icon: 'none' }); return }
setAvatarPreview(filePath)
setPendingAvatar(filePath)
setAvatarSheetOpen(false)
@@ -122,7 +104,7 @@ export default function ProfileEditPage() {
sourceType: [sourceType],
sizeType: ['compressed']
})
useAvatar(result.tempFiles[0]?.tempFilePath || '')
useAvatar(result.tempFiles[0]?.tempFilePath || '', result.tempFiles[0]?.size)
} catch (error) {
const message = errorMessage(error, '')
if (message && !message.toLowerCase().includes('cancel')) {
@@ -136,33 +118,34 @@ export default function ProfileEditPage() {
}
const save = async () => {
const nickname = form.nickname.trim()
if (!nickname) {
Taro.showToast({ title: '请输入昵称', icon: 'none' })
return
}
if (!form.gender || !form.birthday || !form.birthHour || !form.province || !form.city || !form.district) {
Taro.showToast({ title: '请完整填写个人信息', icon: 'none' })
return
}
if (savingRef.current || !profile) return
if (isFrontendPreview()) { Taro.showToast({ title: '当前为纯前端预览,不保存修改', icon: 'none' }); return }
savingRef.current = true
setSaving(true)
try {
await api.updateProfile({ ...form, nickname })
if (pendingAvatar) {
await api.uploadAvatar(pendingAvatar)
}
Taro.showToast({ title: '修改成功', icon: 'success' })
setTimeout(() => Taro.navigateBack(), 500)
if (registering) validateProfileForm(form)
const result = await saveProfileEdits(profile, form, pendingAvatar, api, updated => {
if (!alive.current) return
setProfile(updated); setPendingAvatar(''); setAvatarPreview(resolveAssetUrl(updated.avatarUrl))
})
if (!alive.current) return
setProfile(result.profile)
setForm(toEditableProfile(result.profile))
setAvatarPreview(resolveAssetUrl(result.profile.avatarUrl))
Taro.showToast({ title: result.changed ? isAvatarDemo() ? '已保存演示效果' : '保存成功' : '无需修改', icon: result.changed ? 'success' : 'none' })
if (registering || Taro.getCurrentPages().length <= 1) Taro.switchTab({ url: '/pages/my/index' })
else Taro.navigateBack()
} catch (error) {
Taro.showToast({ title: errorMessage(error, '保存失败,请重试'), icon: 'none' })
} finally {
setSaving(false)
savingRef.current = false
if (alive.current) setSaving(false)
}
}
const birthHour = hours.find((item) => item.name === form.birthHour) || hours[0]
const dateParts = form.birthday ? form.birthday.split('-') : ['', '', '']
const today = new Date().toISOString().slice(0, 10)
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)
@@ -211,21 +194,24 @@ export default function ProfileEditPage() {
return (
<View className='edit-page'>
<NavBar title='编辑个人信息' back />
<NavBar title={registering ? '完善注册资料' : '编辑个人信息'} back />
{loading ? (
<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>
</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)}>
{avatarPreview ? (
<Image className='avatar-image' src={avatarPreview} mode='aspectFill' />
) : (
<View className='water-mascot'>
<View className='mascot-eye left' />
<View className='mascot-eye right' />
<View className='mascot-smile' />
<UserAvatar src={avatarPreview} className='avatar-image' fallback={
<View className='edit-water-mascot'>
<View className='edit-mascot-eye left' />
<View className='edit-mascot-eye right' />
<View className='edit-mascot-smile' />
</View>
)}
} />
<View className='avatar-add'>
<View className='plus-line horizontal' />
<View className='plus-line vertical' />
@@ -308,7 +294,7 @@ export default function ProfileEditPage() {
</View>
</Picker>
<Button className='save-button' loading={saving} disabled={saving || !profile} onClick={save}>保存修改</Button>
<Button className='save-button' loading={saving} disabled={saving || !profile || isFrontendPreview()} onClick={save}>{registering ? '完成注册' : '保存修改'}</Button>
<Text className='edit-note'>半年内只能修改<Text>{profile?.remainingEdits ?? 0}</Text>次哦</Text>
</View>
)}
@@ -316,10 +302,10 @@ export default function ProfileEditPage() {
{avatarSheetOpen && (
<View className='avatar-mask' onClick={() => setAvatarSheetOpen(false)}>
<View className='avatar-sheet' onClick={(event) => event.stopPropagation()}>
<Button className='avatar-action wechat-avatar-action' openType='chooseAvatar' onChooseAvatar={handleWechatAvatar}>
{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>}
<Button className='avatar-action' onClick={() => chooseFrom('album')}>从相册选择</Button>
<Button className='avatar-action' onClick={() => chooseFrom('camera')}>拍照</Button>
<View className='sheet-gap' />
+34 -69
View File
@@ -1,69 +1,34 @@
.tiangong-detail-page {
height: 100vh;
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
background: #fff9eb;
}
.detail-navbar {
position: relative;
z-index: 2;
height: calc(92px + env(safe-area-inset-top));
padding-top: env(safe-area-inset-top);
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
flex: none;
background: #fff;
}
.detail-navbar-title {
color: #25262a;
font-size: 30px;
font-weight: 600;
}
.god-scene-wrap {
flex: 1;
min-height: 0;
overflow: hidden;
background: #f8f5e8;
}
.god-scene {
width: 100%;
height: 100%;
display: block;
}
.worship-actions {
height: calc(158px + env(safe-area-inset-bottom));
padding: 20px 34px calc(18px + env(safe-area-inset-bottom));
display: flex;
justify-content: space-between;
box-sizing: border-box;
flex: none;
background: rgba(255, 247, 226, 0.85);
backdrop-filter: blur(8px);
}
.worship-action {
width: 60px;
height: 104px;
padding: 14px 0;
border: 0;
border-radius: 28px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
color: #25282b;
font-size: 25px;
line-height: 1;
background: #e3f0e9;
box-shadow: 0 4px 10px rgba(129, 154, 137, 0.18);
}
.tiangong-detail-page { height: 100vh; height: 100dvh; display: flex; flex-direction: column; background: #faf2d4; color: #314337; overflow: hidden; }
.tg-sacred-scene { flex: 1; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
.tg-statue-space { position: relative; flex: 1; min-height: 0; background-size: cover; background-position: center bottom; background-repeat: no-repeat; }
.tg-statue { display: flex; align-items: center; justify-content: center; position: absolute; left: 50%; bottom: 0; transform: translateX(-50%); width: 100%; height: 100%; max-height: 100vw; }
.tg-statue.catalog-art { width: 72%; height: 82%; margin: auto; border-radius: 14px; }
// Clip the baked-in white side gutters, not the altar itself. Both states share
// 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); }
.worship-action:active { opacity: .7; }
.tg-panel-heading { display: flex; gap: 20px; align-items: center; }
.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-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; }
.tg-invite-description { flex: none; padding-bottom: calc(28px + env(safe-area-inset-bottom)); background: #faf2d4; }
.tg-invite-name { display: block; padding: 24px; font-size: 36px; line-height: 1.5; text-align: center; background: #d5e5d9; color: #5e7148; font-weight: 600; }
.tg-introduction { display: block; margin: 26px 32px 16px; font-size: 25px; line-height: 1.85; color: #4d503f; }
.tg-content-note { display: block; padding: 8px 32px; text-align: center; font-size: 20px; line-height: 1.7; color: #85806b; }
.tg-invite-description .tg-invite-main { width: 220px; height: 76px; margin: 20px auto; padding: 0; border-radius: 40px; line-height: 76px; font-size: 28px; color: #3c5341; background: linear-gradient(110deg,#bdd4c3,#e1eadc); }
.tg-invalid { min-height: 100vh; text-align: center; background: #f8f6ed; color: #52634f; }
.tg-invalid > text { display: block; padding: 70px 30px; font-size: 28px; }
.tg-invalid > button { color: #52634f; font-size: 28px; }
+101 -51
View File
@@ -1,60 +1,110 @@
import { useEffect, useState } from 'react'
import { useState } from 'react'
import Taro, { useRouter } from '@tarojs/taro'
import { Button, Image, Text, View } from '@tarojs/components'
import wealthDetailSceneAsset from '../../assets/tiangong/wealth-detail-scene.jpg'
import { api, TiangongDetail } from '../../services/request'
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 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'
const mockCashGod: TiangongDetail = {
id: 'wealth',
name: '财神',
title: '敬神',
imageUrl: wealthDetailSceneAsset,
introduction: '',
blessing: ''
}
const actions = ['上香', '祈愿', '回向', '送神', '返回']
type Modal = '' | 'wish' | 'dedicate' | 'offering' | 'send-off' | 'stand' | 'waiting' | 'invited' | 'insufficient' | 'result'
export default function TiangongDetailPage() {
const router = useRouter()
const itemId = router.params.id || 'wealth'
const [detail, setDetail] = useState<TiangongDetail>(mockCashGod)
useEffect(() => {
api.tiangongDetail(itemId)
.then(setDetail)
.catch(() => setDetail(mockCashGod))
}, [itemId])
return (
<View className='tiangong-detail-page'>
<View className='detail-navbar'>
<Text className='detail-navbar-title'>{detail.title || '敬神'}</Text>
</View>
<View className='god-scene-wrap'>
<Image className='god-scene' src={detail.imageUrl} mode='aspectFill' />
</View>
<View className='worship-actions'>
{actions.map((action) => (
<Button
className='worship-action'
key={action}
onClick={() => {
if (action === '返回') {
Taro.navigateBack()
return
const deity = getDeity(router.params.id)
const snapshot = useTiangongSnapshot()
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 open = (next: Modal) => { setError(''); setModal(next) }
const back = () => {
if (Taro.getCurrentPages().length > 1) Taro.navigateBack()
else Taro.switchTab({ url: '/pages/fortune/index' })
}
Taro.showToast({ title: `${action}功能即将开启`, icon: 'none' })
}}
>
<Text>{action.slice(0, 1)}</Text>
<Text>{action.slice(1)}</Text>
</Button>
))}
if (!deity) return <View className='tg-invalid'><TiangongHeader title='天宫' onBack={back} /><Text>未找到该神仙,请返回天宫选择。</Text><Button onClick={back}>返回天宫</Button></View>
const state = snapshot.deities[deity.id]
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 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 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 />
<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></>}
</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()
else if (action === '返回') back()
else open(action === '祈愿' ? 'wish' : action === '回向' ? 'dedicate' : 'send-off')
}}><Text>{action[0]}</Text><Text>{action[1]}</Text></Button>)}
</View> : <View className='tg-invite-description'>
<Text className='tg-invite-name'>{deity.name}</Text>
<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>
</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 === '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>
<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></>}
{error && <Text className='tg-error'>{error}</Text>}
</TiangongModal>}
{bindingCardOpen && <DeityBindingFlow initialDeity={deity.id} onClose={() => {
setBindingCardOpen(false)
if (tiangongMock.snapshot().deities[deity.id].unlocked) open('invited')
}} />}
</View>
)
}
+9
View File
@@ -0,0 +1,9 @@
import Taro from '@tarojs/taro'
import { acceptLoginSession, LoginSession } from './request'
import { isProfileComplete, toEditableProfile } from '../utils/profileEditing'
export async function finishAccountLogin(session: LoginSession) {
const profile = acceptLoginSession(session)
if (isProfileComplete(toEditableProfile(profile))) await Taro.switchTab({ url: '/pages/my/index' })
else await Taro.redirectTo({ url: '/pages/profile-edit/index?mode=register' })
}
+27
View File
@@ -0,0 +1,27 @@
import type { UserProfile } from './request'
import { frontendPreviewProfile } from './frontendPreview'
// H5-only visual sandbox. Never creates a token or writes a real account.
export function isAvatarDemo() {
if (process.env.TARO_ENV !== 'h5' || typeof window === 'undefined') return false
const params = new URLSearchParams(window.location.search)
return params.get('avatarDemo') === '1' && params.get('preview') !== '1'
}
let profile: UserProfile = { ...frontendPreviewProfile, id: -1, nickname: '演示用户' }
export function getAvatarDemoProfile(): UserProfile {
return JSON.parse(JSON.stringify(profile))
}
export function updateAvatarDemoProfile(input: Partial<UserProfile>) {
if (!isAvatarDemo()) throw new Error('非演示模式')
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] })
profile.remainingEdits--
return getAvatarDemoProfile()
}
export function saveAvatarDemoImage(filePath: string) {
if (!isAvatarDemo() || !/^(blob:|data:image\/)/.test(filePath)) throw new Error('请选择本地图片进行演示')
profile = { ...profile, avatarUrl: filePath }
return getAvatarDemoProfile()
}
+92 -17
View File
@@ -1,7 +1,50 @@
import Taro from '@tarojs/taro'
import { frontendPreviewProfile, isFrontendPreview } from './frontendPreview'
import { createResourceCache } from '../utils/resourceCache'
import { getAvatarDemoProfile, isAvatarDemo, saveAvatarDemoImage, updateAvatarDemoProfile } from './avatarDemo'
const BASE_URL = 'http://localhost:8080'
export const BASE_URL = typeof API_BASE_URL === 'undefined' ? 'http://127.0.0.1:8080' : API_BASE_URL
const profileCache = createResourceCache<UserProfile>(30000)
const profileListeners = new Set<(profile: UserProfile | null) => void>()
export const getToken = (): string => Taro.getStorageSync('token') || ''
const profileKey = () => isFrontendPreview() ? 'preview' : isAvatarDemo() ? 'avatar-demo' : `user:${Taro.getStorageSync('token') || ''}`
const cloneProfile = (profile: UserProfile): UserProfile => JSON.parse(JSON.stringify(profile))
export class AuthenticationError extends Error {
constructor(message = '请先登录') { super(message); this.name = 'AuthenticationError' }
}
export function subscribeProfile(listener: (profile: UserProfile | null) => void) {
profileListeners.add(listener)
return () => { profileListeners.delete(listener) }
}
function publishProfile(profile: UserProfile | null) {
profileListeners.forEach(listener => listener(profile ? cloneProfile(profile) : null))
}
export function clearLogin() {
Taro.removeStorageSync('token')
profileCache.clear()
publishProfile(null)
}
function normalizeProfile(profile: UserProfile): UserProfile {
return { ...profile, nickname: profile.nickname || '', birthday: profile.birthday || '',
birthHour: profile.birthHour || '', province: profile.province || '', city: profile.city || '',
district: profile.district || '', gender: profile.gender || '未知', avatarUrl: profile.avatarUrl || '' }
}
function storeProfile(profile: UserProfile, key = profileKey()) {
if (key !== profileKey()) throw new AuthenticationError('登录状态已变更,请重新打开页面')
const data = normalizeProfile(profile)
profileCache.seed(key, cloneProfile(data))
publishProfile(data)
return data
}
export function acceptLoginSession(session: LoginSession) {
if (!session.token || !session.user) throw new Error('登录响应不完整,请重试')
Taro.setStorageSync('token', session.token)
return storeProfile(session.user)
}
export function getCachedProfile(): UserProfile | null {
const profile = profileCache.peek(profileKey())
return profile ? cloneProfile(profile) : null
}
type ApiResult<T> = {
code: number
@@ -11,7 +54,26 @@ type ApiResult<T> = {
type RequestOptions = Omit<Taro.request.Option, 'url'>
function unwrap<T>(status: number, body: ApiResult<T>, token: string, protectedRequest: boolean): T {
const expired = status === 401 || status === 403
|| (body?.code !== 0 && /请先登录|登录状态.*过期|重新登录/.test(body?.message || ''))
if (expired && protectedRequest) {
if (token === getToken()) clearLogin()
throw new AuthenticationError('登录状态已失效,请重新登录')
}
if (status < 200 || status >= 300 || !body || body.code !== 0) {
throw new Error(body?.message && typeof body.message === 'string' ? body.message : `请求失败(${status || '网络异常'}),请稍后重试`)
}
if (protectedRequest && token !== getToken()) throw new AuthenticationError('登录状态已变更,请重新打开页面')
return body.data
}
export async function request<T>(url: string, options: RequestOptions = {}): Promise<T> {
if (isAvatarDemo()) {
if (url === '/api/user/profile' && (!options.method || options.method === 'GET')) return getAvatarDemoProfile() as T
if (url === '/api/user/profile' && options.method === 'PUT') return updateAvatarDemoProfile(options.data as Partial<UserProfile>) as T
throw new Error('已登录演示仅支持本地资料与头像,不请求真实后端')
}
if (isFrontendPreview()) {
if (url === '/api/user/profile' && (!options.method || options.method === 'GET')) {
return { ...frontendPreviewProfile, elementReport: { ...frontendPreviewProfile.elementReport, scores: [] } } as T
@@ -19,6 +81,8 @@ export async function request<T>(url: string, options: RequestOptions = {}): Pro
throw new Error('纯前端预览暂不请求后端或保存修改')
}
const token = Taro.getStorageSync('token')
const protectedRequest = !/^\/api\/(auth|content)\//.test(url)
if (protectedRequest && !token) throw new AuthenticationError()
const headers = {
'content-type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
@@ -27,44 +91,51 @@ export async function request<T>(url: string, options: RequestOptions = {}): Pro
const res = await Taro.request<ApiResult<T>>({
url: `${BASE_URL}${url}`,
...options,
timeout: options.timeout || 15000,
credentials: 'omit',
header: headers
})
if (res.data.code !== 0) {
throw new Error(res.data.message || '请求失败')
}
return res.data.data
return unwrap(res.statusCode, res.data, token, protectedRequest)
}
export function resolveAssetUrl(url?: string) {
if (!url) return ''
return /^https?:\/\//.test(url) || url.startsWith('wxfile://') || url.startsWith('http://tmp/')
if (url.startsWith('cos://')) return '' // Only the backend may resolve private object references.
return /^(https?:\/\/|wxfile:\/\/|blob:|data:image\/)/.test(url)
? url
: `${BASE_URL}${url.startsWith('/') ? url : `/${url}`}`
}
async function uploadAvatar(filePath: string): Promise<UserProfile> {
if (isAvatarDemo()) return storeProfile(saveAvatarDemoImage(filePath))
if (isFrontendPreview()) throw new Error('纯前端预览暂不上传头像或保存修改')
const token = Taro.getStorageSync('token')
if (!token) throw new AuthenticationError()
const key = profileKey()
const response = await Taro.uploadFile({
url: `${BASE_URL}/api/user/avatar`,
filePath,
name: 'file',
timeout: 45000,
withCredentials: false,
header: token ? { Authorization: `Bearer ${token}` } : {}
})
let result: ApiResult<UserProfile>
try {
result = JSON.parse(response.data) as ApiResult<UserProfile>
} catch (error) {
if (response.statusCode === 401 || response.statusCode === 403) unwrap(response.statusCode, null, token, true)
throw new Error('头像上传失败')
}
if (result.code !== 0) {
throw new Error(result.message || '头像上传失败')
}
return result.data
const profile = unwrap(response.statusCode, result, token, true)
return storeProfile(profile, key)
}
export type LoginSession = { token: string; user: UserProfile }
export type WxLoginResponse = { sessionKey: string; phoneBound: boolean; token?: string; user?: UserProfile }
export const api = {
wxLogin: (code: string) => request<{ sessionKey: string; phoneBound: boolean }>('/api/auth/wx-login', {
wxLogin: (code: string) => request<WxLoginResponse>('/api/auth/wx-login', {
method: 'POST',
data: { code }
}),
@@ -72,15 +143,19 @@ export const api = {
method: 'POST',
data: { phone }
}),
bindPhone: (sessionKey: string, phone: string, captcha: string) => request<{ token: string; user: UserProfile }>('/api/auth/bind-phone', {
bindPhone: (sessionKey: string, phone: string, captcha: string) => request<LoginSession>('/api/auth/bind-phone', {
method: 'POST',
data: { sessionKey, phone, captcha }
}),
profile: () => request<UserProfile>('/api/user/profile'),
updateProfile: (data: Partial<UserProfile>) => request<UserProfile>('/api/user/profile', {
method: 'PUT',
data
profile: (force = false) => profileCache.load(profileKey(), () => request<UserProfile>('/api/user/profile').then(normalizeProfile), force).then(data => {
publishProfile(data)
return cloneProfile(data)
}),
updateProfile: async (data: Partial<UserProfile>) => {
const key = profileKey()
const profile = await request<UserProfile>('/api/user/profile', { method: 'PUT', data })
return storeProfile(profile, key)
},
uploadAvatar,
exchangeStatus: () => request<{ points: number; available: boolean; message: string }>('/api/points/exchange-status'),
agreement: (type: string) => request<{ title: string; body: string }>(`/api/content/agreement/${type}`),
@@ -111,7 +186,7 @@ export type UserProfile = {
province: string
city: string
district: string
points: number
points?: number
remainingEdits: number
elementReport: {
scores: ElementScore[]
+17
View File
@@ -0,0 +1,17 @@
import Taro from '@tarojs/taro'
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
switching = true
try {
await Taro.switchTab({ url: `/pages/${page}/index` })
} catch {
Taro.showToast({ title: '页面切换失败,请重试', icon: 'none' })
} finally {
switching = false
}
}
+48
View File
@@ -0,0 +1,48 @@
import { request } from './request'
import { createTiangongMock, DeityId } from '../utils/tiangongMock'
import wealth from '../assets/tiangong/proto-wealth.jpg'
import fortune from '../assets/tiangong/proto-fortune.jpg'
import longevity from '../assets/tiangong/proto-longevity.jpg'
import wenchang from '../assets/tiangong/proto-wenchang.jpg'
import matchmaker from '../assets/tiangong/proto-matchmaker.jpg'
import wealthStatue from '../assets/tiangong/proto-wealth-statue.png'
import fortuneStatue from '../assets/tiangong/proto-fortune-statue.png'
export type Deity = { id: DeityId; name: string; ritualName: string; icon: string; statue: string; introduction?: string; missingStatue?: boolean }
export const deityCatalog: Deity[] = [
{ id: 'wealth', name: '财神', ritualName: '武财神', icon: wealth, statue: wealthStatue },
{ id: 'fortune', name: '禄星', ritualName: '禄星', icon: fortune, statue: fortuneStatue, introduction: '上清灵宝天尊灵宝天尊列在第二神阶之中位,仅次于元始天尊。他居三十六天之第二高位上清境,治蕊珠日阙,管七映紫房。其核心神职是演说灵宝真经、开度三界六道众生,掌金箓玉策、敷演道法,主万物生化,调和阴阳,是灵宝法系与斋醮科仪的核心尊神。' },
{ id: 'longevity', name: '寿星', ritualName: '寿星', icon: longevity, statue: longevity, missingStatue: true },
{ id: 'wenchang', name: '文昌星', ritualName: '文昌星', icon: wenchang, statue: wenchang, missingStatue: true },
{ id: 'matchmaker', name: '月老', ritualName: '月老', icon: matchmaker, statue: matchmaker, missingStatue: true }
]
export const getDeity = (id: string | undefined) => deityCatalog.find(item => item.id === id)
export const tiangongMock = createTiangongMock()
export const listMockDeities = () => { const snapshot = tiangongMock.snapshot(); return deityCatalog.map(item => ({ ...item, ...snapshot.deities[item.id] })) }
export type DeityBindingInfo = {
bindingId: string
deityId: DeityId
uniqueId: string
bound: boolean
guardedDays: number
remainingUnbinds: number
}
export type UnbindResult = { binding: DeityBindingInfo; hasAnyBoundDeity: boolean }
// 未接入,不会自动请求。GET沿用现有路径;所有POST及钱包/支架路径为拟定契约,待后端确认。
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 } }),
unbind: (bindingId: string) => request<UnbindResult>(`/api/tiangong/bindings/${encodeURIComponent(bindingId)}/unbind`, { method: 'POST' })
}
+1
View File
@@ -0,0 +1 @@
declare const API_BASE_URL: string
+17
View File
@@ -0,0 +1,17 @@
import type { ElementName, FiveElementReport } from './fiveElements'
export const elementIcons: Record<ElementName, { kind: string; label: string; color: string }> = {
木: { kind: 'wood', label: '木', color: '#409d61' },
火: { kind: 'fire', label: '火', color: '#e66a49' },
土: { kind: 'earth', label: '土', color: '#ac754d' },
金: { kind: 'metal', label: '金', color: '#c69324' },
水: { kind: 'water', label: '水', color: '#369fc6' }
}
// The entry represents the day master's element, not the largest percentage or favorable element.
export function getDayMasterIcon(report: Pick<FiveElementReport, 'masterElement'> | null | undefined) {
const element = report?.masterElement
return element && Object.prototype.hasOwnProperty.call(elementIcons, element)
? elementIcons[element]
: { kind: 'unknown', label: '?', color: '#9a91b1' }
}
+48
View File
@@ -0,0 +1,48 @@
import type { UserProfile } from '../services/request'
export type EditableProfile = Pick<UserProfile, 'nickname' | 'gender' | 'birthday' | 'birthHour' | 'province' | 'city' | 'district'>
export const toEditableProfile = (profile: UserProfile): EditableProfile => ({
nickname: profile.nickname || '', gender: ['男', '女'].includes(profile.gender) ? profile.gender : '',
birthday: profile.birthday || '', 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)
export const hasPersonalChanges = (profile: UserProfile, form: EditableProfile) => {
const original = toEditableProfile(profile)
return Object.keys(original).some(key => original[key] !== form[key])
}
export function validateProfileForm(form: EditableProfile) {
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('请选择有效的出生日期')
const date = new Date(`${form.birthday}T00:00:00Z`)
if (Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== form.birthday) throw new Error('请选择有效的出生日期')
if (form.birthday > new Date(Date.now() + 28800000).toISOString().slice(0, 10)) throw new Error('出生日期不能晚于今天')
}
type ProfileWriter = {
uploadAvatar: (filePath: string) => Promise<UserProfile>
updateProfile: (form: EditableProfile) => Promise<UserProfile>
}
export async function saveProfileEdits(initial: UserProfile, input: EditableProfile, avatar: string,
writer: ProfileWriter, onAvatarSaved: (profile: UserProfile) => void) {
const form = { ...input, nickname: input.nickname.trim() }
const changed = hasPersonalChanges(initial, form)
if (changed) {
validateProfileForm(form)
if (initial.remainingEdits <= 0) throw new Error('本周期资料修改次数已用尽,仍可单独更新头像')
}
let profile = initial
if (avatar) {
// Upload first: a failed file operation must not consume a profile edit.
profile = await writer.uploadAvatar(avatar)
onAvatarSaved(profile)
}
if (changed) {
try { profile = await writer.updateProfile(form) }
catch (error) {
if (avatar) throw new Error(`头像已更新,个人资料未保存:${error instanceof Error ? error.message : '请重试'}`)
throw error
}
}
return { profile, changed: changed || !!avatar }
}
+45
View File
@@ -0,0 +1,45 @@
// Memory-only, single-account cache. A write supersedes an older in-flight read.
export function createResourceCache<T>(ttl: number, now = Date.now) {
let key: string | undefined
let value: T | undefined
let expires = 0
let pending: Promise<T> | undefined
let revision = 0
const select = (next: string) => {
if (key === next) return
key = next
value = undefined
expires = 0
pending = undefined
revision++
}
return {
clear() { revision++; value = undefined; expires = 0; pending = undefined },
peek(next: string) { select(next); return value },
seed(next: string, data: T) {
select(next)
revision++
value = data
expires = now() + ttl
pending = undefined
},
load(next: string, loader: () => Promise<T>, force = false): Promise<T> {
select(next)
if (!force && value !== undefined && now() < expires) return Promise.resolve(value)
if (pending) return pending
const version = revision
const task = Promise.resolve().then(loader).then(data => {
if (key !== next) throw new Error('登录状态已变更,请重新加载')
if (version !== revision) {
if (value === undefined) throw new Error('资料状态已变更,请重新加载')
return value
}
value = data
expires = now() + ttl
return data
}).finally(() => { if (pending === task) pending = undefined })
pending = task
return task
}
}
}
+43
View File
@@ -0,0 +1,43 @@
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 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() {
let state: TiangongSnapshot
const listeners = new Set<() => void>()
const reset = () => {
state = { balance: 100, 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
}])) as Record<DeityId, WorshipState> }
}
reset()
const snapshot = (): TiangongSnapshot => 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() }
const unbind = (id: DeityId) => {
const deity = owned(id)
if (deity.remainingUnbinds <= 0) throw new Error('该神仙今年的解绑次数已用尽')
deity.remainingUnbinds--
deity.unlocked = false
deity.lit = false
return publish()
}
return {
snapshot, reset: () => { reset(); return publish() },
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() },
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() }
}
}