feat: refine tiangong flows and integrate profile avatars
No files matched your search
@@ -3,3 +3,4 @@ dist/
|
||||
dist-h5/
|
||||
.swc/
|
||||
.DS_Store
|
||||
project.private.config.json
|
||||
@@ -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` 指定编译器路径;未安装时只做静态检查并明确提示。
|
||||
@@ -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: {}
|
||||
|
||||
@@ -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 联调需用户在开发者工具授权验收。未改动生产域名或伪造注册凭证。
|
||||
@@ -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`。
|
||||
- 未自动提交或推送代码;未启动或修改后端。
|
||||
@@ -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`。
|
||||
@@ -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 视口检查财神,神像图像中轴与容器中轴一致,供桌切换前后同尺寸同位置,五个动作完整,没有横向溢出。
|
||||
- 不将浏览器验证与构建通过等同于微信真机验收;未做真机帧率测量,首次资源加载仍受设备与网络影响。
|
||||
@@ -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、寿星恭请余额不足路径;缺少独立神像/介绍时展示说明,不错误复用财神。
|
||||
- 不自动提交或推送代码,未启动后端。素材导出调用异常耗时,任务实际交付晚于计划时间。
|
||||
@@ -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",
|
||||
|
||||
@@ -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\)/)
|
||||
})
|
||||
@@ -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()
|
||||
@@ -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/)
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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 })
|
||||
@@ -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\./)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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: '我的'
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 28 KiB |
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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; } }
|
||||
@@ -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> }
|
||||
@@ -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}</>
|
||||
}
|
||||
@@ -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({})
|
||||
@@ -0,0 +1 @@
|
||||
{"component":true}
|
||||
@@ -0,0 +1 @@
|
||||
<view class="native-tabbar-placeholder" />
|
||||
@@ -0,0 +1 @@
|
||||
.native-tabbar-placeholder { height: 0; }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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%;
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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' />
|
||||
|
||||
@@ -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; }
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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' })
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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[]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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' })
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
declare const API_BASE_URL: string
|
||||
@@ -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' }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||