feat: rename app to Qiyun and refine Tiangong rituals and UI
This commit is contained in:
1 parent
7871114c50
commit
df84a4c130
32 files changed
+481
-129
No files matched your search
@@ -0,0 +1,50 @@
|
||||
# 小程序全站配色方案
|
||||
|
||||
设计分支:`codex/celestial-color-refresh`,从已推送的 `7871114` 创建。
|
||||
|
||||
当前阶段为配色方案对比,尚未替换业务页面的生产样式。三套方案沿用相同内容和主要页面结构,便于判断配色本身;方案确定后统一落地全部页面及弹窗。
|
||||
|
||||
## 现状
|
||||
|
||||
首页大面积高饱和紫色背景、紫粉摘要卡和蓝色指数球同时争夺注意力。天宫素材以青绿山水、祥云米白、朱红神袍和金色配饰为主;个人中心偏淡紫,关注页又偏米金。各页面缺少一致的背景、按钮、正文与导航色。
|
||||
|
||||
## 第二版 三套更鲜明的方向
|
||||
|
||||
第一版被反馈为不够鲜艳、单调。第二版增加互补色、局部明亮渐变和材质层次,保留浅色阅读区域,避免只给所有组件套同一种颜色。
|
||||
|
||||
| 方案 | 主色 | 对比色 | 鎏金色 | 页面底色 | 特点 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 丹霞瑞彩(推荐) | `#C93C48` | `#168B86` | `#F3B642` | `#FFF7ED` | 神袍朱红、碧玉青绿与祥云金橙呼应,活泼、温暖,有传统瑞彩感。 |
|
||||
| 青碧流金 | `#087E87` | `#377DB5` | `#F1BB46` | `#F2FAF5` | 孔雀青、湖蓝与明金互相映衬,清亮而有层次,接近祥云山水的色彩关系。 |
|
||||
| 紫金星阙 | `#7841C6` | `#B33178` | `#F1C359` | `#FBF5FF` | 深紫天幕、桃粉霞光与暖金日晕;顶部明暗对比更强,向下渐入浅色阅读区,仙境仪式感最突出。 |
|
||||
|
||||
## 首页调整原则
|
||||
|
||||
- 顶部通过相邻色的亮色渐变与柔光建立氛围,中下部回到浅色,保证正文阅读。
|
||||
- 摘要卡增加明暗渐变、日晕线条与小标题;指数球使用暖金色及高光,和神像金饰呼应,形成明确视觉中心。
|
||||
- 吉色、吉数、吉向、吉时使用主色浅底、暖金浅底和对比色浅底区分,配色从一套色板派生。
|
||||
- 情感和财富卡分别使用柔和霞色、浅金色底纹,图标和宜忌状态继续保持含义清晰。
|
||||
- 个人中心、表单和底部导航复用相同主色、对比色与金色,防止只有首页改色后又产生新的割裂。
|
||||
- 吉色属于用户数据,色块继续反映实际计算值,不能被主题色覆盖;五行、宜忌状态同样保留区分。
|
||||
|
||||
## 全站应用范围
|
||||
|
||||
| 页面或组件 | 统一规则 |
|
||||
| --- | --- |
|
||||
| 首页及维度排序 | 浅色背景、主色摘要与选中状态、统一卡片及辅助文字。 |
|
||||
| 天宫列表 | 使用共同页面底色和导航色;神仙原图保留色彩,未绑定状态沿用淡化逻辑。 |
|
||||
| 请神及敬神详情 | 保留祥云山水和神像原图;名称栏、介绍、按钮、供奉操作栏统一颜色。 |
|
||||
| 我的及神仙卡片 | 页面背景与首页同源;昵称和菜单正文使用统一深色,金色卡片保留仪式感。 |
|
||||
| 我的元素及生肖弹窗 | 浅底卡片、统一正文和按钮;保留五行的木绿、火暖红、土棕、金黄、水蓝。 |
|
||||
| 编辑个人信息及完善注册资料 | 表单统一纸面颜色,选中态和保存按钮使用主色。 |
|
||||
| 登录、注册及登录门禁 | 使用同一套背景、输入、主按钮、错误和辅助文字颜色。 |
|
||||
| 用户协议及隐私政策 | 浅色阅读背景、深色正文,章节标题与选中标签统一。 |
|
||||
| 关注我们 | 使用同源背景和纸面卡片;二维码、平台标识保持原样。 |
|
||||
| 内部联系页及通用弹窗 | 统一背景、输入和按钮。微信官方客服会话属于微信界面,不修改其配色。 |
|
||||
| 底部导航、返回按钮、绑定门禁 | 统一选中态、非选中态、描边和遮罩,保留已有安全区布局。 |
|
||||
|
||||
## 实现与验收
|
||||
|
||||
确定方案后提取背景、纸面、正文、次级文字、主色、强调、描边、阴影及语义色等共用变量。逐页替换硬编码颜色,避免通过滤镜或透明覆盖把神仙素材变色,也不改业务判断、接口和布局结构。
|
||||
|
||||
对比预览复用了本仓库的神仙、祥云、头像、生肖和首页图标。首页样例数据来自现有算法,以 `1998-06-18 辰时`、`2026-10-21` 生成;个人中心守护天数为演示值。预览用于选色,不连接后端、不修改用户资料。全站应用后需要继续检查正文对比度、长文阅读、未绑定状态、弹窗与小屏安全区。
|
||||
@@ -1,3 +1,5 @@
|
||||
> 当前接口以本文为准;早期交付文档中的拟定路径及送神解绑规则已由本次改动替代。
|
||||
|
||||
# 敬神:音频与真实供奉接口
|
||||
|
||||
## 本期规则
|
||||
@@ -7,7 +9,7 @@
|
||||
- 每次上香独立提交。只有服务端返回有效记录 ID 后,次数才+1;请求失败不计数、不播音、不自动重试。网络结果不明时应刷新核对,避免手动重试重复记账。
|
||||
- 点亮截止时间为最后一次成功请求对应的点击时间+15秒(不从回包开始重计)。持续点击仅增加次数、延长点亮,不重播;音频自然结束不循环。点亮到期即停止声音、恢复供桌,不回退次数。
|
||||
- 页面隐藏/离开销毁音频。返回只恢复剩余点亮,不自动播放。播放错误提示用户,但不撤销已落库记录。
|
||||
- 祈愿最多500字;回向任意姓名最多50字+祝愿最多500字。权限、有效绑定由服务端再次验证。成功才清空,失败保留内容。表单提交中防重复,连续上香不节流丢次数。
|
||||
- 祈愿最多500字;回向对象最多255字+选填祝愿最多500字。权限、有效绑定由服务端再次验证。成功才清空,失败保留内容。表单提交中防重复,连续上香不节流丢次数。
|
||||
|
||||
## 已接入接口(JWT,标准 ApiResponse)
|
||||
|
||||
@@ -16,18 +18,18 @@
|
||||
| 接口 | 用途/请求 |
|
||||
| --- | --- |
|
||||
| GET `/api/tiangong/items`(新增) | 本人列表:id/name/iconUrl/description、holdingId、active、days、incenseCount。状态与统计来自数据库,无示例持有。 |
|
||||
| POST `/api/user/virtual-items/offerings`(复用) | `{userVirtualItemId: 本人holdingId, offeringType: 2}` 上香;type=3+`content` 祈愿。返回流水及id。 |
|
||||
| POST `/api/user/virtual-items/dedications`(新增) | `{userVirtualItemId: 本人holdingId, name, content}` 任意姓名回向。返回流水及id。接收人ID为空,不创建账号、不转移资产。 |
|
||||
| POST `/api/user/virtual-items/offerings`(复用) | `{userVirtualItemId: 本人holdingId, offeringType: 1, incenseCount: 3}` 上香,一次请求计一次;type=2+`prayerContent` 祈愿。返回流水及id。 |
|
||||
| POST `/api/user/virtual-items/dedications`(新增) | `{userVirtualItemId: 本人holdingId, dedicationTarget, dedicationContent?}` 回向对象为1至255字文本,祝愿选填、最多500字。返回主流水及嵌套dedication明细,不保留接收账号。 |
|
||||
|
||||
前端调用:`src/services/tiangong.ts`;状态管理:`src/utils/tiangongStore.ts`。使用服务端持有ID,不猜测数字ID。原型与字典名称精确匹配(财神/武财神、禄星、寿星、文昌星、月老),未配置者禁用,不假装已恭请。账号变更清空缓存,旧响应不能写入新账号。
|
||||
|
||||
真实 NFC 绑定、解绑、送神不属于本次三项写接口;正式模式不执行本地 Mock 绑定,也不展示示例额度或虚构成功。已有绑定可正常供奉。此前拟定 NFC 契约仍未调用。
|
||||
请神/送神通过 POST `/api/user/virtual-items/lifecycle` 提交 `{userVirtualItemId, actionType}`(1请神、2送神)。送神停止供奉并保留设备绑定、累计上香次数,不消耗解绑额度。已送神状态可重新请神;未绑定或过期状态需先完成 NFC 绑定。真实 NFC 绑定与解绑仍待接入。
|
||||
|
||||
## 部署边界
|
||||
|
||||
**前后端代码推送不等于部署:本次未部署远程后端、未操作远程数据库。**
|
||||
|
||||
部署后端前执行 `hera/src/main/resources/db/named-dedication-update.sql`,仅将 `user_virtual_item_transfer.to_user_id` 改为可空,保留数据。`CREATE TABLE IF NOT EXISTS` 不会更新现存表,不能省略。前端构建不能代替后端部署。此前生日偏好迁移也需按对应文档执行。
|
||||
部署时需先执行后端 `src/main/resources/db/virtual-item-lifecycle-update.sql` 并部署对应后端代码。供奉明细改为上香、祈愿、回向三张表,请神/送神写独立流水,前端构建不能代替数据库迁移与后端部署。
|
||||
|
||||
远端无新接口/新表结构时,正式页面提示加载或提交失败,不以 Mock 伪装成功。隔离 H2 测试已验证 Controller→Service→MyBatis→落库,不等同于远端联调。
|
||||
|
||||
@@ -36,9 +38,9 @@ H5演示:`http://127.0.0.1:4173/?avatarDemo=1#/pages/tiangong-detail/index?id=
|
||||
## 开发者工具验收
|
||||
|
||||
1. 重新编译,后端及SQL部署后,用已绑定神仙的账号登录→天宫→对应详情。无功德入口,上香次数与服务端一致。
|
||||
2. 开声→上香:网络出现type=2请求,成功才次数+1、点亮。10秒再点一次,再+1;音频不重播,第二次点击15秒后恢复。
|
||||
2. 开声→上香:网络出现type=1请求,成功才次数+1、点亮。10秒再点一次,再+1;音频不重播,第二次点击15秒后恢复。
|
||||
3. 上香第3秒关闭喇叭,立即无声;第10秒开启,应听到第10秒处剩余音频,不从第3秒接续,次数不变。第15秒音频结束后开启不得再播;第10秒追加上香使供桌仍亮时,也不得重播已结束音频。初始静音上香同样验证。
|
||||
4. 祈愿输入内容→确认,type=3成功显示“祈愿已记录”。回向输入任意姓名+祝愿→确认,dedications成功显示“回向已记录”;数据库to_user_id为空,姓名祝愿保留。
|
||||
4. 祈愿输入内容→确认,type=2成功显示“祈愿已记录”。回向输入回向对象+选填祝愿→确认,dedications成功显示“回向已记录”;数据库独立回向明细保留对象和祝愿。
|
||||
5. 断网或停用绑定:三种提交均不显示成功;上香不增加;表单内容保留。切账号后不能沿用旧持有状态,离开不得继续响。
|
||||
6. 真机检查系统静音、音量及来电中断。自动测试验证音量开关、进度推进及结束状态,不能代替听感验收。
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
2. 未登录直达 `pages/fortune/index` 或 `pages/tiangong-detail/index?id=wealth`:只出现登录提示,不能操作恭请、上香、祈愿或其他业务按钮。
|
||||
3. 正常完成登录/注册,重新点击天宫:资料校验通过后可进入;首页、我的的导航不受影响。
|
||||
4. 登录过期返回 401 后:天宫和详情内容隐藏,重新登录后恢复。断网导致校验失败:显示重新加载和返回首页,不展示旧内容。
|
||||
5. H5 普通地址不带演示参数时执行同样拦截;H5 点击登录只提示在微信环境完成,不伪造 code、不再打开“那吉”品牌中间页。明确的演示 URL 保留原型预览。
|
||||
5. H5 普通地址不带演示参数时执行同样拦截;H5 点击登录只提示在微信环境完成,不伪造 code、不再打开“祈运”品牌中间页。明确的演示 URL 保留原型预览。
|
||||
|
||||
自动测试覆盖导航拦截、重复点击、登录失败重试、无 token 不请求资料、真实会话校验、401、网络错误、隐藏/卸载后的异步失效,以及直达页面不挂载业务内容。
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 35 KiB |
+4
-3
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>Hera 首页预览</title>
|
||||
<title>祈运首页预览</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
@@ -195,6 +195,7 @@
|
||||
.radar-value.earth { top:0; background:#c8836d; }.radar-value.metal { right:7px; top:116px; background:#d5aa52; }.radar-value.water { right:63px; bottom:4px; background:#5bb8df; }.radar-value.wood { left:63px; bottom:4px; background:#69bb7e; }.radar-value.fire { left:4px; top:116px; background:#ed6d79; }
|
||||
.element-info-card { margin-top: 20px; padding: 22px 18px; border-radius: 14px; background:rgba(255,255,255,.75); }
|
||||
.element-info-card + .element-info-card { margin-top: 16px; }.element-info-card b { display:block; font-size:19px; }.element-info-card p { margin:14px 0 0; color:#686273; font-size:15px; line-height:1.75; }.element-info-card .tag { position:static; display:inline-block; width:auto; height:auto; padding:7px 18px; border-radius:20px; margin-right:12px; font-size:16px; line-height:1; }.tag.day { background:#ff9b2a; }.tag.good { background:#49bb68; }.tag.avoid { background:#ef5b80; }
|
||||
.follow-qr-placeholder { width:118px; height:118px; display:flex; align-items:center; justify-content:center; margin:0 auto 12px; border-radius:9px; background:#f8f5ee; color:#9e8b65; font-size:13px; font-weight:400; }
|
||||
.follow-preview { text-align:center; }.follow-title-preview { margin-top:140px; color:#8f640d; font-size:34px; line-height:1.65; font-weight:900; }.follow-tip-preview { display:block; margin-top:28px; color:#9e8b65; font-size:16px; }.follow-qr-row { margin-top:52px; display:flex; justify-content:center; gap:18px; }.follow-qr-card { width:142px; padding:12px 10px 18px; border-radius:18px; background:#fff; box-shadow:0 8px 18px rgba(95,75,38,.16); color:#7a4a20; font-size:16px; font-weight:900; }.follow-qr-card img { width:118px; height:118px; object-fit:cover; border-radius:9px; display:block; margin:0 auto 12px; }.follow-search { margin-top:45px; display:flex; justify-content:center; gap:14px; }.follow-search span { padding:10px 14px; border-radius:20px; background:rgba(255,255,255,.8); color:#7a4a20; font-size:13px; box-shadow:0 4px 12px rgba(98,80,49,.1); }.follow-other { display:block; margin-top:30px; color:#9e8b65; font-size:15px; }.follow-brand { display:block; margin-top:26px; color:#8f640d; font-size:34px; font-weight:400; }
|
||||
.agreement-preview { background:#fffdf7 !important; color:#292929; }.agreement-heading { text-align:center; font-size:24px; color:#8f640d; font-weight:700; }.agreement-tabs-preview { margin-top:40px; display:flex; justify-content:space-around; color:#8f640d; font-size:18px; font-weight:700; }.agreement-tabs-preview .selected { border-bottom:3px solid #8f640d; padding-bottom:12px; }.agreement-copy { margin-top:38px; font-size:17px; line-height:1.95; color:#353535; }.agreement-copy h3 { font-size:22px; }.phone.subpage-active .capsule { display:none; }.phone.full-subpage + .tabbar { display:none; }
|
||||
</style>
|
||||
@@ -286,10 +287,10 @@
|
||||
<div class="element-info-card"><b><span class="tag avoid">忌用</span>木火土</b><p>忌用,是容易让你更累、更乱或效率不佳的行为方式。</p></div>
|
||||
</section>
|
||||
<section class="page-view subpage-preview follow-preview" data-page="follow">
|
||||
<button class="preview-back" data-back="my">‹</button><div class="follow-title-preview">关注我们<br>一触即吉</div><span class="follow-tip-preview">点击图片保存,扫码关注</span><div class="follow-qr-row"><div class="follow-qr-card"><img src="./assets/follow-redbook.jpg" alt="小红书二维码">小红书</div><div class="follow-qr-card"><img src="./assets/follow-video.jpg" alt="视频号二维码">视频号</div></div><div class="follow-search"><span>那小吉 ⌕</span><span>那吉 nagiflow ⌕</span></div><span class="follow-other">其他平台搜索我们</span><span class="follow-brand">那吉<br>NAGI FLOW</span>
|
||||
<button class="preview-back" data-back="my">‹</button><div class="follow-title-preview">关注我们<br>一触即吉</div><span class="follow-tip-preview">点击图片保存,扫码关注</span><div class="follow-qr-row"><div class="follow-qr-card"><img src="./assets/follow-redbook.jpg" alt="小红书二维码">小红书</div><div class="follow-qr-card"><div class="follow-qr-placeholder">二维码更新中</div>视频号</div></div><div class="follow-search"><span>祈运 ⌕</span><span>祈运 ⌕</span></div><span class="follow-other">其他平台搜索我们</span><span class="follow-brand">祈运</span>
|
||||
</section>
|
||||
<section class="page-view subpage-preview agreement-preview" data-page="agreement">
|
||||
<button class="preview-back" data-back="my">‹</button><div class="agreement-heading">协议</div><div class="agreement-tabs-preview"><span class="selected">用户协议</span><span>隐私政策</span></div><div class="agreement-copy"><h3>用户协议</h3><p>为使用小程序软件服务,您应当阅读并遵守相关注册协议以及专项规则。请您审慎阅读、充分理解各条款内容,特别是免除或限制责任的相应条款。</p><h3>一、协议的范围</h3><p>本协议是您与那吉之间关于使用那吉软件服务所订立的协议。所有规则均为本协议不可分割的一部分,与协议正文具有同等法律效力。</p><h3>二、适用范围</h3><p>本政策适用于您通过本应用进行的所有操作及我们因此收集的个人信息。</p></div>
|
||||
<button class="preview-back" data-back="my">‹</button><div class="agreement-heading">协议</div><div class="agreement-tabs-preview"><span class="selected">用户协议</span><span>隐私政策</span></div><div class="agreement-copy"><h3>用户协议</h3><p>为使用小程序软件服务,您应当阅读并遵守相关注册协议以及专项规则。请您审慎阅读、充分理解各条款内容,特别是免除或限制责任的相应条款。</p><h3>一、协议的范围</h3><p>本协议是您与祈运之间关于使用祈运软件服务所订立的协议。所有规则均为本协议不可分割的一部分,与协议正文具有同等法律效力。</p><h3>二、适用范围</h3><p>本政策适用于您通过本应用进行的所有操作及我们因此收集的个人信息。</p></div>
|
||||
</section>
|
||||
</main>
|
||||
<nav class="tabbar">
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"miniprogramRoot": "dist/",
|
||||
"projectname": "hera-web",
|
||||
"description": "Hera WeChat mini program",
|
||||
"projectname": "祈运",
|
||||
"description": "祈运微信小程序",
|
||||
"appid": "wxa76a2ef9e8ba612e",
|
||||
"setting": {
|
||||
"urlCheck": false,
|
||||
|
||||
@@ -45,7 +45,7 @@ test('首页和我的未登录入口使用同一登录组件;天宫包装也
|
||||
assert.equal(page('src/pages/home/index.tsx', { guest: true }).type, 'shared-login-gate')
|
||||
assert.equal(page('src/pages/my/index.tsx', { guest: true }).type, 'shared-login-gate')
|
||||
assert.match(read('src/components/TiangongLoginGate.tsx'), /return <LoginGate/)
|
||||
assert.doesNotMatch(read('src/pages/login/index.tsx'), /brand-mark|一触即吉|那吉/)
|
||||
assert.doesNotMatch(read('src/pages/login/index.tsx'), /brand-mark|一触即吉|祈运/)
|
||||
})
|
||||
|
||||
test('首页头像点击使用主Tab导航前往我的页面', () => {
|
||||
|
||||
+18
-14
@@ -53,7 +53,7 @@ test('本期无功德、余额、捐款接口或入口', () => {
|
||||
test('祈愿/回向校验空值,不持久化姓名或祝愿;重置还原样例', () => {
|
||||
const mock = createTiangongMock()
|
||||
assert.throws(() => mock.wish('wealth', ' '), /愿望/)
|
||||
assert.throws(() => mock.dedicate('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('测试'))
|
||||
@@ -95,23 +95,18 @@ test('最后一位解绑锁定,任意一位绑定重新解锁;解绑他位
|
||||
mock.unbind('fortune')
|
||||
assert.equal(hasBoundDeity(mock.snapshot()), true)
|
||||
})
|
||||
test('每位初始2次,重复恭请/取消不扣减或重置,耗尽后送神也不能绕过', () => {
|
||||
test('送神保留绑定和解绑额度,重复请神不重置累计上香次数', () => {
|
||||
const mock = createTiangongMock()
|
||||
mock.incense('wealth'); mock.sendOff('wealth')
|
||||
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)
|
||||
assert.equal(mock.snapshot().deities.wealth.unlocked, false)
|
||||
assert.equal(hasBoundDeity(mock.snapshot()), true)
|
||||
assert.throws(() => mock.incense('wealth'), /未恭请/)
|
||||
mock.invite('wealth')
|
||||
assert.equal(mock.snapshot().deities.wealth.incenseCount, 60)
|
||||
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)
|
||||
assert.equal(hasBoundDeity(mock.snapshot()), false)
|
||||
})
|
||||
test('绑定、解绑、重置同步通知缓存Tab;退订后不再通知', () => {
|
||||
const mock = createTiangongMock()
|
||||
@@ -134,3 +129,12 @@ test('绑定门禁和卡片采用神仙用语,真实NFC与解绑契约不被
|
||||
assert.match(ui, /disabled=\{state.remainingUnbinds === 0\}/)
|
||||
assert.doesNotMatch(ui, /getNFCAdapter|tiangongRemoteContract\./)
|
||||
})
|
||||
|
||||
test('已送神仍可解绑设备,解绑清除再次请神资格并正常扣减额度', () => {
|
||||
const mock = createTiangongMock()
|
||||
mock.sendOff('wealth'); assert.equal(hasBoundDeity(mock.snapshot()), true)
|
||||
mock.unbind('wealth')
|
||||
assert.equal(hasBoundDeity(mock.snapshot()), false)
|
||||
assert.equal(mock.snapshot().deities.wealth.canInvite, false)
|
||||
assert.equal(mock.snapshot().deities.wealth.remainingUnbinds, 1)
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const vm = require('node:vm')
|
||||
const ts = require('typescript')
|
||||
function harness(respond = async () => ({ id: 1 })) {
|
||||
const calls = [], exports = {}
|
||||
const source = fs.readFileSync(path.join(__dirname, '../src/services/tiangongApi.ts'), 'utf8')
|
||||
vm.runInNewContext(ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText, {
|
||||
exports, require: () => ({ request: (...args) => { calls.push(JSON.parse(JSON.stringify(args))); return respond(...args) } })
|
||||
})
|
||||
return { api: exports.tiangongApi, calls }
|
||||
}
|
||||
test('上香、祈愿、回向按新编码提交,各类型不混传明细或接收账号', async () => {
|
||||
const { api, calls } = harness()
|
||||
await api.offer(726, 1)
|
||||
await api.offer(726, 2, '愿顺遂')
|
||||
await api.dedicate(726, '家人', '平安')
|
||||
await api.dedicate(726, '众生', '')
|
||||
assert.deepEqual(calls, [
|
||||
['/api/user/virtual-items/offerings', { method: 'POST', data: { userVirtualItemId: 726, offeringType: 1, incenseCount: 3 } }],
|
||||
['/api/user/virtual-items/offerings', { method: 'POST', data: { userVirtualItemId: 726, offeringType: 2, prayerContent: '愿顺遂' } }],
|
||||
['/api/user/virtual-items/dedications', { method: 'POST', data: { userVirtualItemId: 726, dedicationTarget: '家人', dedicationContent: '平安' } }],
|
||||
['/api/user/virtual-items/dedications', { method: 'POST', data: { userVirtualItemId: 726, dedicationTarget: '众生' } }]
|
||||
])
|
||||
})
|
||||
test('请神送神调用独立生命周期接口', async () => {
|
||||
const { api, calls } = harness()
|
||||
await api.lifecycle(726, 1); await api.lifecycle(726, 2)
|
||||
assert.deepEqual(calls.map(call => call[1].data), [{ userVirtualItemId: 726, actionType: 1 }, { userVirtualItemId: 726, actionType: 2 }])
|
||||
assert.ok(calls.every(call => call[0] === '/api/user/virtual-items/lifecycle'))
|
||||
})
|
||||
test('列表通过持有ID和字典ID共同关联状态,不把未绑定或其他资产当作已送神', async () => {
|
||||
const { api } = harness(async url => url.startsWith('/api/tiangong') ? [
|
||||
{ id: 81, holdingId: 726, active: false, incenseCount: 7 }, { id: 82, holdingId: 727, active: false }, { id: 83, active: false }
|
||||
] : [{ id: 726, virtualItemId: 81, status: 0 }, { id: 727, virtualItemId: 999, status: 0 }])
|
||||
const rows = await api.load()
|
||||
assert.equal(rows[0].holdingStatus, 0); assert.equal(rows[0].incenseCount, 7)
|
||||
assert.equal(rows[1].holdingStatus, undefined); assert.equal(rows[2].holdingStatus, undefined)
|
||||
})
|
||||
test('查询保留后端按当前用户和神仙返回的最新祈愿及回向', async () => {
|
||||
const { api } = harness(async url => url.startsWith('/api/tiangong') ? [
|
||||
{ id: 81, holdingId: 726, active: true,
|
||||
latestPrayer: { offeringId: 10, prayerContent: '愿顺遂' },
|
||||
latestDedication: { offeringId: 11, dedicationTarget: '众生', dedicationContent: null } },
|
||||
{ id: 82, latestPrayer: null, latestDedication: null }
|
||||
] : [{ id: 726, virtualItemId: 81, status: 1 }])
|
||||
const rows = await api.load()
|
||||
assert.equal(rows[0].latestPrayer.prayerContent, '愿顺遂')
|
||||
assert.equal(rows[0].latestDedication.dedicationTarget, '众生')
|
||||
assert.equal(rows[0].latestDedication.dedicationContent, null)
|
||||
assert.equal(rows[1].latestPrayer, null)
|
||||
assert.equal(rows[1].latestDedication, null)
|
||||
})
|
||||
@@ -5,15 +5,16 @@ const path = require('node:path')
|
||||
const vm = require('node:vm')
|
||||
const ts = require('typescript')
|
||||
const root = path.resolve(__dirname, '..')
|
||||
function page(file, { bound = false, demo = false, modal = '', detail = 'https://example.invalid/detail.png', description = '后端返回的介绍\n保留换行' } = {}) {
|
||||
function page(file, { bound = false, demo = false, modal = '', detail = 'https://example.invalid/detail.png', description = '后端返回的介绍\n保留换行', latestPrayer = null, latestDedication = null } = {}) {
|
||||
const deity = { id: 'wealth', name: '财神', ritualName: '武财神', icon: 'local-list.png', statue: 'local-statue.png', introduction: '本地原型介绍', missingStatue: true }
|
||||
const state = { available: true, unlocked: bound, iconUrl: 'https://example.invalid/list.png', iconDetailUrl: detail, description, days: 4, incenseCount: 12 }
|
||||
const state = { available: true, unlocked: bound, iconUrl: 'https://example.invalid/list.png', iconDetailUrl: detail, description, days: 4, incenseCount: 12, latestPrayer, latestDedication }
|
||||
const states = []; let index = 0
|
||||
const exports = {}
|
||||
vm.runInNewContext(ts.transpileModule(fs.readFileSync(path.join(root, file), 'utf8'), { compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2019, jsx: ts.JsxEmit.ReactJSX
|
||||
} }).outputText, { exports, require: id => {
|
||||
if (id === 'react/jsx-runtime') return require(id)
|
||||
if (id.endsWith('.jpg')) return { default: id }
|
||||
if (id === 'react') return { useRef: value => ({ current: value }), useState: initial => {
|
||||
const current = index++
|
||||
if (!(current in states)) states[current] = current === 1 && file.includes('tiangong-detail') ? modal : initial
|
||||
@@ -39,6 +40,42 @@ function nodes(tree) {
|
||||
}
|
||||
const text = tree => nodes(tree).filter(node => node.type === 'Text').map(node => node.props.children).flat().join(' ')
|
||||
const image = tree => nodes(tree).filter(node => node.type === 'Image')
|
||||
const statueImages = tree => image(tree).filter(node => /tg-statue|tg-panel-statue/.test(node.props.className))
|
||||
test('打开祈愿回填最新内容,编辑中不被查询快照覆盖,再次打开恢复已保存内容', () => {
|
||||
const render = page('src/pages/tiangong-detail/index.tsx', { bound: true, latestPrayer: { prayerContent: '已保存的愿望' } })
|
||||
const openWish = () => nodes(render()).find(node => node.props['aria-label'] === '祈愿').props.onClick()
|
||||
const input = () => nodes(render()).find(node => node.type === 'Textarea')
|
||||
openWish()
|
||||
assert.equal(input().props.value, '已保存的愿望')
|
||||
input().props.onInput({ detail: { value: '正在编辑的新愿望' } })
|
||||
assert.equal(input().props.value, '正在编辑的新愿望')
|
||||
nodes(render()).find(node => node.type === 'Button' && node.props.children === '取消').props.onClick()
|
||||
openWish()
|
||||
assert.equal(input().props.value, '已保存的愿望')
|
||||
})
|
||||
test('打开回向回填对象及可选祝愿,没有可用记录时均为空', () => {
|
||||
for (const latestDedication of [null, { dedicationTarget: '家人', dedicationContent: '平安顺遂' }, { dedicationTarget: '一切众生', dedicationContent: null }]) {
|
||||
const render = page('src/pages/tiangong-detail/index.tsx', { bound: true, latestDedication })
|
||||
nodes(render()).find(node => node.props['aria-label'] === '回向').props.onClick()
|
||||
assert.equal(nodes(render()).find(node => node.type === 'Input').props.value, latestDedication?.dedicationTarget || '')
|
||||
assert.equal(nodes(render()).find(node => node.type === 'Textarea').props.value, latestDedication?.dedicationContent || '')
|
||||
}
|
||||
const render = page('src/pages/tiangong-detail/index.tsx', { bound: true })
|
||||
nodes(render()).find(node => node.props['aria-label'] === '祈愿').props.onClick()
|
||||
assert.equal(nodes(render()).find(node => node.type === 'Textarea').props.value, '')
|
||||
})
|
||||
test('请神和敬神均用原生图片加载本地山水,背景位于神像下方', () => {
|
||||
for (const bound of [true, false]) {
|
||||
const tree = page('src/pages/tiangong-detail/index.tsx', { bound })()
|
||||
const scene = nodes(tree).find(node => node.props.className === 'tg-statue-space')
|
||||
assert.equal(scene.props.style?.backgroundImage, undefined)
|
||||
const background = scene.props.children[0]
|
||||
assert.equal(background.type, 'Image')
|
||||
assert.equal(background.props.className, 'tg-scene-background')
|
||||
assert.match(background.props.src, /proto-clouds\.jpg$/)
|
||||
assert.equal(background.props.mode, bound ? 'aspectFill' : 'scaleToFill')
|
||||
}
|
||||
})
|
||||
test('详情及弹窗均使用详情图;仅请神页展示description,敬神页不展示介绍', () => {
|
||||
for (const bound of [true, false]) {
|
||||
const tree = page('src/pages/tiangong-detail/index.tsx', { bound, modal: 'wish' })()
|
||||
@@ -66,12 +103,13 @@ test('敬神页在正式和Demo模式均不显示介绍,缺少介绍也不显
|
||||
test('真实字段为空或加载失败时提示,不以本地神像、列表缩略图或原型介绍顶替', () => {
|
||||
const empty = page('src/pages/tiangong-detail/index.tsx', { detail: '', description: '' })()
|
||||
assert.match(text(empty), /详情图片暂未配置/); assert.match(text(empty), /暂无介绍/)
|
||||
assert.equal(image(empty).length, 0)
|
||||
assert.equal(statueImages(empty).length, 0)
|
||||
const render = page('src/pages/tiangong-detail/index.tsx')
|
||||
image(render())[0].props.onError()
|
||||
statueImages(render())[0].props.onError()
|
||||
const failed = render()
|
||||
assert.match(text(failed), /详情图片加载失败/)
|
||||
assert.equal(image(failed).length, 0)
|
||||
assert.equal(statueImages(failed).length, 0)
|
||||
assert.ok(image(failed).some(node => node.props.className === 'tg-scene-background'))
|
||||
})
|
||||
test('列表保留iconUrl与20%透明度规则,详情字段不误用到列表;显式Demo保留原型', () => {
|
||||
for (const bound of [true, false]) {
|
||||
@@ -82,6 +120,6 @@ test('列表保留iconUrl与20%透明度规则,详情字段不误用到列表
|
||||
}
|
||||
assert.match(fs.readFileSync(path.join(root, 'src/pages/fortune/index.scss'), 'utf8'), /\.deity-card\.locked \.deity-image \{ opacity: \.2; \}/)
|
||||
const demo = page('src/pages/tiangong-detail/index.tsx', { demo: true })()
|
||||
assert.equal(image(demo)[0].props.src, 'local-statue.png')
|
||||
assert.equal(statueImages(demo)[0].props.src, 'local-statue.png')
|
||||
assert.match(text(demo), /本地原型介绍/)
|
||||
})
|
||||
@@ -15,11 +15,11 @@ const { createTiangongStore } = load('src/utils/tiangongStore.ts', { './tiangong
|
||||
const defer = () => { let resolve, reject; const promise = new Promise((ok, fail) => { resolve = ok; reject = fail }); return { promise, resolve, reject } }
|
||||
function harness() {
|
||||
let key = 'account-a', time = 1000, rows = [{ id: 81, name: '财神', holdingId: 726, active: true, incenseCount: 7, days: 3 }]
|
||||
let loader = async () => rows, offer = async () => ({ id: 99 })
|
||||
let loader = async () => rows, offer = async () => ({ id: 99 }), lifecycle = async () => ({ id: 101 }), dedicate = async () => ({ id: 100 })
|
||||
const calls = []
|
||||
const store = createTiangongStore({ key: () => key, now: () => time, resolve: name => name === '财神' ? 'wealth' : undefined,
|
||||
load: () => loader(), offer: (...args) => { calls.push(args); return offer(...args) }, dedicate: async (...args) => { calls.push(args); return { id: 100 } } })
|
||||
return { store, calls, key: value => { key = value }, time: value => { time = value }, rows: value => { rows = value }, load: value => { loader = value }, offer: value => { offer = value } }
|
||||
load: () => loader(), offer: (...args) => { calls.push(args); return offer(...args) }, lifecycle: (...args) => { calls.push(args); return lifecycle(...args) }, dedicate: (...args) => { calls.push(args); return dedicate(...args) } })
|
||||
return { store, calls, key: value => { key = value }, time: value => { time = value }, rows: value => { rows = value }, load: value => { loader = value }, offer: value => { offer = value }, lifecycle: value => { lifecycle = value }, dedicate: value => { dedicate = value } }
|
||||
}
|
||||
test('真实状态不预置财神,按后端持有ID提交,后端确认后才计数', async () => {
|
||||
const h = harness()
|
||||
@@ -29,7 +29,7 @@ test('真实状态不预置财神,按后端持有ID提交,后端确认后才
|
||||
const request = h.store.incense('wealth')
|
||||
assert.equal(h.store.snapshot().deities.wealth.incenseCount, 7)
|
||||
assert.equal(h.store.snapshot().deities.wealth.lit, false)
|
||||
assert.equal(h.calls[0][0], 726); assert.equal(h.calls[0][1], 2)
|
||||
assert.equal(h.calls[0][0], 726); assert.equal(h.calls[0][1], 1)
|
||||
response.resolve({ id: 10 }); await request
|
||||
assert.equal(h.store.snapshot().deities.wealth.incenseCount, 8)
|
||||
assert.equal(h.store.snapshot().deities.wealth.incenseUntil, 16000)
|
||||
@@ -101,8 +101,128 @@ test('祈愿和任意姓名回向去空白提交,不携带功德或注册收
|
||||
const h = harness(); await h.store.refresh()
|
||||
await h.store.wish('wealth', ' 愿顺遂 ')
|
||||
await h.store.dedicate('wealth', ' 家人 ', ' 平安 ')
|
||||
assert.deepEqual(h.calls, [[726, 3, '愿顺遂'], [726, '家人', '平安']])
|
||||
assert.deepEqual(h.calls, [[726, 2, '愿顺遂'], [726, '家人', '平安']])
|
||||
assert.equal(h.store.snapshot().deities.wealth.latestPrayer.prayerContent, '愿顺遂')
|
||||
assert.equal(h.store.snapshot().deities.wealth.latestDedication.dedicationTarget, '家人')
|
||||
assert.equal(h.store.snapshot().deities.wealth.latestDedication.dedicationContent, '平安')
|
||||
await assert.rejects(h.store.wish('wealth', ' '), /愿望/)
|
||||
await assert.rejects(h.store.dedicate('wealth', '', '祝愿'), /回向对象/)
|
||||
assert.equal(h.calls.length, 2)
|
||||
})
|
||||
|
||||
test('最新祈愿回向随查询加载,失效或账号切换清空,不影响其他神仙', async () => {
|
||||
const h = harness()
|
||||
const row = { id: 81, name: '财神', holdingId: 726, active: true, incenseCount: 0, days: 1,
|
||||
latestPrayer: { offeringId: 10, prayerContent: '已保存愿望' },
|
||||
latestDedication: { offeringId: 11, dedicationTarget: '众生', dedicationContent: null } }
|
||||
h.rows([row]); await h.store.refresh()
|
||||
const first = h.store.snapshot().deities
|
||||
assert.equal(first.wealth.latestPrayer.prayerContent, '已保存愿望')
|
||||
assert.equal(first.wealth.latestDedication.dedicationTarget, '众生')
|
||||
assert.equal(first.wealth.latestDedication.dedicationContent, null)
|
||||
assert.equal(first.wenchang.latestPrayer, undefined)
|
||||
first.wealth.latestPrayer.prayerContent = '修改快照不污染缓存'
|
||||
assert.equal(h.store.snapshot().deities.wealth.latestPrayer.prayerContent, '已保存愿望')
|
||||
h.rows([{ ...row, latestPrayer: null, latestDedication: null }]); await h.store.refresh(true)
|
||||
assert.equal(h.store.snapshot().deities.wealth.latestPrayer, null)
|
||||
assert.equal(h.store.snapshot().deities.wealth.latestDedication, null)
|
||||
h.rows([row]); await h.store.refresh(true)
|
||||
h.key('account-b')
|
||||
assert.equal(h.store.snapshot().deities.wealth.latestPrayer, undefined)
|
||||
assert.equal(h.store.snapshot().deities.wealth.latestDedication, undefined)
|
||||
})
|
||||
|
||||
test('祈愿确认后才更新回显,失败和账号切换不写入旧账号内容', async () => {
|
||||
const h = harness(); await h.store.refresh()
|
||||
const response = defer(); h.offer(() => response.promise)
|
||||
const request = h.store.wish('wealth', '待确认的愿望')
|
||||
assert.equal(h.store.snapshot().deities.wealth.latestPrayer, null)
|
||||
response.resolve({ id: 105 }); await request
|
||||
assert.equal(h.store.snapshot().deities.wealth.latestPrayer.prayerContent, '待确认的愿望')
|
||||
h.offer(async () => { throw new Error('提交失败') })
|
||||
await assert.rejects(h.store.wish('wealth', '失败愿望'), /提交失败/)
|
||||
assert.equal(h.store.snapshot().deities.wealth.latestPrayer.prayerContent, '待确认的愿望')
|
||||
const pending = defer(); h.offer(() => pending.promise)
|
||||
const sending = h.store.wish('wealth', '旧账号愿望')
|
||||
h.key('account-b'); h.store.snapshot()
|
||||
pending.resolve({ id: 106 }); await assert.rejects(sending, /登录状态/)
|
||||
assert.equal(h.store.snapshot().deities.wealth.latestPrayer, undefined)
|
||||
})
|
||||
|
||||
test('回向成功后可回显空祝愿,失败不覆盖已保存对象', async () => {
|
||||
const h = harness(); await h.store.refresh()
|
||||
await h.store.dedicate('wealth', '家人', '平安')
|
||||
h.dedicate(async () => { throw new Error('提交失败') })
|
||||
await assert.rejects(h.store.dedicate('wealth', '失败对象', ''), /提交失败/)
|
||||
assert.equal(h.store.snapshot().deities.wealth.latestDedication.dedicationTarget, '家人')
|
||||
const response = defer(); h.dedicate(() => response.promise)
|
||||
const sending = h.store.dedicate('wealth', '众生', '')
|
||||
assert.equal(h.store.snapshot().deities.wealth.latestDedication.dedicationContent, '平安')
|
||||
response.resolve({ id: 101 }); await sending
|
||||
assert.equal(h.store.snapshot().deities.wealth.latestDedication.dedicationTarget, '众生')
|
||||
assert.equal(h.store.snapshot().deities.wealth.latestDedication.dedicationContent, '')
|
||||
})
|
||||
|
||||
test('送神和重新请神使用独立流水,成功后才改变状态且保留次数', async () => {
|
||||
const h = harness(); await h.store.refresh()
|
||||
const response = defer(); h.lifecycle(() => response.promise)
|
||||
const sending = h.store.sendOff('wealth')
|
||||
assert.equal(h.store.snapshot().deities.wealth.unlocked, true)
|
||||
await assert.rejects(h.store.sendOff('wealth'), /提交/)
|
||||
await assert.rejects(h.store.incense('wealth'), /提交/)
|
||||
response.resolve({ id: 102 }); await sending
|
||||
const stopped = h.store.snapshot().deities.wealth
|
||||
assert.equal(stopped.unlocked, false); assert.equal(stopped.canInvite, true)
|
||||
assert.equal(stopped.incenseCount, 7); assert.equal(stopped.incenseUntil, 0)
|
||||
assert.equal(mock.hasBoundDeity(h.store.snapshot()), true)
|
||||
await assert.rejects(h.store.wish('wealth', '愿顺遂'), /绑定/)
|
||||
h.lifecycle(async () => ({ id: 103 }))
|
||||
await h.store.invite('wealth')
|
||||
assert.equal(h.store.snapshot().deities.wealth.unlocked, true)
|
||||
assert.equal(h.store.snapshot().deities.wealth.incenseCount, 7)
|
||||
assert.deepEqual(h.calls, [[726, 2], [726, 1]])
|
||||
})
|
||||
test('刷新后只允许已送神持有关系重新请神,失败及账号切换不伪造成功', async () => {
|
||||
const h = harness()
|
||||
h.rows([{ id: 81, name: '财神', holdingId: 726, holdingStatus: 0, active: false, incenseCount: 7 }])
|
||||
await h.store.refresh(); assert.equal(h.store.snapshot().deities.wealth.canInvite, true)
|
||||
h.lifecycle(async () => { throw new Error('设备已解绑') })
|
||||
await assert.rejects(h.store.invite('wealth'), /设备已解绑/)
|
||||
assert.equal(h.store.snapshot().deities.wealth.unlocked, false)
|
||||
const response = defer(); h.lifecycle(() => response.promise)
|
||||
const sending = h.store.invite('wealth'); h.key('account-b'); h.store.snapshot()
|
||||
response.resolve({ id: 12 }); await assert.rejects(sending, /登录状态/)
|
||||
assert.equal(h.store.snapshot().deities.wealth.unlocked, false)
|
||||
for (const status of [undefined, 1, 3]) {
|
||||
h.rows([{ id: 81, name: '财神', holdingId: 726, holdingStatus: status, active: false }])
|
||||
await h.store.refresh(true)
|
||||
await assert.rejects(h.store.invite('wealth'), /当前状态/)
|
||||
}
|
||||
})
|
||||
test('回向对象允许255字,祝愿可空;上香在途不能送神', async () => {
|
||||
const h = harness(); await h.store.refresh()
|
||||
await h.store.dedicate('wealth', '甲'.repeat(255), '')
|
||||
await assert.rejects(h.store.dedicate('wealth', '甲'.repeat(256), ''), /回向对象/)
|
||||
await assert.rejects(h.store.dedicate('wealth', '甲', '愿'.repeat(501)), /500/)
|
||||
const response = defer(); h.offer(() => response.promise)
|
||||
const sending = h.store.incense('wealth')
|
||||
await assert.rejects(h.store.sendOff('wealth'), /提交/)
|
||||
response.resolve({ id: 23 }); await sending
|
||||
assert.equal(h.store.snapshot().deities.wealth.incenseCount, 8)
|
||||
})
|
||||
|
||||
test('送神失败保留供奉状态,无效流水不显示成功,晚到列表不撤销已送神', async () => {
|
||||
const h = harness(); await h.store.refresh()
|
||||
h.lifecycle(async () => { throw new Error('网络失败') })
|
||||
await assert.rejects(h.store.sendOff('wealth'), /网络失败/)
|
||||
assert.equal(h.store.snapshot().deities.wealth.unlocked, true)
|
||||
h.lifecycle(async () => ({ id: 0 }))
|
||||
await assert.rejects(h.store.sendOff('wealth'), /有效记录/)
|
||||
assert.equal(h.store.snapshot().deities.wealth.unlocked, true)
|
||||
const read = defer(); h.load(() => read.promise)
|
||||
const refreshing = h.store.refresh(true)
|
||||
h.lifecycle(async () => ({ id: 15 })); await h.store.sendOff('wealth')
|
||||
read.resolve([{ id: 81, name: '财神', holdingId: 726, active: true, incenseCount: 7 }]); await refreshing
|
||||
assert.equal(h.store.snapshot().deities.wealth.unlocked, false)
|
||||
assert.equal(h.store.snapshot().deities.wealth.canInvite, true)
|
||||
})
|
||||
@@ -11,6 +11,7 @@ export default defineAppConfig({
|
||||
'pages/agreement/index'
|
||||
],
|
||||
window: {
|
||||
navigationBarTitleText: '祈运',
|
||||
backgroundTextStyle: 'light',
|
||||
navigationStyle: 'custom',
|
||||
backgroundColor: '#fffdf7'
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 35 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
@@ -39,5 +39,6 @@
|
||||
.app-person-body { position: absolute; left: 4px; bottom: 0; width: 44px; height: 24px; border-radius: 23px 23px 7px 7px; background: currentColor; }
|
||||
// 共用48px图标占位,放大图标只向上延伸,不挤压或裁切下方文字。
|
||||
.app-realm-symbol { position: relative; width: 116px; height: 48px; flex: none; }
|
||||
.app-spirit-frame { position: absolute; left: 0; bottom: 0; width: 116px; height: 116px; padding: 9px; border: 2px solid #fff; box-sizing: border-box; border-radius: 50%; background: linear-gradient(145deg,#fff,#edf3ff); box-shadow: 0 7px 20px rgba(137,110,160,.12); }
|
||||
.app-spirit-image { display: block; width: 100%; height: 100%; border-radius: 50%; }
|
||||
.app-realm-frame { position: absolute; left: 0; bottom: 0; width: 116px; height: 116px; padding: 9px; border: 2px solid #fff; box-sizing: border-box; border-radius: 50%; background: linear-gradient(145deg,#fff,#edf3ff); box-shadow: 0 7px 20px rgba(137,110,160,.12); }
|
||||
.app-realm-image-clip { width: 100%; height: 100%; border-radius: 50%; overflow: hidden; }
|
||||
.app-realm-image { display: block; width: 100%; height: 100%; transform: scale(1.18); transform-origin: 50% 51%; }
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Button, Image, Text, View } from '@tarojs/components'
|
||||
import { MainTab, switchMainTab } from '../services/tabNavigation'
|
||||
import spirit from '../assets/home/spirit.png'
|
||||
import palace from '../assets/home/tiangong-palace.jpg'
|
||||
import './AppTabBar.scss'
|
||||
|
||||
export function AppTabBar({ active }: { active: MainTab }) {
|
||||
@@ -11,7 +11,7 @@ export function AppTabBar({ active }: { active: MainTab }) {
|
||||
<Text className='app-tabbar-label'>首页</Text>
|
||||
</Button>
|
||||
<Button className={`app-tabbar-realm ${active === 'fortune' ? 'active' : ''}`} onClick={() => navigate('fortune')}>
|
||||
<View className='app-realm-symbol'><View className='app-spirit-frame'><Image className='app-spirit-image' src={spirit} mode='aspectFit' /></View></View>
|
||||
<View className='app-realm-symbol'><View className='app-realm-frame'><View className='app-realm-image-clip'><Image className='app-realm-image' src={palace} mode='aspectFit' /></View></View></View>
|
||||
<Text className='app-tabbar-label'>天宫</Text>
|
||||
</Button>
|
||||
<Button className={`app-tabbar-item ${active === 'my' ? 'active' : ''}`} onClick={() => navigate('my')}>
|
||||
|
||||
@@ -41,7 +41,7 @@ export function DeityCardDialog({ deityId, mode = 'view', onClose }: { deityId:
|
||||
}
|
||||
return <View className='binding-dialog-mask' catchMove>
|
||||
<View className='binding-dialog-stack'>
|
||||
<View className='binding-gold-card' role='dialog' aria-label={demo && mode === 'bind' ? '确认绑定神仙' : state.unlocked ? '已绑定神仙详情' : '神仙信息'}>
|
||||
<View className='binding-gold-card' role='dialog' aria-label={demo && mode === 'bind' ? '确认绑定神仙' : state.unlocked || state.canInvite ? '已绑定神仙详情' : '神仙信息'}>
|
||||
<View className='binding-card-hook' /><View className='binding-card-corner top' /><View className='binding-card-corner bottom' />
|
||||
<View className='binding-card-title-row'><Text className='binding-card-spark'>✦</Text><Text className='binding-card-name'>{deity.name}</Text><Text className='binding-card-spark'>✦</Text>
|
||||
{demo && mode === 'view' && <Button className='binding-more' aria-label='神仙更多操作' onClick={() => setMenu(!menu)}>•••</Button>}
|
||||
@@ -56,7 +56,7 @@ export function DeityCardDialog({ deityId, mode = 'view', onClose }: { deityId:
|
||||
tiangongMock.invite(deityId)
|
||||
Taro.showToast({ title: '模拟绑定成功', icon: 'none' })
|
||||
})}>确认绑定</Button>
|
||||
</> : state.unlocked ? <View className='binding-guarded'><View className='binding-shield'>✓</View><Text>已守护 <Text className='binding-days'>{state.days}</Text> 天</Text></View> : <Text className='binding-card-note'>请通过 NFC 绑定神仙。</Text>}
|
||||
</> : state.unlocked ? <View className='binding-guarded'><View className='binding-shield'>✓</View><Text>已守护 <Text className='binding-days'>{state.days}</Text> 天</Text></View> : state.canInvite ? <Text className='binding-card-note'>已送神,设备仍绑定,可前往天宫再次请神。</Text> : <Text className='binding-card-note'>请通过 NFC 绑定神仙。</Text>}
|
||||
{error && <Text className='binding-error'>{error}</Text>}
|
||||
</View>
|
||||
<Button className='binding-card-close' aria-label='关闭神仙卡片' onClick={onClose}>×</Button>
|
||||
@@ -89,7 +89,7 @@ export function DeityBindingFlow({ initialDeity, onClose }: { initialDeity?: Dei
|
||||
const snapshot = useTiangongSnapshot()
|
||||
if (!isTiangongDemo()) return <View className='binding-dialog-mask' catchMove><View className='binding-picker' role='dialog' aria-label='绑定神仙说明'><Text className='binding-confirm-title'>碰一碰,绑定神仙</Text><Text className='binding-picker-note'>请通过 NFC 绑定神仙,本次不在页面中模拟真实设备绑定。</Text><Button className='binding-outline binding-picker-cancel' onClick={onClose}>关闭</Button></View></View>
|
||||
if (selected) return <DeityCardDialog deityId={selected} mode='bind' onClose={onClose} />
|
||||
const available = deityCatalog.filter(item => !snapshot.deities[item.id].unlocked)
|
||||
const available = deityCatalog.filter(item => !snapshot.deities[item.id].unlocked && !snapshot.deities[item.id].canInvite)
|
||||
return <View className='binding-dialog-mask' catchMove><View className='binding-picker' role='dialog' aria-label='模拟 NFC 识别'>
|
||||
<Text className='binding-confirm-title'>碰一碰,绑定神仙</Text>
|
||||
<Text className='binding-picker-note'>Mock 演示:选择要模拟识别的神仙。不会开启 NFC、连接设备或绑定真实微信号。</Text>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
.radar { position: relative; width: 100%; height: 490px; margin: 0 auto; }
|
||||
.radar-plot { position: absolute; width: 66%; height: 360px; left: 17%; top: 75px; }
|
||||
.radar { position: relative; width: 100%; padding: 75px 0 80px; box-sizing: border-box; margin: 0 auto; }
|
||||
// Percentage padding uses the parent's width, keeping the plot square in WXSS.
|
||||
.radar-plot { position: relative; width: 66%; height: 0; padding-bottom: 66%; box-sizing: content-box; margin: 0 auto; }
|
||||
.radar-ring, .radar-ring-inner, .radar-fill {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -15,11 +16,10 @@
|
||||
.radar-name-row { display: flex; align-items: center; gap: 6px; }
|
||||
.radar-name { font-size: 32px; line-height: 40px; }
|
||||
.radar-value { min-width: 64px; height: 28px; padding: 0 8px; box-sizing: border-box; border-radius: 20px; color: #fff; font-size: 21px; font-weight: 500; line-height: 28px; text-align: center; }
|
||||
.radar-label-0 { left: 50%; top: 0; transform: translateX(-50%); }
|
||||
.radar-label-1 { right: 2%; top: 177px; }
|
||||
.radar-label-2 { right: 20%; bottom: 0; }
|
||||
.radar-label-3 { left: 20%; bottom: 0; }
|
||||
.radar-label-4 { left: 2%; top: 177px; }
|
||||
.radar-label-0 { transform: translate(-50%, calc(-100% - 16px)); }
|
||||
.radar-label-1 { transform: translate(16px, -50%); }
|
||||
.radar-label-2, .radar-label-3 { transform: translate(-50%, 20px); }
|
||||
.radar-label-4 { transform: translate(calc(-100% - 16px), -50%); }
|
||||
.element-mark { position: relative; width: 28px; height: 30px; border-radius: 11px 13px 10px 12px; background: currentColor; box-shadow: inset 5px 5px 8px rgba(255,255,255,.35); }
|
||||
.mark-1 { border-radius: 8px 8px 16px 16px; height: 23px; }
|
||||
.mark-2 { border-radius: 70% 35% 65% 40%; transform: rotate(25deg); }
|
||||
|
||||
@@ -38,18 +38,18 @@ export function RadarChart({ scores }: Props) {
|
||||
{dataPoints.map((point, index) => (
|
||||
<View key={order[index]} className='radar-dot' style={{ left: `${point.x}%`, top: `${point.y}%`, backgroundColor: colors[index] }} />
|
||||
))}
|
||||
</View>
|
||||
{values.map((score, index) => (
|
||||
<View key={score.name} className={`radar-label radar-label-${index}`} style={{ color: score.color }}>
|
||||
<View className='radar-name-row'>
|
||||
<View className={`element-mark mark-${index}`}>
|
||||
<View className='mark-eye left' /><View className='mark-eye right' /><View className='mark-smile' />
|
||||
{values.map((score, index) => (
|
||||
<View key={score.name} className={`radar-label radar-label-${index}`} style={{ color: score.color, left: `${points[index].x}%`, top: `${points[index].y}%` }}>
|
||||
<View className='radar-name-row'>
|
||||
<View className={`element-mark mark-${index}`}>
|
||||
<View className='mark-eye left' /><View className='mark-eye right' /><View className='mark-smile' />
|
||||
</View>
|
||||
<Text className='radar-name'>{score.name}</Text>
|
||||
</View>
|
||||
<Text className='radar-name'>{score.name}</Text>
|
||||
<Text className='radar-value' style={{ backgroundColor: score.color }}>{score.value}%</Text>
|
||||
</View>
|
||||
<Text className='radar-value' style={{ backgroundColor: score.color }}>{score.value}%</Text>
|
||||
</View>
|
||||
))}
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -45,6 +45,7 @@ export function useIncenseRitual(deityId?: DeityId) {
|
||||
|
||||
return {
|
||||
soundEnabled,
|
||||
stop: leave,
|
||||
pending,
|
||||
toggleSound: () => {
|
||||
const next = !enabled.current
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#a184fa" />
|
||||
<title>Hera 前端预览</title>
|
||||
<title>祈运</title>
|
||||
<script><%= htmlWebpackPlugin.options.script %></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -5,7 +5,7 @@ import './index.scss'
|
||||
export default function ContactPage() {
|
||||
return (
|
||||
<View className='contact-page'>
|
||||
<NavBar back title='纳吉云 ›' />
|
||||
<NavBar back title='祈运 ›' />
|
||||
<Text className='session-subtitle'>客服会话</Text>
|
||||
<View className='chat-input'>
|
||||
<Input placeholder='可按住 转文字' />
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
.follow-page {
|
||||
--follow-safe-top: env(safe-area-inset-top, 0px);
|
||||
padding-top: calc(96px + var(--follow-safe-top));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.follow-page .nav {
|
||||
height: calc(92px + var(--follow-safe-top));
|
||||
}
|
||||
|
||||
.follow-title {
|
||||
margin-top: 210px;
|
||||
color: #8f640d;
|
||||
@@ -48,6 +54,16 @@
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.qr-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f8f5ee;
|
||||
color: #9e8b65;
|
||||
font-size: 24px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.search-row {
|
||||
margin-top: 86px;
|
||||
display: flex;
|
||||
|
||||
@@ -1,20 +1,28 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { CSSProperties } from 'react'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { Image, Text, View } from '@tarojs/components'
|
||||
import { NavBar } from '../../components/NavBar'
|
||||
import { api } from '../../services/request'
|
||||
import redBookQr from '../../assets/follow-redbook.jpg'
|
||||
import videoQr from '../../assets/follow-video.jpg'
|
||||
import './index.scss'
|
||||
|
||||
export default function FollowPage() {
|
||||
const [data, setData] = useState<Record<string, string>>({})
|
||||
const [safeAreaStyle] = useState<CSSProperties | undefined>(() => {
|
||||
if (process.env.TARO_ENV !== 'weapp') return undefined
|
||||
const info = typeof Taro.getWindowInfo === 'function' ? Taro.getWindowInfo() : Taro.getSystemInfoSync()
|
||||
// Keep the device inset in physical pixels, outside Taro's rpx conversion.
|
||||
const top = Math.max(info.statusBarHeight || 0, info.safeArea?.top || 0)
|
||||
return { '--follow-safe-top': `${top}px` } as CSSProperties
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
api.follow().then(setData).catch(() => setData({}))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<View className='page follow-page soft-bg'>
|
||||
<View className='page follow-page soft-bg' style={safeAreaStyle}>
|
||||
<NavBar back />
|
||||
<Text className='follow-title'>关注我们{'\n'}一触即吉</Text>
|
||||
<Text className='follow-tip'>点击图片保存,扫码关注</Text>
|
||||
@@ -24,16 +32,16 @@ export default function FollowPage() {
|
||||
<Text>小红书</Text>
|
||||
</View>
|
||||
<View className='qr-card'>
|
||||
<Image className='qr-image' src={data.videoQr || videoQr} mode='aspectFit' />
|
||||
{data.videoQr ? <Image className='qr-image' src={data.videoQr} mode='aspectFit' /> : <View className='qr-image qr-placeholder'><Text>二维码更新中</Text></View>}
|
||||
<Text>视频号</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className='search-row'>
|
||||
<View className='search-pill'>{data.redBookKeyword || '那小吉'} 🔍</View>
|
||||
<View className='search-pill'>{data.videoKeyword || '那吉 nagiflow'} 🔍</View>
|
||||
<View className='search-pill'>{data.redBookKeyword || '祈运'} 🔍</View>
|
||||
<View className='search-pill'>{data.videoKeyword || '祈运'} 🔍</View>
|
||||
</View>
|
||||
<Text className='other'>其他平台搜索我们</Text>
|
||||
<Text className='brand'>{data.brand || '那吉 NAGI FLOW'}</Text>
|
||||
<Text className='brand'>{data.brand || '祈运'}</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -63,7 +63,7 @@ export default function MyPage() {
|
||||
})
|
||||
const bindingSnapshot = useTiangongSnapshot()
|
||||
const unlocked = hasBoundDeity(bindingSnapshot)
|
||||
const boundDeities = deityCatalog.filter(item => bindingSnapshot.deities[item.id].unlocked)
|
||||
const boundDeities = deityCatalog.filter(item => (bindingSnapshot.deities[item.id].unlocked || bindingSnapshot.deities[item.id].canInvite === true))
|
||||
const [bindingOpen, setBindingOpen] = useState(false)
|
||||
const [cardId, setCardId] = useState<DeityId | null>(null)
|
||||
const [profile, setProfile] = useState<UserProfile | null>(getCachedProfile)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
.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-space { position: relative; flex: 1; min-height: 0; overflow: hidden; background: #d0e4d9; }
|
||||
.tg-scene-background { position: absolute; left: 0; top: 0; width: 100%; height: 100%; pointer-events: none; }
|
||||
.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; }
|
||||
.tg-image-note { height: 100%; display: flex; align-items: center; justify-content: center; color: #85806b; font-size: 25px; }
|
||||
.tg-image-note { position: relative; height: 100%; display: flex; align-items: center; justify-content: center; color: #85806b; font-size: 25px; }
|
||||
// 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; }
|
||||
@@ -22,7 +23,7 @@
|
||||
// Grow the landscape, rather than leaving an empty footer on tall screens.
|
||||
// On short screens the minimum scene size and copy flow naturally by scrolling.
|
||||
.invitation .tg-sacred-scene { flex: 1 0 auto; min-height: 118vw; }
|
||||
.invitation .tg-statue-space { flex: 1 0 auto; min-height: 118vw; background-size: 100% 100%; background-position: center; }
|
||||
.invitation .tg-statue-space { flex: 1 0 auto; min-height: 118vw; }
|
||||
.invitation .tg-statue { bottom: 9%; width: 100%; height: 88%; max-height: none; }
|
||||
.invitation .tg-statue.catalog-art { bottom: 12%; width: 76%; height: 74%; }
|
||||
.tg-invite-description { flex: none; padding-bottom: calc(22px + env(safe-area-inset-bottom)); background: linear-gradient(180deg,#fcf3d7,#faf0ce); border-radius: 0 0 26px 26px; overflow: hidden; }
|
||||
|
||||
@@ -16,7 +16,7 @@ import altar from '../../assets/tiangong/proto-altar.jpg'
|
||||
import altarLit from '../../assets/tiangong/proto-altar-lit.jpg'
|
||||
import './index.scss'
|
||||
|
||||
type Modal = '' | 'wish' | 'dedicate' | 'send-off' | 'stand' | 'waiting' | 'invited' | 'result'
|
||||
type Modal = '' | 'wish' | 'dedicate' | 'send-off' | 'invite' | 'stand' | 'waiting' | 'invited' | 'result'
|
||||
export default function TiangongDetailPage() {
|
||||
const auth = useRequiredLogin()
|
||||
if (auth.status !== 'allowed' || !hasLoginAccess()) return <TiangongLoginGate status={auth.status === 'allowed' ? 'denied' : auth.status} retry={auth.retry} />
|
||||
@@ -40,7 +40,15 @@ function TiangongDetailContent() {
|
||||
const working = useRef(false)
|
||||
const demo = isTiangongDemo()
|
||||
const close = () => { if (!working.current) { setModal(''); setError('') } }
|
||||
const open = (next: Modal) => { setError(''); setModal(next) }
|
||||
const open = (next: Modal) => {
|
||||
const saved = deity ? snapshot.deities[deity.id] : undefined
|
||||
if (next === 'wish') setWish(saved?.latestPrayer?.prayerContent || '')
|
||||
if (next === 'dedicate') {
|
||||
setName(saved?.latestDedication?.dedicationTarget || '')
|
||||
setBlessing(saved?.latestDedication?.dedicationContent || '')
|
||||
}
|
||||
setError(''); setModal(next)
|
||||
}
|
||||
const back = () => {
|
||||
if (Taro.getCurrentPages().length > 1) Taro.navigateBack()
|
||||
else Taro.switchTab({ url: '/pages/fortune/index' })
|
||||
@@ -76,17 +84,18 @@ function TiangongDetailContent() {
|
||||
</Button>
|
||||
} /><TiangongMockNote />
|
||||
<View className='tg-sacred-scene'>
|
||||
<View className='tg-statue-space' style={{ backgroundImage: `url(${clouds})` }}>
|
||||
<View className='tg-statue-space'>
|
||||
{/* Native Image supports bundled assets on real WeChat devices. */}
|
||||
<Image className='tg-scene-background' src={clouds} mode={invited ? 'aspectFill' : 'scaleToFill'} />
|
||||
{showImage ? <Image className={`tg-statue ${demo && deity.missingStatue ? 'catalog-art' : ''}`} src={detailImage} mode='aspectFit' onError={() => setFailedImage(detailImage)} /> : <View className='tg-image-note'><Text>{imageNote}</Text></View>}
|
||||
{invited && <Text className='tg-incense-count'>上香 {state.incenseCount}{ritual.pending > 0 ? ' · 提交中' : ''}</Text>}
|
||||
</View>
|
||||
{invited && <View className={`tg-altar-frame ${state.lit ? 'lit' : ''}`}><Image className='tg-altar' src={state.lit ? altarLit : altar} mode='scaleToFill' /></View>}
|
||||
</View>
|
||||
{invited ? <View className='worship-actions'>
|
||||
{['上香', '祈愿', '回向', '送神', '返回'].map(action => <Button className='worship-action' key={action} aria-label={action} onClick={() => {
|
||||
{['上香', '祈愿', '回向', '送神', '返回'].map(action => <Button className='worship-action' key={action} aria-label={action} disabled={busy || (action === '送神' && ritual.pending > 0)} onClick={() => {
|
||||
if (action === '上香') ritual.incense()
|
||||
else if (action === '返回') back()
|
||||
else if (action === '送神' && !demo) showResult('送神解绑流程尚未接入,本次未变更供奉关系。')
|
||||
else open(action === '祈愿' ? 'wish' : action === '回向' ? 'dedicate' : 'send-off')
|
||||
}}><Text>{action[0]}</Text><Text>{action[1]}</Text></Button>)}
|
||||
</View> : <View className='tg-invite-description'>
|
||||
@@ -94,10 +103,10 @@ function TiangongDetailContent() {
|
||||
<Text className='tg-introduction'>{introduction}</Text>
|
||||
{demo && deity.introduction && <Text className='tg-content-note'>原型介绍与“禄星”名称不一致,文案待确认。</Text>}
|
||||
{demo && deity.missingStatue && <Text className='tg-content-note'>原型未提供独立详情神像,暂使用同一神仙的列表原图。</Text>}
|
||||
<Button className='tg-invite-main' onClick={() => demo ? open('stand') : showResult('请先通过 NFC 绑定该神仙。本页不模拟建立真实供奉关系。')}>请神</Button>
|
||||
<Button className='tg-invite-main' onClick={() => state.canInvite ? open('invite') : demo ? open('stand') : showResult('请先通过 NFC 绑定该神仙;已过期的供奉关系需要重新绑定。')}>请神</Button>
|
||||
{demo && <Text className='tg-content-note'>仅演示恭请流程,不扣费、不建立真实供奉关系。</Text>}
|
||||
</View>}
|
||||
{modal && <TiangongModal title={{ wish: '祈愿', dedicate: '回向', 'send-off': '送神确认', stand: '支架确认', waiting: '等待支架连接', invited: '请神成功', result: '操作结果' }[modal]} onClose={busy ? () => {} : close} variant={['stand', 'waiting'].includes(modal) ? 'plain' : ''}>
|
||||
{modal && <TiangongModal title={{ wish: '祈愿', dedicate: '回向', 'send-off': '送神确认', invite: '请神确认', stand: '支架确认', waiting: '等待支架连接', invited: '请神成功', result: '操作结果' }[modal]} onClose={busy ? () => {} : close} variant={['stand', 'waiting'].includes(modal) ? 'plain' : ''}>
|
||||
{modal === 'wish' && <>
|
||||
{panelHeader(`向【${deity.ritualName}】祈愿`, '诚心许愿,助您愿望实现!')}
|
||||
<Text className='tg-form-label'>您的愿望</Text><Textarea className='tg-textarea' aria-label='您的愿望' placeholder='请输入您的愿望' value={wish} onInput={e => setWish(e.detail.value)} maxlength={500} fixed />
|
||||
@@ -106,14 +115,15 @@ function TiangongDetailContent() {
|
||||
{modal === 'dedicate' && <>
|
||||
{panelHeader('将美好祝愿,送给牵挂的人。', '请填写回向对象与祝愿。')}
|
||||
<View className='tg-worship-stats'><Text>供奉天数:{state.days}</Text><Text>上香次数:{state.incenseCount}</Text></View>
|
||||
<Text className='tg-form-label'>特别回向给</Text><Input className='tg-input' aria-label='回向对象' placeholder='请输入 ta 的名字' value={name} onInput={e => setName(e.detail.value)} maxlength={50} />
|
||||
<Text className='tg-form-label'>愿 TA</Text><Textarea className='tg-textarea' aria-label='回向祝愿' placeholder='请输入祝愿' value={blessing} onInput={e => setBlessing(e.detail.value)} maxlength={500} fixed />
|
||||
<Text className='tg-form-label'>特别回向给</Text><Input className='tg-input' aria-label='回向对象' placeholder='请输入回向对象' value={name} onInput={e => setName(e.detail.value)} maxlength={255} />
|
||||
<Text className='tg-form-label'>愿 TA</Text><Textarea className='tg-textarea' aria-label='回向祝愿' placeholder='请输入祝愿(选填)' value={blessing} onInput={e => setBlessing(e.detail.value)} maxlength={500} fixed />
|
||||
{dialogActions('回向', submitDedication)}
|
||||
</>}
|
||||
{modal === 'send-off' && <><Text className='tg-modal-title'>确认恭送{deity.ritualName}?</Text><Text className='tg-modal-copy'>上奏天恩,感谢神灵保佑</Text><Text className='tg-modal-caption'>送神将解绑该神仙,并从「我的天宫」移除。该神仙今年还剩 {state.remainingUnbinds} 次解绑机会。</Text>{state.remainingUnbinds === 0 && <Text className='tg-error'>今年解绑次数已用尽,暂不可送神解绑。</Text>}<Text className='tg-modal-caption'>仅改变本次 Mock 会话状态,不连接真实设备。</Text>{dialogActions('确定', () => perform(() => { tiangongMock.sendOff(deity.id); close(); back() }), true, state.remainingUnbinds === 0)}</>}
|
||||
{modal === 'send-off' && <><Text className='tg-modal-title'>确认恭送{deity.ritualName}?</Text><Text className='tg-modal-copy'>上奏天恩,感谢神灵保佑</Text><Text className='tg-modal-caption'>送神将停止当前供奉,保留设备绑定和上香记录,可再次请神。</Text>{dialogActions('确定送神', () => perform(async () => { await tiangongSession.sendOff(deity.id); ritual.stop(); showResult(demo ? '模拟送神已完成,设备绑定仍保留。' : '已送神,设备绑定仍保留,可再次请神。') }), true, ritual.pending > 0)}</>}
|
||||
{modal === 'invite' && <><Text className='tg-modal-title'>确认恭请{deity.ritualName}?</Text><Text className='tg-modal-caption'>使用已绑定的设备恢复供奉。</Text>{dialogActions('确定请神', () => perform(async () => { await tiangongSession.invite(deity.id); open('invited') }))}</>}
|
||||
{modal === 'stand' && <><Text className='tg-modal-title'>请碰一碰您购买的支架</Text><Text className='tg-modal-caption'>当前是 Mock,不会读取 NFC、蓝牙或手机设备。</Text>{dialogActions('确定', () => open('waiting'))}</>}
|
||||
{modal === 'waiting' && <><Text className='tg-modal-title'>请碰一碰您购买的手机支架</Text><View className='tg-waiting-dots'>···</View><Text className='tg-modal-caption'>模拟等待中,不会自动假装设备连接成功。下一步核对神仙并确认绑定。</Text>{dialogActions('模拟连接成功', showBindingCard)}</>}
|
||||
{modal === 'invited' && <><Text className='tg-modal-title'>请神成功</Text><Text className='tg-modal-copy'>请敬神!</Text><Text className='tg-modal-caption'>模拟恭请成功,未扣款、未连接真实支架。</Text><View className='tg-modal-actions'><Button className='tg-button primary' onClick={close}>进入敬神</Button></View></>}
|
||||
{modal === 'invited' && <><Text className='tg-modal-title'>请神成功</Text><Text className='tg-modal-copy'>请敬神!</Text><Text className='tg-modal-caption'>{demo ? '模拟恭请成功,未连接真实支架。' : '供奉已恢复,请神记录已保存。'}</Text><View className='tg-modal-actions'><Button className='tg-button primary' onClick={close}>进入敬神</Button></View></>}
|
||||
{modal === 'result' && <><Text className='tg-modal-title'>{demo ? 'Mock 演示结果' : '操作结果'}</Text><Text className='tg-modal-copy'>{result}</Text><View className='tg-modal-actions'><Button className='tg-button primary' onClick={close}>知道了</Button></View></>}
|
||||
{error && <Text className='tg-error'>{error}</Text>}
|
||||
</TiangongModal>}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { getToken, request, subscribeProfile } from './request'
|
||||
import { getToken, subscribeProfile } from './request'
|
||||
import { tiangongApi } from './tiangongApi'
|
||||
import { isFrontendPreview } from './frontendPreview'
|
||||
import { isAvatarDemo } from './avatarDemo'
|
||||
import { createTiangongStore, RemoteDeity } from '../utils/tiangongStore'
|
||||
import { createTiangongStore } from '../utils/tiangongStore'
|
||||
import { createTiangongMock, DeityId } from '../utils/tiangongMock'
|
||||
import wealth from '../assets/tiangong/proto-wealth.jpg'
|
||||
import fortune from '../assets/tiangong/proto-fortune.jpg'
|
||||
@@ -25,9 +26,7 @@ export const isTiangongDemo = () => isFrontendPreview() || isAvatarDemo()
|
||||
const live = createTiangongStore({
|
||||
key: getToken,
|
||||
resolve: name => deityCatalog.find(item => item.name === name || item.ritualName === name)?.id,
|
||||
load: () => request<RemoteDeity[]>('/api/tiangong/items'),
|
||||
offer: (holdingId, type, content) => request<{ id: number }>('/api/user/virtual-items/offerings', { method: 'POST', data: { userVirtualItemId: holdingId, offeringType: type, content } }),
|
||||
dedicate: (holdingId, name, content) => request<{ id: number }>('/api/user/virtual-items/dedications', { method: 'POST', data: { userVirtualItemId: holdingId, name, content } })
|
||||
...tiangongApi
|
||||
})
|
||||
export const tiangongSession = {
|
||||
snapshot: () => isTiangongDemo() ? tiangongMock.snapshot() : live.snapshot(),
|
||||
@@ -44,29 +43,9 @@ export const tiangongSession = {
|
||||
return { startsRound }
|
||||
},
|
||||
refreshIncense: (id: DeityId) => isTiangongDemo() ? tiangongMock.refreshIncense(id) : live.refreshIncense(),
|
||||
invite: async (id: DeityId) => { if (isTiangongDemo()) tiangongMock.invite(id); else await live.invite(id) },
|
||||
sendOff: async (id: DeityId) => { if (isTiangongDemo()) tiangongMock.sendOff(id); else await live.sendOff(id) },
|
||||
wish: async (id: DeityId, text: string) => { if (isTiangongDemo()) tiangongMock.wish(id, text); else await live.wish(id, text) },
|
||||
dedicate: async (id: DeityId, name: string, text: string) => { if (isTiangongDemo()) tiangongMock.dedicate(id, name, text); else await live.dedicate(id, name, text) }
|
||||
}
|
||||
export const listMockDeities = () => { const snapshot = tiangongMock.snapshot(); return deityCatalog.map(item => ({ ...item, ...snapshot.deities[item.id] })) }
|
||||
|
||||
export type DeityBindingInfo = {
|
||||
bindingId: string
|
||||
deityId: DeityId
|
||||
uniqueId: string
|
||||
bound: boolean
|
||||
guardedDays: number
|
||||
remainingUnbinds: number
|
||||
}
|
||||
export type UnbindResult = { binding: DeityBindingInfo; hasAnyBoundDeity: boolean }
|
||||
|
||||
// 其余绑定/送神契约尚未接入,不会自动请求。实际供奉接口见 tiangongSession。
|
||||
export const tiangongRemoteContract = {
|
||||
list: () => request<unknown[]>('/api/tiangong/items'),
|
||||
detail: (id: DeityId) => request<unknown>(`/api/tiangong/items/${id}`),
|
||||
invite: (id: DeityId, bindingId: string) => request<unknown>(`/api/tiangong/items/${id}/invite`, { method: 'POST', data: { bindingId } }),
|
||||
sendOff: (id: DeityId) => request<unknown>(`/api/tiangong/items/${id}/send-off`, { method: 'POST' }),
|
||||
binding: (bindingId: string) => request<DeityBindingInfo>(`/api/tiangong/bindings/${encodeURIComponent(bindingId)}`),
|
||||
// 拟定接口:服务端需验证NFC凭证、一物一账号及年度额度。Mock不会调用。
|
||||
resolveBinding: (nfcCredential: string) => request<DeityBindingInfo>('/api/tiangong/bindings/resolve', { method: 'POST', data: { nfcCredential } }),
|
||||
unbind: (bindingId: string) => request<UnbindResult>(`/api/tiangong/bindings/${encodeURIComponent(bindingId)}/unbind`, { method: 'POST' })
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { request } from './request'
|
||||
import type { RemoteDeity } from '../utils/tiangongStore'
|
||||
|
||||
type Holding = { id: number; virtualItemId: number; status: number }
|
||||
/** Common offering receipt; only the detail matching offeringType is populated. */
|
||||
export type OfferingReceipt = {
|
||||
id: number; offeringType: number
|
||||
incense?: { incenseTimes: number; incenseCount: number; incenseType?: number; remark?: string }
|
||||
prayer?: { prayerContent: string }
|
||||
dedication?: { dedicationTarget: string; dedicationContent?: string }
|
||||
}
|
||||
export const tiangongApi = {
|
||||
load: async (): Promise<RemoteDeity[]> => {
|
||||
const [items, holdings] = await Promise.all([
|
||||
request<RemoteDeity[]>('/api/tiangong/items'),
|
||||
request<Holding[]>('/api/user/virtual-items?offset=0&limit=100')
|
||||
])
|
||||
if (!Array.isArray(items) || !Array.isArray(holdings)) throw new Error('神仙列表响应格式不正确')
|
||||
return items.map(item => ({ ...item, holdingStatus: holdings.find(holding => holding.id === item.holdingId && holding.virtualItemId === item.id)?.status }))
|
||||
},
|
||||
offer: (holdingId: number, type: 1 | 2, prayerContent?: string) => request<OfferingReceipt>('/api/user/virtual-items/offerings', {
|
||||
method: 'POST', data: { userVirtualItemId: holdingId, offeringType: type, ...(type === 1 ? { incenseCount: 3 } : { prayerContent }) }
|
||||
}),
|
||||
dedicate: (holdingId: number, dedicationTarget: string, dedicationContent: string) => request<OfferingReceipt>('/api/user/virtual-items/dedications', {
|
||||
method: 'POST', data: { userVirtualItemId: holdingId, dedicationTarget, ...(dedicationContent ? { dedicationContent } : {}) }
|
||||
}),
|
||||
lifecycle: (holdingId: number, actionType: 1 | 2) => request<{ id: number }>('/api/user/virtual-items/lifecycle', {
|
||||
method: 'POST', data: { userVirtualItemId: holdingId, actionType }
|
||||
})
|
||||
}
|
||||
@@ -2,13 +2,15 @@ import { INCENSE_DURATION_MS } from './incenseRitual'
|
||||
|
||||
export const DEITY_IDS = ['wealth', 'fortune', 'longevity', 'wenchang', 'matchmaker'] as const
|
||||
export type DeityId = typeof DEITY_IDS[number]
|
||||
export type WorshipState = { unlocked: boolean; days: number; incenseCount: number; lit: boolean; incenseUntil: number; remainingUnbinds: number; available?: boolean; holdingId?: number; iconUrl?: string; iconDetailUrl?: string; description?: string }
|
||||
export type SavedPrayer = { offeringId?: number; prayerContent: string }
|
||||
export type SavedDedication = { offeringId?: number; dedicationTarget: string; dedicationContent?: string | null }
|
||||
export type WorshipState = { unlocked: boolean; canInvite?: boolean; holdingStatus?: number; days: number; incenseCount: number; lit: boolean; incenseUntil: number; remainingUnbinds: number; available?: boolean; holdingId?: number; iconUrl?: string; iconDetailUrl?: string; description?: string; latestPrayer?: SavedPrayer | null; latestDedication?: SavedDedication | null }
|
||||
export type TiangongSnapshot = { deities: Record<DeityId, WorshipState>; ready?: boolean; loading?: boolean; error?: string }
|
||||
export const emptyTiangongSnapshot = (): TiangongSnapshot => ({ deities: Object.fromEntries(DEITY_IDS.map(id => [id, {
|
||||
unlocked: false, days: 0, incenseCount: 0, lit: false, incenseUntil: 0, remainingUnbinds: 0, available: false
|
||||
}])) as Record<DeityId, WorshipState>, ready: false, loading: false, error: '' })
|
||||
export const isDeityId = (id: unknown): id is DeityId => DEITY_IDS.includes(id as DeityId)
|
||||
export const hasBoundDeity = (snapshot: TiangongSnapshot) => DEITY_IDS.some(id => snapshot.deities[id].unlocked)
|
||||
export const hasBoundDeity = (snapshot: TiangongSnapshot) => DEITY_IDS.some(id => snapshot.deities[id].unlocked || snapshot.deities[id].canInvite === true)
|
||||
|
||||
// 仅当前运行会话;不读取真实账户,不保存祈愿或姓名。
|
||||
export function createTiangongMock(now = Date.now) {
|
||||
@@ -33,10 +35,12 @@ export function createTiangongMock(now = Date.now) {
|
||||
const owned = (id: DeityId) => { const deity = get(id); if (!deity.unlocked) throw new Error('您还未恭请此神'); return deity }
|
||||
const publish = () => { listeners.forEach(listener => listener()); return snapshot() }
|
||||
const unbind = (id: DeityId) => {
|
||||
const deity = owned(id)
|
||||
const deity = get(id)
|
||||
if (!deity.unlocked && !deity.canInvite) throw new Error('您还未恭请此神')
|
||||
if (deity.remainingUnbinds <= 0) throw new Error('该神仙今年的解绑次数已用尽')
|
||||
deity.remainingUnbinds--
|
||||
deity.unlocked = false
|
||||
deity.canInvite = false
|
||||
deity.lit = false
|
||||
deity.incenseUntil = 0
|
||||
return publish()
|
||||
@@ -44,11 +48,11 @@ export function createTiangongMock(now = Date.now) {
|
||||
return {
|
||||
snapshot, reset: () => { reset(); return publish() },
|
||||
subscribe: (listener: () => void) => { listeners.add(listener); return () => { listeners.delete(listener) } },
|
||||
invite: (id: DeityId) => { get(id).unlocked = true; return publish() },
|
||||
unbind, sendOff: unbind,
|
||||
invite: (id: DeityId) => { const deity = get(id); deity.unlocked = true; deity.canInvite = false; return publish() },
|
||||
unbind, sendOff: (id: DeityId) => { const deity = owned(id); deity.unlocked = false; deity.canInvite = true; deity.lit = false; deity.incenseUntil = 0; return publish() },
|
||||
incense: (id: DeityId) => { const deity = owned(id); deity.incenseCount += 1; deity.lit = true; deity.incenseUntil = now() + INCENSE_DURATION_MS; return publish() },
|
||||
refreshIncense: (id: DeityId) => { get(id); return publish() },
|
||||
wish: (id: DeityId, text: string) => { owned(id); if (!text.trim()) throw new Error('请先输入您的愿望'); return { mock: true as const } },
|
||||
dedicate: (id: DeityId, name: string, text: string) => { owned(id); if (!name.trim() || !text.trim()) throw new Error('请填写回向对象和祝愿'); return { mock: true as const } },
|
||||
dedicate: (id: DeityId, name: string, text: string) => { owned(id); if (!name.trim() || name.trim().length > 255 || text.trim().length > 500) throw new Error('请填写有效的回向对象和祝愿'); return { mock: true as const } },
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import { DeityId, emptyTiangongSnapshot, TiangongSnapshot } from './tiangongMock'
|
||||
import { DeityId, emptyTiangongSnapshot, SavedPrayer, SavedDedication, TiangongSnapshot } from './tiangongMock'
|
||||
import { INCENSE_DURATION_MS } from './incenseRitual'
|
||||
|
||||
export type RemoteDeity = { id: number; name: string; holdingId?: number; active: boolean; incenseCount: number; days: number; iconUrl?: string; iconDetailUrl?: string; description?: string }
|
||||
export type RemoteDeity = { id: number; name: string; holdingId?: number; holdingStatus?: number; active: boolean; incenseCount: number; days: number; iconUrl?: string; iconDetailUrl?: string; description?: string; latestPrayer?: SavedPrayer | null; latestDedication?: SavedDedication | null }
|
||||
type Dependencies = {
|
||||
key: () => string; load: () => Promise<RemoteDeity[]>; resolve: (name: string) => DeityId | undefined
|
||||
offer: (holdingId: number, type: 2 | 3, content?: string) => Promise<{ id: number }>
|
||||
offer: (holdingId: number, type: 1 | 2, content?: string) => Promise<{ id: number }>
|
||||
dedicate: (holdingId: number, name: string, content: string) => Promise<{ id: number }>
|
||||
lifecycle: (holdingId: number, actionType: 1 | 2) => Promise<{ id: number }>
|
||||
now?: () => number
|
||||
}
|
||||
|
||||
@@ -15,10 +16,11 @@ export function createTiangongStore(deps: Dependencies) {
|
||||
const now = deps.now || Date.now
|
||||
let key = '', generation = 0, writes = 0, activeWrites = 0, expires = 0
|
||||
let state = emptyTiangongSnapshot(), pending: Promise<void> | undefined
|
||||
const lifecycleWrites = new Set<DeityId>()
|
||||
const listeners = new Set<() => void>()
|
||||
const select = () => {
|
||||
const next = deps.key()
|
||||
if (next !== key) { key = next; generation++; state = emptyTiangongSnapshot(); pending = undefined; expires = 0; writes = 0; activeWrites = 0 }
|
||||
if (next !== key) { lifecycleWrites.clear(); key = next; generation++; state = emptyTiangongSnapshot(); pending = undefined; expires = 0; writes = 0; activeWrites = 0 }
|
||||
}
|
||||
const snapshot = (): TiangongSnapshot => {
|
||||
select()
|
||||
@@ -30,6 +32,7 @@ export function createTiangongStore(deps: Dependencies) {
|
||||
const owned = (id: DeityId) => {
|
||||
select()
|
||||
const item = state.deities[id]
|
||||
if (lifecycleWrites.has(id)) throw new Error('请神或送神正在提交,请稍后再试')
|
||||
if (!key || !item.unlocked || !item.holdingId) throw new Error('请先绑定对应的神仙')
|
||||
return item.holdingId
|
||||
}
|
||||
@@ -50,8 +53,10 @@ export function createTiangongStore(deps: Dependencies) {
|
||||
const old = state.deities[id]
|
||||
const active = item.active === true && Number.isSafeInteger(item.holdingId) && item.holdingId! > 0
|
||||
next.deities[id] = { ...next.deities[id], available: true, unlocked: active,
|
||||
holdingId: item.holdingId, days: Math.max(0, item.days || 0), incenseCount: Math.max(0, item.incenseCount || 0),
|
||||
holdingId: item.holdingId, holdingStatus: item.holdingStatus,
|
||||
canInvite: !active && item.holdingStatus === 0 && Number.isSafeInteger(item.holdingId) && item.holdingId! > 0, days: Math.max(0, item.days || 0), incenseCount: Math.max(0, item.incenseCount || 0),
|
||||
iconUrl: item.iconUrl, iconDetailUrl: item.iconDetailUrl, description: item.description,
|
||||
latestPrayer: item.latestPrayer || null, latestDedication: item.latestDedication || null,
|
||||
incenseUntil: active && old.holdingId === item.holdingId ? old.incenseUntil : 0,
|
||||
lit: active && old.holdingId === item.holdingId && old.incenseUntil > now() }
|
||||
})
|
||||
@@ -75,15 +80,31 @@ export function createTiangongStore(deps: Dependencies) {
|
||||
const result = await work()
|
||||
assertSession(version)
|
||||
if (!Number.isSafeInteger(result?.id) || result.id <= 0) throw new Error('服务端未返回有效记录,请刷新核对后再试')
|
||||
return result
|
||||
} finally { if (generation === version) activeWrites-- }
|
||||
}
|
||||
const changeLifecycle = async (id: DeityId, action: 1 | 2) => {
|
||||
select()
|
||||
const item = state.deities[id], version = generation
|
||||
if (activeWrites > 0) throw new Error('操作正在提交,请稍后再试')
|
||||
if (!key || !item.holdingId || (action === 1 ? !item.canInvite : !item.unlocked)) throw new Error('当前状态不可操作,请先绑定对应的神仙或刷新状态')
|
||||
lifecycleWrites.add(id)
|
||||
try {
|
||||
await checkedWrite(() => deps.lifecycle(item.holdingId!, action))
|
||||
item.unlocked = action === 1; item.canInvite = action === 2; item.holdingStatus = action === 1 ? 1 : 0
|
||||
item.lit = false; item.incenseUntil = 0
|
||||
publish()
|
||||
} finally { if (generation === version) lifecycleWrites.delete(id) }
|
||||
}
|
||||
return {
|
||||
snapshot, refresh,
|
||||
invite: (id: DeityId) => changeLifecycle(id, 1),
|
||||
sendOff: (id: DeityId) => changeLifecycle(id, 2),
|
||||
subscribe: (listener: () => void) => { listeners.add(listener); return () => { listeners.delete(listener) } },
|
||||
refreshIncense: () => publish(),
|
||||
async incense(id: DeityId) {
|
||||
const holdingId = owned(id), clickedAt = now()
|
||||
await checkedWrite(() => deps.offer(holdingId, 2))
|
||||
await checkedWrite(() => deps.offer(holdingId, 1))
|
||||
const item = state.deities[id], startsRound = item.incenseUntil <= now()
|
||||
if (!item.unlocked || item.holdingId !== holdingId) return { startsRound: false }
|
||||
item.incenseCount++
|
||||
@@ -94,12 +115,22 @@ export function createTiangongStore(deps: Dependencies) {
|
||||
async wish(id: DeityId, text: string) {
|
||||
const holdingId = owned(id)
|
||||
if (!text.trim() || text.length > 500) throw new Error('请输入1至500字的愿望')
|
||||
await checkedWrite(() => deps.offer(holdingId, 3, text.trim()))
|
||||
const result = await checkedWrite(() => deps.offer(holdingId, 2, text.trim()))
|
||||
const item = state.deities[id]
|
||||
if (!item.latestPrayer?.offeringId || result.id > item.latestPrayer.offeringId) {
|
||||
item.latestPrayer = { offeringId: result.id, prayerContent: text.trim() }
|
||||
publish()
|
||||
}
|
||||
},
|
||||
async dedicate(id: DeityId, name: string, text: string) {
|
||||
const holdingId = owned(id)
|
||||
if (!name.trim() || name.trim().length > 50 || !text.trim() || text.length > 500) throw new Error('请填写有效的回向对象和祝愿')
|
||||
await checkedWrite(() => deps.dedicate(holdingId, name.trim(), text.trim()))
|
||||
if (!name.trim() || name.trim().length > 255 || text.trim().length > 500) throw new Error('请填写1至255字的回向对象,祝愿最多500字(选填)')
|
||||
const result = await checkedWrite(() => deps.dedicate(holdingId, name.trim(), text.trim()))
|
||||
const item = state.deities[id]
|
||||
if (!item.latestDedication?.offeringId || result.id > item.latestDedication.offeringId) {
|
||||
item.latestDedication = { offeringId: result.id, dedicationTarget: name.trim(), dedicationContent: text.trim() }
|
||||
publish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user