diff --git a/.gitignore b/.gitignore index 09be9e0..3e84ed8 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ dist/ dist-h5/ .swc/ .DS_Store +project.private.config.json diff --git a/README.md b/README.md index 2032ecb..9d919b7 100644 --- a/README.md +++ b/README.md @@ -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` 指定编译器路径;未安装时只做静态检查并明确提示。 diff --git a/config/index.ts b/config/index.ts index 2fd244b..bfa01e4 100644 --- a/config/index.ts +++ b/config/index.ts @@ -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: {} diff --git a/docs/account-backend-integration.md b/docs/account-backend-integration.md new file mode 100644 index 0000000..9c37f3b --- /dev/null +++ b/docs/account-backend-integration.md @@ -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 `;失效登录 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 联调需用户在开发者工具授权验收。未改动生产域名或伪造注册凭证。 diff --git a/docs/deity-binding-delivery.md b/docs/deity-binding-delivery.md new file mode 100644 index 0000000..b3e8807 --- /dev/null +++ b/docs/deity-binding-delivery.md @@ -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`。 +- 未自动提交或推送代码;未启动或修改后端。 diff --git a/docs/my-element-icon.md b/docs/my-element-icon.md new file mode 100644 index 0000000..9bc30c1 --- /dev/null +++ b/docs/my-element-icon.md @@ -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`。 diff --git a/docs/navigation-alignment-fix.md b/docs/navigation-alignment-fix.md new file mode 100644 index 0000000..8a2f9c1 --- /dev/null +++ b/docs/navigation-alignment-fix.md @@ -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 视口检查财神,神像图像中轴与容器中轴一致,供桌切换前后同尺寸同位置,五个动作完整,没有横向溢出。 +- 不将浏览器验证与构建通过等同于微信真机验收;未做真机帧率测量,首次资源加载仍受设备与网络影响。 diff --git a/docs/tiangong-delivery.md b/docs/tiangong-delivery.md new file mode 100644 index 0000000..c5cd9cd --- /dev/null +++ b/docs/tiangong-delivery.md @@ -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、寿星恭请余额不足路径;缺少独立神像/介绍时展示说明,不错误复用财神。 +- 不自动提交或推送代码,未启动后端。素材导出调用异常耗时,任务实际交付晚于计划时间。 diff --git a/package.json b/package.json index bafe9a8..fd99d41 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/accountIntegration.test.cjs b/scripts/accountIntegration.test.cjs new file mode 100644 index 0000000..660b750 --- /dev/null +++ b/scripts/accountIntegration.test.cjs @@ -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: 'unauthorized' }) + 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\)/) +}) diff --git a/scripts/checkWxss.cjs b/scripts/checkWxss.cjs new file mode 100644 index 0000000..452b09d --- /dev/null +++ b/scripts/checkWxss.cjs @@ -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() diff --git a/scripts/elementIcon.test.cjs b/scripts/elementIcon.test.cjs new file mode 100644 index 0000000..dd14fe9 --- /dev/null +++ b/scripts/elementIcon.test.cjs @@ -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, //) + assert.match(page, /calculateFiveElements\(profile.birthday, profile.birthHour\)/) + assert.match(page, /\[profile\?\.birthday, profile\?\.birthHour\]/) + assert.match(page, /]+fallback=\{\}/) + assert.doesNotMatch(page, / rule.selectors.forEach(selector => assert.match(selector, /^\.five-element-/))) + assert.doesNotMatch(css, /\.water-mascot/) +}) diff --git a/scripts/frontendPreview.test.cjs b/scripts/frontendPreview.test.cjs index dc9e910..d6527c6 100644 --- a/scripts/frontendPreview.test.cjs +++ b/scripts/frontendPreview.test.cjs @@ -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') +}) diff --git a/scripts/navigation.test.cjs b/scripts/navigation.test.cjs new file mode 100644 index 0000000..f8156a9 --- /dev/null +++ b/scripts/navigation.test.cjs @@ -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) +}) diff --git a/scripts/optimize-tiangong-assets.cjs b/scripts/optimize-tiangong-assets.cjs new file mode 100644 index 0000000..c8283e4 --- /dev/null +++ b/scripts/optimize-tiangong-assets.cjs @@ -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 }) diff --git a/scripts/tiangong.test.cjs b/scripts/tiangong.test.cjs new file mode 100644 index 0000000..b7285f9 --- /dev/null +++ b/scripts/tiangong.test.cjs @@ -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\./) +}) diff --git a/scripts/wxssCompatibility.test.cjs b/scripts/wxssCompatibility.test.cjs new file mode 100644 index 0000000..06c7ef3 --- /dev/null +++ b/scripts/wxssCompatibility.test.cjs @@ -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) +}) diff --git a/src/app.config.ts b/src/app.config.ts index 77d7672..be829ac 100644 --- a/src/app.config.ts +++ b/src/app.config.ts @@ -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: '我的' diff --git a/src/app.scss b/src/app.scss index 4accb9c..480e536 100644 --- a/src/app.scss +++ b/src/app.scss @@ -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; diff --git a/src/assets/tiangong/proto-altar-lit.jpg b/src/assets/tiangong/proto-altar-lit.jpg new file mode 100644 index 0000000..06df024 Binary files /dev/null and b/src/assets/tiangong/proto-altar-lit.jpg differ diff --git a/src/assets/tiangong/proto-altar.jpg b/src/assets/tiangong/proto-altar.jpg new file mode 100644 index 0000000..7f6f400 Binary files /dev/null and b/src/assets/tiangong/proto-altar.jpg differ diff --git a/src/assets/tiangong/proto-clouds.jpg b/src/assets/tiangong/proto-clouds.jpg new file mode 100644 index 0000000..b0778e7 Binary files /dev/null and b/src/assets/tiangong/proto-clouds.jpg differ diff --git a/src/assets/tiangong/proto-fortune-statue.png b/src/assets/tiangong/proto-fortune-statue.png new file mode 100644 index 0000000..2ba65ad Binary files /dev/null and b/src/assets/tiangong/proto-fortune-statue.png differ diff --git a/src/assets/tiangong/proto-fortune.jpg b/src/assets/tiangong/proto-fortune.jpg new file mode 100644 index 0000000..78cf830 Binary files /dev/null and b/src/assets/tiangong/proto-fortune.jpg differ diff --git a/src/assets/tiangong/proto-longevity.jpg b/src/assets/tiangong/proto-longevity.jpg new file mode 100644 index 0000000..5004a15 Binary files /dev/null and b/src/assets/tiangong/proto-longevity.jpg differ diff --git a/src/assets/tiangong/proto-matchmaker.jpg b/src/assets/tiangong/proto-matchmaker.jpg new file mode 100644 index 0000000..54d9aa4 Binary files /dev/null and b/src/assets/tiangong/proto-matchmaker.jpg differ diff --git a/src/assets/tiangong/proto-wealth-statue.png b/src/assets/tiangong/proto-wealth-statue.png new file mode 100644 index 0000000..66dcab0 Binary files /dev/null and b/src/assets/tiangong/proto-wealth-statue.png differ diff --git a/src/assets/tiangong/proto-wealth.jpg b/src/assets/tiangong/proto-wealth.jpg new file mode 100644 index 0000000..2c64d18 Binary files /dev/null and b/src/assets/tiangong/proto-wealth.jpg differ diff --git a/src/assets/tiangong/proto-wenchang.jpg b/src/assets/tiangong/proto-wenchang.jpg new file mode 100644 index 0000000..ae16104 Binary files /dev/null and b/src/assets/tiangong/proto-wenchang.jpg differ diff --git a/src/components/AppTabBar.scss b/src/components/AppTabBar.scss index 8d34d0d..4326cef 100644 --- a/src/components/AppTabBar.scss +++ b/src/components/AppTabBar.scss @@ -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; } diff --git a/src/components/AppTabBar.tsx b/src/components/AppTabBar.tsx index 02126df..92aa9da 100644 --- a/src/components/AppTabBar.tsx +++ b/src/components/AppTabBar.tsx @@ -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 - diff --git a/src/components/DeityBinding.scss b/src/components/DeityBinding.scss new file mode 100644 index 0000000..7f98c36 --- /dev/null +++ b/src/components/DeityBinding.scss @@ -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; } +} diff --git a/src/components/DeityBinding.tsx b/src/components/DeityBinding.tsx new file mode 100644 index 0000000..7242314 --- /dev/null +++ b/src/components/DeityBinding.tsx @@ -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 + + {lock && } + 须绑定神仙开启你的守护之旅 + + + + + + +} + +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 + + + + ✦{deity.name}✦ + {mode === 'view' && } + + {menu && } + + 守护神仙 · {deity.name} + 唯一 ID:DEMO-{deityId.toUpperCase()}-001 + Mock 原型演示 · 不连接真实 NFC + {mode === 'bind' ? <> + 每位神仙只能绑定一个微信号哦 + + : ✓已守护 {state.days} 天} + {error && {error}} + + + + {confirmUnbind && + + ⓘ 确定要解绑吗? + + • 解绑后「我的」里面不再展示该神仙 + • 该神仙今年还剩 {state.remainingUnbinds} 次 解绑机会 + • 用神仙碰触手机 NFC 可再次绑定 + + {state.remainingUnbinds === 0 && 该神仙今年的解绑次数已用尽,暂不可解绑。} + 本次仅模拟;取消不会扣减次数。 + + + + + {error && {error}} + + } + +} + +export function DeityBindingFlow({ initialDeity, onClose }: { initialDeity?: DeityId; onClose: () => void }) { + const [selected, setSelected] = useState(initialDeity || null) + const snapshot = useTiangongSnapshot() + if (selected) return + const available = deityCatalog.filter(item => !snapshot.deities[item.id].unlocked) + return + 碰一碰,绑定神仙 + Mock 演示:选择要模拟识别的神仙。不会开启 NFC、连接设备或绑定真实微信号。 + {available.map(deity => )} + {!available.length && 当前示例中的神仙均已绑定。} + + +} diff --git a/src/components/FiveElementIcon.scss b/src/components/FiveElementIcon.scss new file mode 100644 index 0000000..bda931e --- /dev/null +++ b/src/components/FiveElementIcon.scss @@ -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; } +} diff --git a/src/components/FiveElementIcon.tsx b/src/components/FiveElementIcon.tsx new file mode 100644 index 0000000..fcff1c6 --- /dev/null +++ b/src/components/FiveElementIcon.tsx @@ -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 | null }) { + const icon = getDayMasterIcon(report) + return + {icon.kind === 'unknown' ? ? : <> + + + + + + + + {icon.label} + } + +} diff --git a/src/components/TiangongUI.scss b/src/components/TiangongUI.scss new file mode 100644 index 0000000..87c1486 --- /dev/null +++ b/src/components/TiangongUI.scss @@ -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; } } diff --git a/src/components/TiangongUI.tsx b/src/components/TiangongUI.tsx new file mode 100644 index 0000000..d754416 --- /dev/null +++ b/src/components/TiangongUI.tsx @@ -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 + {onBack && }{title} + +} +export function TiangongModal({ title, onClose, children, variant = '' }: { title: string; onClose: () => void; children: ReactNode; variant?: string }) { + return e.stopPropagation()}> + {children} + +} +export function TiangongMockNote() { return Mock 原型演示 · 不扣费、不连接设备 · 刷新重置 } diff --git a/src/components/UserAvatar.tsx b/src/components/UserAvatar.tsx new file mode 100644 index 0000000..6997bc2 --- /dev/null +++ b/src/components/UserAvatar.tsx @@ -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 ? : <>{fallback} +} diff --git a/src/custom-tab-bar/index.js b/src/custom-tab-bar/index.js new file mode 100644 index 0000000..2a451a9 --- /dev/null +++ b/src/custom-tab-bar/index.js @@ -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({}) diff --git a/src/custom-tab-bar/index.json b/src/custom-tab-bar/index.json new file mode 100644 index 0000000..d80dd20 --- /dev/null +++ b/src/custom-tab-bar/index.json @@ -0,0 +1 @@ +{"component":true} diff --git a/src/custom-tab-bar/index.wxml b/src/custom-tab-bar/index.wxml new file mode 100644 index 0000000..144c8c3 --- /dev/null +++ b/src/custom-tab-bar/index.wxml @@ -0,0 +1 @@ + diff --git a/src/custom-tab-bar/index.wxss b/src/custom-tab-bar/index.wxss new file mode 100644 index 0000000..996bad3 --- /dev/null +++ b/src/custom-tab-bar/index.wxss @@ -0,0 +1 @@ +.native-tabbar-placeholder { height: 0; } diff --git a/src/hooks/useTiangongSnapshot.ts b/src/hooks/useTiangongSnapshot.ts new file mode 100644 index 0000000..a585133 --- /dev/null +++ b/src/hooks/useTiangongSnapshot.ts @@ -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 +} diff --git a/src/pages/fortune/index.scss b/src/pages/fortune/index.scss index e13e740..0e78a4d 100644 --- a/src/pages/fortune/index.scss +++ b/src/pages/fortune/index.scss @@ -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; } diff --git a/src/pages/fortune/index.tsx b/src/pages/fortune/index.tsx index d9fbd15..4ba38ea 100644 --- a/src/pages/fortune/index.tsx +++ b/src/pages/fortune/index.tsx @@ -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(mockTiangong) - - useEffect(() => { - api.tiangong() - .then(setItems) - .catch(() => setItems(mockTiangong)) - }, []) - - const go = (url: string) => Taro.redirectTo({ url }) - - return ( - - - 天宫 - 供奉神祇,收集属于你的福运 - - {items.map((item) => ( - - ))} - - - - - - - - + {selected === item.id && !item.unlocked && + + 您还未恭请此神是否恭请 + + } + )} - ) + + {settings && setSettings(false)} variant='plain'> + Mock 演示设置当前模拟爱心:{balance} + 仅切换演示余额,不是充值。刷新网页或重新启动小程序后恢复初始样例。 + {([0, 100] as const).map(value => )} + + } + + } diff --git a/src/pages/home/index.scss b/src/pages/home/index.scss index 3b9ad1f..77ec4d9 100644 --- a/src/pages/home/index.scss +++ b/src/pages/home/index.scss @@ -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; } diff --git a/src/pages/home/index.tsx b/src/pages/home/index.tsx index 0b8e45f..2790ccd 100644 --- a/src/pages/home/index.tsx +++ b/src/pages/home/index.tsx @@ -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 = { 正北: 0, 东北: 45, 正东: 90, 东南: 135, 正南: 180, 西南: 225, 正西: 270, 西北: 315 } export default function HomePage() { - const [profile, setProfile] = useState(null) - const [loading, setLoading] = useState(true) + const bindingSnapshot = useTiangongSnapshot() + const unlocked = hasBoundDeity(bindingSnapshot) + const [bindingOpen, setBindingOpen] = useState(false) + const [profile, setProfile] = useState(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(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 @@ -82,17 +92,18 @@ export default function HomePage() { } if (loading || !overview) { + const loggedOut = !isFrontendPreview() && !isAvatarDemo() && !getToken() return - {loading ? '正在准备你的每日参考…' : loadFailed ? '个人资料加载失败,请重试' : '请先完善有效的出生日期和时辰'} - {!loading && } + {loggedOut ? '登录后开启你的守护之旅' : loading ? '正在准备你的每日参考…' : loadFailed ? '个人资料加载失败,请重试' : '请先完善有效的出生日期和时辰'} + {!loading && } } - if (mode === 'sort') { + if (mode === 'sort' && unlocked) { return 维度排序 @@ -112,8 +123,9 @@ export default function HomePage() { const activeDay = overview.days[dayOffset + 1] return {isFrontendPreview() && 前端预览 · 示例资料 · 不保存个人资料} + {isAvatarDemo() && 已登录演示 · 头像仅本地保存 · 刷新重置} - setAvatarFailed(true)} /> + } /> {overview.days.map((day, index) => )} @@ -121,6 +133,8 @@ export default function HomePage() { {Number(activeDay.date.slice(5, 7))}月{Number(activeDay.date.slice(8, 10))}日 {activeDay.weekName}{activeDay.lunarDate} + + {overview.summary} @@ -136,12 +150,16 @@ export default function HomePage() { 吉时{overview.luckyHours.length ? overview.luckyHours.map((hour) => {hour.branch}{hour.time.replace(/:00/g, '')}) : 暂无参考时段} - 维度解析 + 维度解析 {orderedDimensions.map((item) => {item.title}{item.headline}{item.suitable ? '宜' : '忌'} {item.detail} )} 每日简化参考 · 仅作传统文化娱乐参考,不作为现实决策依据 + + {!unlocked && setBindingOpen(true)} />} + + {bindingOpen && setBindingOpen(false)} />} } diff --git a/src/pages/login/index.scss b/src/pages/login/index.scss index 5e1d813..13a0c01 100644 --- a/src/pages/login/index.scss +++ b/src/pages/login/index.scss @@ -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%; diff --git a/src/pages/login/index.tsx b/src/pages/login/index.tsx index e724fdf..d6af33f 100644 --- a/src/pages/login/index.tsx +++ b/src/pages/login/index.tsx @@ -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) => { + 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 ( 那吉 一触即吉 - 微信登录后绑定手机号,开启你的吉运档案 - - - setPhone(String(e.detail.value))} /> + 微信登录后绑定手机号,开启你的守护之旅 + {!supported && 微信登录 / 注册请在微信开发者工具或微信小程序中完成。H5 不伪造微信凭证;已有会话可继续联调资料和头像接口。} + {isFrontendPreview() && 当前为纯前端预览,不会请求注册接口。} + + {sessionKey && + { setPhone(String(e.detail.value)); setCaptcha(''); setMockCode('') }} /> setCaptcha(String(e.detail.value))} /> - + - {mockCode && 开发验证码:{mockCode}} - - + {mockCode && 开发验证码:{mockCode}(后端返回,非真实短信)} + + } ) } diff --git a/src/pages/my/index.scss b/src/pages/my/index.scss index 4aa919a..37cd219 100644 --- a/src/pages/my/index.scss +++ b/src/pages/my/index.scss @@ -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; } diff --git a/src/pages/my/index.tsx b/src/pages/my/index.tsx index ddc15e9..87b4535 100644 --- a/src/pages/my/index.tsx +++ b/src/pages/my/index.tsx @@ -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 = { 木: '生长与舒展', @@ -46,7 +52,12 @@ const WaterMascot = ({ compact = false }: { compact?: boolean }) => ( ) export default function MyPage() { - const [profile, setProfile] = useState(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(null) + const [profile, setProfile] = useState(getCachedProfile) const [profileError, setProfileError] = useState(false) const [mode, setMode] = useState('home') const [zodiacOpen, setZodiacOpen] = useState(false) @@ -55,12 +66,12 @@ export default function MyPage() { const [birthdayVisible, setBirthdayVisible] = useState(false) const previewNote = isFrontendPreview() ? 前端预览 · 示例资料 · 不保存修改 - : null + : isAvatarDemo() ? 已登录演示 · 头像仅本地保存 · 刷新重置 : 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 = () => + const renderBindingDialogs = () => <> + {bindingOpen && setBindingOpen(false)} />} + {cardId && setCardId(null)} />} + const renderElement = () => !elementReport ? ( @@ -117,11 +136,14 @@ export default function MyPage() { {renderTabs()} + {renderBindingDialogs()} ) : ( {previewNote} + + @@ -170,14 +192,19 @@ export default function MyPage() { : '当前不划分固定偏忌,避免单一五行过度偏增。'} 普通扶抑简化参考算法 v1,仅作传统文化娱乐参考;不含从格、化气、合冲刑害及调候等特殊判断。按北京时间公历、节气月柱与零点换日计算;时辰以起始小时代表,不作真太阳时校正。 + + {!unlocked && setBindingOpen(true)} />} + {renderTabs()} + {renderBindingDialogs()} ) if (!profile) { + const loggedOut = !isFrontendPreview() && !isAvatarDemo() && !getToken() return - {profileError ? '个人资料加载失败,请重试' : '个人资料加载中…'} - {profileError && } + {loggedOut ? '登录后查看个人信息' : profileError ? '个人资料加载失败,请重试' : '个人资料加载中…'} + {loggedOut ? : profileError && } {renderTabs()} } @@ -190,14 +217,12 @@ export default function MyPage() { {previewNote} go('/pages/profile-edit/index')}> - {profile.avatarUrl - ? - : } + } /> {profile.nickname} - {genderSymbol(profile.gender)} + {!!genderSymbol(profile.gender) && {genderSymbol(profile.gender)}} @@ -237,7 +262,7 @@ export default function MyPage() { 我的生肖 @@ -245,13 +270,12 @@ export default function MyPage() { 我的天宫 - (已收集 1 款) - - - 厚积薄发 - - 山鬼花钱 + (已绑定 {boundDeities.length} 位) + {!!boundDeities.length && } + {boundDeities.length ? {boundDeities.map(deity => )} : 暂无已绑定神仙} @@ -286,16 +310,17 @@ export default function MyPage() { {renderTabs()} + {renderBindingDialogs()} {zodiacOpen && ( setZodiacOpen(false)}> - event.stopPropagation()}> + event.stopPropagation()}> 运 {zodiac ? `${zodiac} · 生肖周运` : '我的生肖'} - {weeklyFortune ? ( + {!unlocked ? 绑定神仙后查看本周生肖运势 : weeklyFortune ? ( <> 属{zodiac} 按农历出生年 · 春节换年 diff --git a/src/pages/profile-edit/index.scss b/src/pages/profile-edit/index.scss index 3abfd03..89401af 100644 --- a/src/pages/profile-edit/index.scss +++ b/src/pages/profile-edit/index.scss @@ -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; } +} diff --git a/src/pages/profile-edit/index.tsx b/src/pages/profile-edit/index.tsx index 6291f19..dca41df 100644 --- a/src/pages/profile-edit/index.tsx +++ b/src/pages/profile-edit/index.tsx @@ -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 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 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(null) const [form, setForm] = useState(emptyForm) const [avatarPreview, setAvatarPreview] = useState('') const [pendingAvatar, setPendingAvatar] = useState('') - const [regionCodes, setRegionCodes] = useState(findRegionCodes(previewProfile)) + const [regionCodes, setRegionCodes] = useState(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 ( - + {loading ? ( 加载中... - ) : ( + ) : loadError || !profile ? + {loadError || '个人信息加载失败'} + + : ( + {isFrontendPreview() && 纯前端预览 · 不保存个人资料或头像} + {isAvatarDemo() && 已登录演示 · 选择图片并保存可查看三处头像同步,不上传真实存储;刷新页面重置。} + 半年内只能修改{profile?.remainingEdits ?? 0}次哦 )} @@ -316,10 +302,10 @@ export default function ProfileEditPage() { {avatarSheetOpen && ( setAvatarSheetOpen(false)}> event.stopPropagation()}> - + } diff --git a/src/pages/tiangong-detail/index.scss b/src/pages/tiangong-detail/index.scss index f4f2a5b..5b1a207 100644 --- a/src/pages/tiangong-detail/index.scss +++ b/src/pages/tiangong-detail/index.scss @@ -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; } diff --git a/src/pages/tiangong-detail/index.tsx b/src/pages/tiangong-detail/index.tsx index 8746188..44ec8c9 100644 --- a/src/pages/tiangong-detail/index.tsx +++ b/src/pages/tiangong-detail/index.tsx @@ -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(mockCashGod) + const deity = getDeity(router.params.id) + const snapshot = useTiangongSnapshot() + const [bindingCardOpen, setBindingCardOpen] = useState(false) + const [modal, setModal] = useState('') + const [wish, setWish] = useState('') + const [name, setName] = useState('') + const [blessing, setBlessing] = useState('') + const [amount, setAmount] = useState('') + const [error, setError] = useState('') + const [result, setResult] = useState('') + const [busy, setBusy] = useState(false) + const close = () => { setModal(''); setError('') } + const open = (next: Modal) => { setError(''); setModal(next) } + const back = () => { + if (Taro.getCurrentPages().length > 1) Taro.navigateBack() + else Taro.switchTab({ url: '/pages/fortune/index' }) + } + if (!deity) return 未找到该神仙,请返回天宫选择。 + 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) => {title}{subtitle} + const dialogActions = (confirm: string, action: () => void, danger = false, disabled = false) => - useEffect(() => { - api.tiangongDetail(itemId) - .then(setDetail) - .catch(() => setDetail(mockCashGod)) - }, [itemId]) - - return ( - - - {detail.title || '敬神'} - - - - - - - - {actions.map((action) => ( - - ))} + return + + + + + {invited && <>上香 {state.incenseCount}} + {invited && } - ) + {invited ? + {['上香', '祈愿', '回向', '送神', '返回'].map(action => )} + : + {deity.name} + {deity.introduction || '原型未提供这位神仙的独立介绍,待确认后补充。'} + {deity.introduction && 原型介绍与“禄星”名称不一致,文案待确认。} + {deity.missingStatue && 原型未提供独立详情神像,暂使用同一神仙的列表原图。} + + 仅演示恭请流程,不扣费、不建立真实供奉关系。 + } + {modal && + {modal === 'wish' && <> + {panelHeader(`向【${deity.ritualName}】祈愿`, '诚心许愿,助您愿望实现!')} + 您的愿望