feat: update agreements and refine miniapp layouts and integrations

This commit is contained in:
zhouzhibo committed 2026-10-21 20:33:50 +08:00
1 parent 38fc4187ea
commit 7871114c50
26 files changed
+2018 -120

No files matched your search

+10
View File
@@ -0,0 +1,10 @@
# 我的天宫:绑定卡片样式恢复
- 正式模式和显式Demo共用 `binding-gold-card` 金色卡片、挂饰、固定比例图片框、守护天数条和底部关闭按钮。不再为正式模式提前返回简化的紫色信息弹窗。
- 正式卡片神仙图片取已有 GET `/api/tiangong/items` 的 `iconUrl`;守护天数取 `days`;持有关系显示为“持有记录:holdingId”,不冒充NFC唯一ID。
- 正式模式不展示DEMO编号、Mock标记、示例解绑额度、模拟确认绑定或模拟解绑菜单。真实绑定/解绑仍未在此组件接入;本次不新增写接口、不改持有状态。
- 图片为空或加载失败时保留卡片和图片框,显示明确提示,不以本地原型图冒充后端图片。Demo继续使用原有素材和模拟分支。
验收:登录后进入“我的→我的天宫→点击已绑定神仙”,应看到金色卡片、对应图片、真实守护天数及关闭按钮,不出现演示编号或模拟解绑。375×812与320×568演示布局已检查,关闭按钮可用;真实模式的数据与权限分支由组件测试验证。
112项前端测试、TypeScript、H5/微信构建、13个微信原生WXSS检查通过。未自动提交、推送或部署后端。效果参考截图(演示数据):`/private/tmp/hera-restored-gold-card.png`。
+27
View File
@@ -0,0 +1,27 @@
# 天宫列表与详情素材字段
## 展示规则
- 正式模式列表图沿用后端 `iconUrl`(数据库 `icon_url`)。未绑定仍按现有 `opacity: .2` 淡化,不改灰度;已绑定正常显示。
- 详情主神像与祈愿/回向弹窗神像使用 `iconDetailUrl`(数据库 `icon_detail_url`),不再使用本地原型神像,也不使用列表缩略图顶替。
- 介绍使用 `description`,仅在未恭请神仙的请神页显示并保留换行;已绑定的敬神页不展示介绍,也不展示“暂无介绍”占位。
- 详情图缺失显示“详情图片暂未配置”;加载失败显示“详情图片加载失败”;空介绍显示“暂无介绍”。不补写神仙介绍。
- 显式 `preview=1`/`avatarDemo=1` 仍为独立 Mock,只使用原型素材,不用于验证真实后端字段。
## 接口
继续复用 JWT 接口 GET `/api/tiangong/items`,不新增请求,不调用尚未接入的独立详情接口。该接口同时返回 `iconUrl`、`iconDetailUrl`、`description`、`active` 及持有关系和统计。后端现有代码已查询并返回这些字段、解析存储地址,本次未修改后端或执行SQL。
## 开发者工具验收
1. 导入项目并重新编译,使用真实账号登录,不使用H5演示参数。
2. 天宫列表:已绑定显示原图,未绑定仍显示同一图片的20%透明度效果。
3. 查看 Network 中 GET `/api/tiangong/items` 返回的三个字段。进入对应神仙详情,主神像应对应 `iconDetailUrl`;未恭请的请神页介绍与 `description` 一致,不能显示另一份本地原型介绍。
4. 已绑定神仙进入敬神页,不应显示介绍或介绍占位;上香等按钮正常显示。打开祈愿/回向弹窗,弹窗神像也对应详情图,不能换回本地图片。
5. 服务端未配置图片/介绍时按上述提示展示;图片不可访问时显示加载失败。返回列表不改变未绑定图片效果。
## 验证与边界
108项前端测试、TypeScript、H5/微信构建、13个微信原生WXSS编译检查通过。测试覆盖请神页显示介绍、敬神页不显示介绍或占位、详情与弹窗、字段缺失及加载失败、更新及账号切换。微信产物2,022,607 bytes,低于2MiB。
本机浏览器真实模式停在登录入口,尚未通过真实账号验证远程图片和介绍;未绕过登录、未修改远程数据。后端已有的未提交改动、本机 project.config.json 均保留。本次未自动提交、推送或部署后端。
+28
View File
@@ -0,0 +1,28 @@
# 请神页面布局调整(2026-10-20)
本次仅调整 `src/pages/tiangong-detail/index.scss` 的请神布局,不修改后端、业务流程或接口。
- 祥云山水背景与神像区域改为随可用高度伸展,取消固定场景高度;底部介绍区结束后不再保留大片空白。
- 参考用户图片,使用通栏浅绿色名称栏、米黄色介绍区和居中的浅绿渐变「请神」按钮。
- 长屏由场景填满;小屏、长介绍自然滚动,保留底部安全区;神像保持等比例显示。
- 敬神页仍使用原场景、供桌和操作栏,不展示介绍。
## 验收
1. 开发者工具编译 `dist`,登录后进入天宫,选择未绑定神仙的请神页。
2. 检查名称栏水平铺满、介绍排版完整、请神按钮居中,底部没有额外的大块留白。
3. 切换小屏和长屏机型:小屏可向下滚动看到完整介绍和按钮,长屏背景撑满,不横向溢出。
4. 点击请神仍沿用原流程;返回已绑定神仙的敬神页,供桌与按钮保持原样且没有介绍。
## 边界与差异
- 使用已有祥云山水素材,不将参考截图整张铺入页面;名称采用金棕色以保持浅绿底上的可读性。
- 正式环境图片仍取 `iconDetailUrl`,介绍仍取 `description`,本次没有新增接口或文案。
- 本地 `?avatarDemo=1` 是独立 Mock 演示,不建立真实供奉关系;演示页附加提示文字属于 Mock 边界说明,正式页面没有这些说明。
- 演示素材中禄星介绍与名称不一致的问题仍明确提示待确认,本次未自行更改。
## 验证
类型检查、H5 和微信构建、原生 WXSS 编译检查通过;新增 `scripts/invitationLayout.test.cjs` 两项通过,防止恢复固定高度或影响敬神布局。浏览器通过显式 Demo 验证小屏、常规长屏与更长屏及原有弹窗,不代表正式后端绑定已联调。
最终全量测试为 118 项、116 通过、2 失败:执行期间出现本次范围外的个人信息页安全区改动,`avatarEntry.test.cjs` 的旧测试替身未提供 `getSystemInfoSync` 且依赖固定 Hook 下标,导致两项头像入口测试失败。本次保留这些页面改动,未修改头像逻辑或掩盖失败。
+24
View File
@@ -0,0 +1,24 @@
# 微信客服接入
## 已配置
- 企业ID:`wwf8bbe2cad2e9e84b`。
- 官方客服链接:`https://work.weixin.qq.com/kfid/kfc5a57c84cb0feb2fd`。
- 统一入口:`src/services/customerService.ts`。这两个参数为公开入口信息,不是企业Secret或访问令牌。
- “我的→联系我们”和首页/元素未绑定提示中的“联系我们”共用该入口。
小程序点击时直接调用 `Taro.openCustomerServiceChat({corpId, extInfo: {url}})`,不先等待网络请求,保留微信要求的用户点击上下文。不跳转项目里的模拟聊天页,不发送用户资料或自动发送聊天消息,不连接自建聊天后端。
H5仅提示“请在微信小程序中联系客服”;不支持的微信版本提示更新;接口失败提示重试;用户取消不报错。连续点击去重,完成或失败后可再次点击。不配置企业Secret,不开启API消息接管或消息回调。
## 管理员配置及真机验收
1. 在微信公众平台的小程序后台,确认客服设置已绑定上述企业ID;企业微信中确认该客服账号已启用并添加接待人员。
2. 微信开发者工具导入项目,重新编译当前 `dist`,再使用微信真机预览或体验版测试。H5提示正常不代表官方客服已可用。
3. “我的→联系我们”:预期进入对应微信客服会话。人工发送一条测试消息,企业微信接待人员应能收到并回复。
4. 未绑定神仙时,在首页或“我的元素”的绑定提示点击“联系我们”:应进入同一个官方客服,不弹“入口待配置”,不进入模拟聊天页。
5. 如提示打开失败,先检查企业ID关联、客服链接及账号启用状态,再检查微信版本和主体资质是否满足后台要求。不要把Secret或密码发到聊天。
## 边界
本次只接入官方客服入口,无新增HTTP业务接口,不修改后端或企业微信管理后台,不购买服务、不自动向客服发送消息。真实客服可用性需管理员配置和真机验收,本地测试不替代该验收。前一条被中断的8张神仙图片压缩上传及数据库更新,本次未执行。
+25 -3
View File
@@ -8,7 +8,29 @@
"es6": true,
"enhance": true,
"postcss": true,
"minified": true
"minified": true,
"compileWorklet": false,
"uglifyFileName": false,
"uploadWithSourceMap": true,
"packNpmManually": false,
"packNpmRelationList": [],
"minifyWXSS": true,
"minifyWXML": true,
"localPlugins": false,
"disableUseStrict": false,
"useCompilerPlugins": false,
"condition": false,
"babelSetting": {
"ignore": [],
"disablePlugins": [],
"outputPath": ""
}
},
"compileType": "miniprogram"
}
"compileType": "miniprogram",
"simulatorPluginLibVersion": {},
"packOptions": {
"ignore": [],
"include": []
},
"editorSetting": {}
}
+18 -4
View File
@@ -10,6 +10,18 @@ function render(env, sheet = false) {
const exports = {}, updates = [], media = []
let index = 0
const source = fs.readFileSync(path.join(__dirname, '../src/pages/profile-edit/index.tsx'), 'utf8')
const parsed = ts.createSourceFile('profile-edit.tsx', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
const stateNames = []
const collectStateNames = node => {
if (ts.isVariableDeclaration(node) && ts.isArrayBindingPattern(node.name)
&& node.initializer && ts.isCallExpression(node.initializer) && node.initializer.expression.getText(parsed) === 'useState') {
stateNames.push(node.name.elements[0].name.getText(parsed))
}
ts.forEachChild(node, collectStateNames)
}
collectStateNames(parsed)
const avatarSheetIndex = stateNames.indexOf('avatarSheetOpen')
assert.notEqual(avatarSheetIndex, -1)
vm.runInNewContext(ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2019, jsx: ts.JsxEmit.ReactJSX } }).outputText, {
exports, process: { env: { TARO_ENV: env } }, require: id => {
@@ -19,10 +31,11 @@ function render(env, sheet = false) {
let value = typeof initial === 'function' ? initial() : initial
if (value === null || (value && typeof value === 'object' && 'nickname' in value)) value = profile
if (initial === true) value = false
if (current === 5) value = sheet // avatarSheetOpen
if (current === avatarSheetIndex) value = sheet
return [value, next => updates.push([current, next])]
} }
if (id === '@tarojs/taro') return { useRouter: () => ({ params: {} }), default: {
getWindowInfo: () => ({ statusBarHeight: 44, safeArea: { top: 44 } }),
chooseMedia: async options => { media.push(options); return { tempFiles: [{ tempFilePath: 'temp:album', size: 10 }] } }, showToast: () => {}
} }
if (id === '@tarojs/components') return { View: 'View', Text: 'Text', Button: 'Button', Input: 'Input', Picker: 'Picker' }
@@ -40,7 +53,8 @@ function render(env, sheet = false) {
if (Array.isArray(node)) return node.flatMap(collect)
return [node, ...collect(node.props?.children)]
}
return { nodes: collect(exports.default()), updates, media }
return { nodes: collect(exports.default()), updates, media,
avatarSheetUpdates: () => updates.filter(([index]) => index === avatarSheetIndex).map(([, value]) => value) }
}
test('微信端头像本身直接绑定官方chooseAvatar,不再弹自定义菜单', () => {
@@ -53,7 +67,7 @@ test('微信端头像本身直接绑定官方chooseAvatar,不再弹自定义
button.props.onChooseAvatar({ detail: { avatarUrl: 'temp:wechat-avatar' } })
assert.equal(h.media.length, 0)
assert.ok(h.updates.some(([, value]) => value === 'temp:wechat-avatar'))
assert.ok(h.updates.some(([index, value]) => index === 5 && value === false))
assert.ok(h.avatarSheetUpdates().includes(false))
})
test('H5保留相册拍照菜单,不伪造微信官方头像选择能力', async () => {
@@ -62,7 +76,7 @@ test('H5保留相册拍照菜单,不伪造微信官方头像选择能力', asy
assert.equal(button.props.openType, undefined)
assert.equal(button.props.onChooseAvatar, undefined)
button.props.onClick()
assert.ok(h.updates.some(([index, value]) => index === 5 && value === true))
assert.ok(h.avatarSheetUpdates().includes(true))
const opened = render('h5', true)
const album = opened.nodes.find(node => node.props?.children === '从相册选择')
await album.props.onClick()
+56
View File
@@ -0,0 +1,56 @@
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 harness(env = 'weapp', supported = true) {
const calls = [], notices = []
let outcome = () => Promise.resolve({}), resolve
const exports = {}
vm.runInNewContext(ts.transpileModule(fs.readFileSync(path.join(root, 'src/services/customerService.ts'), 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2019 }
}).outputText, { exports, process: { env: { TARO_ENV: env } }, require: () => ({ default: {
canIUse: api => { assert.equal(api, 'openCustomerServiceChat'); return supported },
openCustomerServiceChat: options => { calls.push(JSON.parse(JSON.stringify(options))); return outcome() },
showToast: options => notices.push(options.title)
} }) })
return { api: exports, calls, notices,
fail: error => { outcome = () => Promise.reject(error) },
defer: () => { outcome = () => new Promise(ok => { resolve = ok }) },
complete: () => resolve({}) }
}
test('客服在点击的同一调用栈打开,企业ID与链接准确,不携带用户资料或秘密', async () => {
const h = harness()
const work = h.api.openWechatCustomerService()
assert.equal(h.calls.length, 1)
assert.deepEqual(h.calls[0], { corpId: 'wwf8bbe2cad2e9e84b', extInfo: { url: 'https://work.weixin.qq.com/kfid/kfc5a57c84cb0feb2fd' } })
await work
assert.equal(h.notices.length, 0)
})
test('H5与不支持的微信版本仅提示,不模拟客服接通', async () => {
const h5 = harness('h5'); await h5.api.openWechatCustomerService()
assert.equal(h5.calls.length, 0); assert.match(h5.notices[0], /微信小程序/)
const old = harness('weapp', false); await old.api.openWechatCustomerService()
assert.equal(old.calls.length, 0); assert.match(old.notices[0], /更新微信/)
})
test('连续点击不重复打开,完成或失败后可重试,取消无错误提示', async () => {
const h = harness(); h.defer()
const first = h.api.openWechatCustomerService()
await h.api.openWechatCustomerService(); assert.equal(h.calls.length, 1)
h.complete(); await first
h.fail({ errMsg: 'openCustomerServiceChat:fail not bound' })
await h.api.openWechatCustomerService(); assert.equal(h.calls.length, 2)
assert.match(h.notices[0], /客服打开失败/)
h.fail({ errMsg: 'openCustomerServiceChat:fail cancel' })
await h.api.openWechatCustomerService(); assert.equal(h.calls.length, 3)
assert.equal(h.notices.length, 1)
})
test('我的及绑定提示共用同一客服入口,不再显示待配置或跳模拟聊天页面', () => {
for (const file of ['src/pages/my/index.tsx', 'src/components/DeityBinding.tsx']) {
const source = fs.readFileSync(path.join(root, file), 'utf8')
assert.match(source, /onClick=\{openWechatCustomerService\}/)
assert.doesNotMatch(source, /企业微信入口待配置|openEnterpriseWechat|pages\/contact/)
}
})
+77
View File
@@ -0,0 +1,77 @@
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({ demo = false, bound = true, mode = 'view', iconUrl = 'https://example.invalid/backend-icon.png' } = {}) {
const states = [], calls = []; let index = 0, closed = 0
const exports = {}
const deity = { id: 'wealth', name: '财神', icon: 'local-prototype.png' }
vm.runInNewContext(ts.transpileModule(fs.readFileSync(path.join(__dirname, '../src/components/DeityBinding.tsx'), '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 === 'react') return { useRef: value => ({ current: value }), useState: initial => {
const current = index++; if (!(current in states)) states[current] = initial
return [states[current], value => { states[current] = value }]
} }
if (id === '@tarojs/components') return { View: 'View', Image: 'Image', Text: 'Text', Button: 'Button' }
if (id === '@tarojs/taro') return { default: { showToast: () => {} } }
if (id.endsWith('/tiangong')) return { getDeity: () => deity, isTiangongDemo: () => demo,
tiangongMock: { invite: () => calls.push('invite'), unbind: () => calls.push('unbind') } }
if (id.endsWith('/request')) return { resolveAssetUrl: value => value || '' }
if (id.endsWith('/useTiangongSnapshot')) return { useTiangongSnapshot: () => ({ deities: {
wealth: { unlocked: bound, days: 23, holdingId: 726, iconUrl, remainingUnbinds: 2 }
} }) }
return {}
} })
return { calls, closed: () => closed, render: () => { index = 0; return exports.DeityCardDialog({ deityId: 'wealth', mode, onClose: () => { closed++ } }) } }
}
function nodes(tree) {
if (!tree || typeof tree !== 'object') return []
const children = tree.props?.children
return [tree, ...(Array.isArray(children) ? children : [children]).flatMap(nodes)]
}
function text(tree) {
const walk = node => typeof node === 'string' || typeof node === 'number' ? String(node) : node && typeof node === 'object' ? (Array.isArray(node.props?.children) ? node.props.children : [node.props?.children]).map(walk).join('') : ''
return walk(tree)
}
const find = (tree, className) => nodes(tree).find(node => node.props.className === className)
test('正式与Demo共用金色挂饰卡片、固定图片框与关闭入口', () => {
for (const demo of [false, true]) {
const h = harness({ demo }), tree = h.render()
for (const name of ['binding-dialog-stack', 'binding-gold-card', 'binding-card-hook', 'binding-art-frame', 'binding-guarded']) assert.ok(find(tree, name))
assert.equal(find(tree, 'binding-card-art').props.mode, 'aspectFit')
assert.equal(find(tree, 'binding-card-art').props.src, demo ? 'local-prototype.png' : 'https://example.invalid/backend-icon.png')
assert.equal(find(tree, 'binding-days').props.children, 23)
find(tree, 'binding-card-close').props.onClick(); assert.equal(h.closed(), 1)
}
})
test('正式卡片只显示真实持有记录,无演示ID、模拟绑定解绑或示例额度', () => {
for (const mode of ['view', 'bind']) {
const h = harness({ mode }), tree = h.render()
assert.match(text(tree), /持有记录:726/)
assert.doesNotMatch(text(tree), /DEMO|Mock|模拟|解绑|确认绑定|唯一 ID/)
assert.equal(find(tree, 'binding-more'), undefined)
assert.equal(find(tree, 'binding-primary binding-card-confirm'), undefined)
assert.deepEqual(h.calls, [])
}
})
test('正式未持有不伪造守护;缺图和加载失败保留金色布局而不替换成本地神像', () => {
const missing = harness({ bound: false, iconUrl: '' }).render()
assert.ok(find(missing, 'binding-gold-card')); assert.equal(find(missing, 'binding-guarded'), undefined)
assert.match(text(missing), /图片暂未配置|请通过 NFC 绑定神仙/)
assert.equal(find(missing, 'binding-card-art'), undefined)
const h = harness(); find(h.render(), 'binding-card-art').props.onError()
assert.match(text(h.render()), /图片加载失败/)
assert.equal(find(h.render(), 'binding-card-art'), undefined)
})
test('Demo保留原有模拟解绑分支,正式模式不会复用它', () => {
const h = harness({ demo: true })
find(h.render(), 'binding-more').props.onClick()
find(h.render(), 'binding-unbind-menu').props.onClick()
const confirm = nodes(h.render()).find(node => node.props.className?.startsWith('binding-primary ') && text(node) === '确定解绑')
assert.ok(confirm); confirm.props.onClick()
assert.deepEqual(h.calls, ['unbind']); assert.equal(h.closed(), 1)
})
+36
View File
@@ -0,0 +1,36 @@
const test = require('node:test')
const assert = require('node:assert/strict')
const path = require('node:path')
const sass = require(require.resolve('sass', { paths: [require.resolve('@tarojs/webpack5-runner')] }))
const postcss = require(require.resolve('postcss', { paths: [require.resolve('@tarojs/webpack5-runner')] }))
const css = postcss.parse(sass.renderSync({ file: path.resolve(__dirname, '../src/pages/tiangong-detail/index.scss') }).css.toString())
function declarations(selector) {
const result = {}
css.walkRules(selector, rule => rule.walkDecls(decl => { result[decl.prop] = decl.value }))
return result
}
test('请神长屏由场景撑满,短屏允许内容自然滚动', () => {
const page = declarations('.tiangong-detail-page.invitation')
assert.equal(page.height, 'auto')
assert.equal(page['min-height'], '100dvh')
assert.equal(page.overflow, 'visible')
for (const selector of ['.invitation .tg-sacred-scene', '.invitation .tg-statue-space']) {
const scene = declarations(selector)
assert.equal(scene.flex, '1 0 auto')
assert.equal(scene['min-height'], '118vw')
assert.equal(scene.height, undefined)
}
assert.equal(declarations('.invitation .tg-statue')['max-height'], 'none')
})
test('名称栏、介绍和请神按钮组成完整底栏,不改变敬神场景约束', () => {
assert.equal(declarations('.tg-invite-name')['text-align'], 'center')
assert.equal(declarations('.tg-introduction')['overflow-wrap'], 'break-word')
assert.match(declarations('.tg-invite-description')['padding-bottom'], /safe-area-inset-bottom/)
assert.equal(declarations('.tg-invite-description .tg-invite-main').margin, '18px auto 4px')
assert.equal(declarations('.tiangong-detail-page').height, '100dvh')
assert.equal(declarations('.tiangong-detail-page').overflow, 'hidden')
assert.equal(declarations('.tg-statue')['max-height'], '100vw')
})
+4 -2
View File
@@ -16,11 +16,13 @@ function page(file, { guest = false, bound = false, element = false, routes = []
const exports = {}
vm.runInNewContext(ts.transpileModule(read(file), { compilerOptions: {
module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2019, jsx: ts.JsxEmit.ReactJSX
} }).outputText, { exports, require: id => {
} }).outputText, { exports, process: { env: { TARO_ENV: 'weapp' } }, require: id => {
if (id === 'react/jsx-runtime') return require(id)
if (id === 'react') return { useEffect: () => {}, useMemo: fn => fn(), useRef: value => ({ current: value }),
useState: value => [element && value === 'home' ? 'element' : typeof value === 'function' ? value() : value, () => {}] }
if (id === '@tarojs/taro') return { useDidShow: () => {} }
if (id === '@tarojs/taro') return { useDidShow: () => {}, default: {
getWindowInfo: () => ({ statusBarHeight: 44, safeArea: { top: 44 } })
} }
if (id === '@tarojs/components') return { View: 'View', Text: 'Text', Button: 'Button', Image: 'Image' }
if (id.endsWith('/LoginGate')) return { LoginGate: 'shared-login-gate' }
if (id.endsWith('/tabNavigation')) return { switchMainTab: (target, active) => routes.push([target, active]) }
+87
View File
@@ -0,0 +1,87 @@
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 page(file, { bound = false, demo = false, modal = '', detail = 'https://example.invalid/detail.png', description = '后端返回的介绍\n保留换行' } = {}) {
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 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 === '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
return [states[current], value => { states[current] = value }]
} }
if (id === '@tarojs/taro') return { useRouter: () => ({ params: { id: 'wealth' } }), default: {} }
if (id === '@tarojs/components') return { View: 'View', Text: 'Text', Image: 'Image', Button: 'Button', Input: 'Input', Textarea: 'Textarea' }
if (id.endsWith('/useRequiredLogin')) return { useRequiredLogin: () => ({ status: 'allowed' }) }
if (id.endsWith('/loginAccess')) return { hasLoginAccess: () => true }
if (id.endsWith('/useTiangongSnapshot')) return { useTiangongSnapshot: () => ({ ready: true, deities: { wealth: state } }) }
if (id.endsWith('/useIncenseRitual')) return { useIncenseRitual: () => ({ soundEnabled: true, pending: 0 }) }
if (id.endsWith('/tiangong')) return { getDeity: () => deity, deityCatalog: [deity], isTiangongDemo: () => demo }
if (id.endsWith('/request')) return { resolveAssetUrl: value => value || '' }
return { TiangongHeader: 'Header', TiangongModal: 'Modal', TiangongMockNote: 'MockNote', AppTabBar: 'TabBar' }
} })
const Content = exports.default().type
return () => { index = 0; return Content() }
}
function nodes(tree) {
if (!tree || typeof tree !== 'object') return []
const children = tree.props?.children
return [tree, ...(Array.isArray(children) ? children : [children]).flatMap(nodes)]
}
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')
test('详情及弹窗均使用详情图;仅请神页展示description,敬神页不展示介绍', () => {
for (const bound of [true, false]) {
const tree = page('src/pages/tiangong-detail/index.tsx', { bound, modal: 'wish' })()
const statues = image(tree).filter(node => /tg-statue|tg-panel-statue/.test(node.props.className))
assert.equal(statues.length, 2)
for (const statue of statues) assert.equal(statue.props.src, 'https://example.invalid/detail.png')
if (bound) {
assert.doesNotMatch(text(tree), /后端返回的介绍|暂无介绍/)
assert.equal(nodes(tree).some(node => node.props.className === 'tg-introduction'), false)
} else assert.match(text(tree), /后端返回的介绍\n保留换行/)
assert.doesNotMatch(text(tree), /本地原型介绍|名称不一致|原型未提供/)
assert.ok(!statues.some(node => node.props.className.includes('catalog-art')))
}
})
test('敬神页在正式和Demo模式均不显示介绍,缺少介绍也不显示占位', () => {
for (const demo of [true, false]) {
for (const description of ['', '后端返回的介绍']) {
const tree = page('src/pages/tiangong-detail/index.tsx', { bound: true, demo, description })()
assert.equal(nodes(tree).some(node => node.props.className === 'tg-introduction'), false)
assert.doesNotMatch(text(tree), /后端返回的介绍|本地原型介绍|暂无介绍/)
assert.ok(nodes(tree).some(node => node.props.className === 'worship-actions'))
}
}
})
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)
const render = page('src/pages/tiangong-detail/index.tsx')
image(render())[0].props.onError()
const failed = render()
assert.match(text(failed), /详情图片加载失败/)
assert.equal(image(failed).length, 0)
})
test('列表保留iconUrl与20%透明度规则,详情字段不误用到列表;显式Demo保留原型', () => {
for (const bound of [true, false]) {
const tree = page('src/pages/fortune/index.tsx', { bound })()
assert.equal(image(tree)[0].props.src, 'https://example.invalid/list.png')
const card = nodes(tree).find(node => node.props.className?.startsWith('deity-card '))
assert.equal(card.props.className, `deity-card ${bound ? 'unlocked' : 'locked'}`)
}
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.match(text(demo), /本地原型介绍/)
})
+17
View File
@@ -34,6 +34,23 @@ test('真实状态不预置财神,按后端持有ID提交,后端确认后才
assert.equal(h.store.snapshot().deities.wealth.incenseCount, 8)
assert.equal(h.store.snapshot().deities.wealth.incenseUntil, 16000)
})
test('列表图、详情图、介绍独立保存,刷新及切换账号不串用旧素材', async () => {
const h = harness()
h.rows([{ id: 81, name: '财神', holdingId: 726, active: true, incenseCount: 7, days: 3,
iconUrl: '/file/list.png', iconDetailUrl: '/file/detail.png', description: '接口介绍\n第二行' }])
await h.store.refresh()
const first = h.store.snapshot().deities.wealth
assert.equal(first.iconUrl, '/file/list.png'); assert.equal(first.iconDetailUrl, '/file/detail.png')
assert.equal(first.description, '接口介绍\n第二行')
h.rows([{ id: 81, name: '财神', active: false, incenseCount: 0, days: 0,
iconUrl: '/file/new-list.png', iconDetailUrl: '/file/new-detail.png', description: '更新的介绍' }])
await h.store.refresh(true)
const second = h.store.snapshot().deities.wealth
assert.equal(second.unlocked, false); assert.equal(second.iconDetailUrl, '/file/new-detail.png')
assert.equal(second.description, '更新的介绍')
h.key('another-account')
assert.equal(h.store.snapshot().deities.wealth.iconDetailUrl, undefined)
})
test('失败无计数无自动重试;晚到查询不覆盖已确认上香', async () => {
const h = harness(); await h.store.refresh()
h.offer(async () => { throw new Error('网络失败') })
+1
View File
@@ -13,6 +13,7 @@
.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-image-note { color: #ab8d50; font-size: 24px; line-height: 1.6; }
.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; }
+16 -17
View File
@@ -4,6 +4,8 @@ import { Button, Image, Text, View } from '@tarojs/components'
import { deityCatalog, getDeity, isTiangongDemo, tiangongMock } from '../services/tiangong'
import { DeityId } from '../utils/tiangongMock'
import { useTiangongSnapshot } from '../hooks/useTiangongSnapshot'
import { resolveAssetUrl } from '../services/request'
import { openWechatCustomerService } from '../services/customerService'
import './DeityBinding.scss'
export function BindingGate({ onBind, lock = true }: { onBind: () => void; lock?: boolean }) {
@@ -13,7 +15,7 @@ export function BindingGate({ onBind, lock = true }: { onBind: () => void; lock?
<Text>须绑定神仙</Text><Text>开启你的守护之旅</Text>
<View className='binding-gate-actions'>
<Button className='binding-primary' onClick={onBind}>绑定神仙</Button>
<Button className='binding-outline' onClick={() => Taro.showToast({ title: '企业微信入口待配置', icon: 'none' })}>联系我们</Button>
<Button className='binding-outline' onClick={openWechatCustomerService}>联系我们</Button>
</View>
</View>
</View>
@@ -26,43 +28,40 @@ export function DeityCardDialog({ deityId, mode = 'view', onClose }: { deityId:
const [menu, setMenu] = useState(false)
const [confirmUnbind, setConfirmUnbind] = useState(false)
const [error, setError] = useState('')
const [failedImage, setFailedImage] = useState('')
const working = useRef(false)
if (!isTiangongDemo()) return <View className='binding-dialog-mask' catchMove><View className='binding-picker' role='dialog' aria-label='神仙信息'>
<Text className='binding-confirm-title'>{deity.name}</Text>
<Image className='binding-card-art' src={deity.icon} mode='aspectFit' />
<Text className='binding-picker-note'>{state.unlocked ? `已守护 ${state.days} 天 · 持有记录 ${state.holdingId}` : '请通过 NFC 绑定神仙。此处不模拟建立真实绑定关系。'}</Text>
<Button className='binding-outline binding-picker-cancel' onClick={onClose}>关闭</Button>
</View></View>
const demo = isTiangongDemo()
const art = demo ? deity.icon : resolveAssetUrl(state.iconUrl)
const canShowArt = !!art && failedImage !== art
const perform = (action: () => void) => {
if (working.current) return
if (!demo || working.current) return
working.current = true
try { action(); onClose() } catch (cause) { setError(cause instanceof Error ? cause.message : '模拟操作失败') }
finally { working.current = false }
}
return <View className='binding-dialog-mask' catchMove>
<View className='binding-dialog-stack'>
<View className='binding-gold-card' role='dialog' aria-label={mode === 'bind' ? '确认绑定神仙' : '已绑定神仙详情'}>
<View className='binding-gold-card' role='dialog' aria-label={demo && mode === 'bind' ? '确认绑定神仙' : state.unlocked ? '已绑定神仙详情' : '神仙信息'}>
<View className='binding-card-hook' /><View className='binding-card-corner top' /><View className='binding-card-corner bottom' />
<View className='binding-card-title-row'><Text className='binding-card-spark'>✦</Text><Text className='binding-card-name'>{deity.name}</Text><Text className='binding-card-spark'>✦</Text>
{mode === 'view' && <Button className='binding-more' aria-label='神仙更多操作' onClick={() => setMenu(!menu)}>•••</Button>}
{demo && mode === 'view' && <Button className='binding-more' aria-label='神仙更多操作' onClick={() => setMenu(!menu)}>•••</Button>}
</View>
{menu && <Button className='binding-unbind-menu' onClick={() => { setMenu(false); setConfirmUnbind(true) }}>解绑神仙</Button>}
<View className='binding-art-frame'><Image className='binding-card-art' src={deity.icon} mode='aspectFit' /></View>
{demo && menu && <Button className='binding-unbind-menu' onClick={() => { setMenu(false); setConfirmUnbind(true) }}>解绑神仙</Button>}
<View className='binding-art-frame'>{canShowArt ? <Image className='binding-card-art' src={art} mode='aspectFit' onError={() => setFailedImage(art)} /> : <Text className='binding-card-image-note'>{art ? '图片加载失败' : '图片暂未配置'}</Text>}</View>
<Text className='binding-card-subtitle'>守护神仙 · {deity.name}</Text>
<Text className='binding-card-code'>唯一 ID:DEMO-{deityId.toUpperCase()}-001</Text>
<Text className='binding-card-note'>Mock 原型演示 · 不连接真实 NFC</Text>
{mode === 'bind' ? <>
{demo ? <><Text className='binding-card-code'>唯一 ID:DEMO-{deityId.toUpperCase()}-001</Text><Text className='binding-card-note'>Mock 原型演示 · 不连接真实 NFC</Text></> : state.holdingId ? <Text className='binding-card-code'>持有记录:{state.holdingId}</Text> : null}
{demo && mode === 'bind' ? <>
<Text className='binding-card-warning'>每位神仙只能绑定一个微信号哦</Text>
<Button className='binding-primary binding-card-confirm' onClick={() => perform(() => {
tiangongMock.invite(deityId)
Taro.showToast({ title: '模拟绑定成功', icon: 'none' })
})}>确认绑定</Button>
</> : <View className='binding-guarded'><View className='binding-shield'>✓</View><Text>已守护 <Text className='binding-days'>{state.days}</Text> 天</Text></View>}
</> : 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>}
{error && <Text className='binding-error'>{error}</Text>}
</View>
<Button className='binding-card-close' aria-label='关闭神仙卡片' onClick={onClose}>×</Button>
</View>
{confirmUnbind && <View className='binding-confirm-mask'>
{demo && confirmUnbind && <View className='binding-confirm-mask'>
<View className='binding-confirm-panel' role='dialog' aria-label='解绑神仙确认'>
<Text className='binding-confirm-title'><Text className='binding-alert'>ⓘ</Text> 确定要解绑吗?</Text>
<View className='binding-confirm-copy'>
File diff suppressed because it is too large. Load diff
+37 -5
View File
@@ -1,9 +1,10 @@
.agreement-page {
--agreement-safe-top: env(safe-area-inset-top, 0px);
width: 100%;
max-width: 750px;
min-height: 100vh;
margin: 0 auto;
padding: calc(132px + env(safe-area-inset-top)) 32px calc(36px + env(safe-area-inset-bottom));
padding: calc(132px + var(--agreement-safe-top)) 32px calc(36px + env(safe-area-inset-bottom));
box-sizing: border-box;
color: #28243a;
background:
@@ -17,7 +18,7 @@
right: auto;
width: 100%;
max-width: 750px;
height: calc(106px + env(safe-area-inset-top));
height: calc(106px + var(--agreement-safe-top));
padding-bottom: 20px;
transform: translateX(-50%);
}
@@ -86,7 +87,7 @@
}
.agreement-article {
height: calc(100vh - 252px - env(safe-area-inset-top) - env(safe-area-inset-bottom));
height: calc(100vh - 252px - var(--agreement-safe-top) - env(safe-area-inset-bottom));
margin-top: 28px;
border: 2px solid rgba(255, 255, 255, 0.72);
border-radius: 28px;
@@ -126,6 +127,37 @@
margin-bottom: 0;
}
.article-section-title {
display: block;
margin: 38px 0 22px;
color: #28213f;
font-size: 32px;
font-weight: 700;
line-height: 1.6;
}
.article-emphasis {
font-weight: 700;
}
.article-list {
margin-bottom: 26px;
border-top: 1px solid #ded9e8;
}
.article-list-item {
padding: 20px 0;
border-bottom: 1px solid #ded9e8;
}
.article-list-field {
display: block;
color: #4c465c;
font-size: 28px;
line-height: 1.9;
word-break: break-all;
}
@keyframes article-enter {
from {
opacity: 0;
@@ -140,11 +172,11 @@
@media screen and (max-height: 700px) {
.agreement-page {
padding-top: calc(112px + env(safe-area-inset-top));
padding-top: calc(112px + var(--agreement-safe-top));
}
.agreement-article {
height: calc(100vh - 222px - env(safe-area-inset-top) - env(safe-area-inset-bottom));
height: calc(100vh - 222px - var(--agreement-safe-top) - env(safe-area-inset-bottom));
margin-top: 22px;
}
+35 -28
View File
@@ -1,35 +1,25 @@
import { useEffect, useState } from 'react'
import { useState } from 'react'
import type { CSSProperties } from 'react'
import Taro from '@tarojs/taro'
import { Button, ScrollView, Text, View } from '@tarojs/components'
import { NavBar } from '../../components/NavBar'
import { api } from '../../services/request'
import { agreements } from '../../content/agreements'
import type { AgreementType } from '../../content/agreements'
import './index.scss'
type AgreementType = 'user' | 'privacy'
const sampleUser = '为使用小程序软件服务(以下简称“本服务”),您应当阅读并遵守《小程序软件注册协议》以及专项规则等。请您务必审慎阅读、充分理解各条款内容,特别是免除或限制责任的相应条款。'
const samplePrivacy = '欢迎使用那吉。本隐私政策旨在向您说明我们收集、使用、存储、披露个人信息的方式、范围及您享有的相关权利,确保您在充分知情的前提下自愿使用本应用。'
export default function AgreementPage() {
const [active, setActive] = useState<AgreementType>('user')
const [content, setContent] = useState<Record<AgreementType, string>>({
user: sampleUser,
privacy: samplePrivacy
const [safeAreaStyle] = useState<CSSProperties | undefined>(() => {
if (process.env.TARO_ENV !== 'weapp') return undefined
const info = typeof Taro.getWindowInfo === 'function' ? Taro.getWindowInfo() : Taro.getSystemInfoSync()
// WeChat reports physical pixels; keep this inset out of the rpx conversion.
const top = Math.max(info.statusBarHeight || 0, info.safeArea?.top || 0)
return { '--agreement-safe-top': `${top}px` } as CSSProperties
})
useEffect(() => {
let cancelled = false
api.agreement(active)
.then((res) => {
if (!cancelled) setContent((current) => ({ ...current, [active]: res.body }))
})
.catch(() => undefined)
return () => {
cancelled = true
}
}, [active])
const document = agreements[active]
return (
<View className='agreement-page'>
<View className='agreement-page' style={safeAreaStyle}>
<NavBar back title='协议' />
<View className={`agreement-tabs ${active === 'privacy' ? 'privacy-active' : ''}`}>
<View className='tab-indicator' />
@@ -38,11 +28,28 @@ export default function AgreementPage() {
</View>
<ScrollView key={active} className='agreement-article' scrollY enhanced showScrollbar={false}>
<View className='article-inner'>
<Text className='article-title'>{active === 'user' ? '用户协议' : '隐私政策'}</Text>
<Text className='paragraph'>{content[active]}</Text>
<Text className='paragraph'>一、协议的范围{'\n'}1.1 本协议是您与那吉之间关于使用那吉软件服务所订立的协议。所有规则均为本协议不可分割的一部分,与协议正文具有同等法律效力。</Text>
<Text className='paragraph'>二、适用范围{'\n'}本政策适用于您通过本应用进行的所有操作及我们因此收集的个人信息,涵盖注册、登录、使用功能、参与互动等场景。</Text>
<Text className='paragraph'>三、个人信息的收集与使用{'\n'}我们遵循合法、正当、必要原则,仅收集为实现服务功能所必需的信息。</Text>
<View className='article-title'><Text selectable>{document.title}</Text></View>
{document.blocks.map((block, index) => block.type === 'table' ? (
<View className='article-list' key={index}>
{block.rows.map((row, rowIndex) => (
<View className='article-list-item' key={rowIndex}>
{row.map((cell, cellIndex) => (
<View className='article-list-field' key={cellIndex}>
<Text selectable><Text className='article-emphasis'>{block.headers[cellIndex]}:</Text>{cell}</Text>
</View>
))}
</View>
))}
</View>
) : (
<View className={block.type === 'heading' ? 'article-section-title' : 'paragraph'} key={index}>
<Text selectable>
{block.runs.map((run, runIndex) => (
<Text className={run.bold ? 'article-emphasis' : ''} key={runIndex}>{run.text}</Text>
))}
</Text>
</View>
))}
</View>
</ScrollView>
</View>
+11 -16
View File
@@ -1,4 +1,5 @@
.my-page {
--my-safe-top: env(safe-area-inset-top, 0px);
min-height: 100vh;
box-sizing: border-box;
padding: calc(190px + env(safe-area-inset-top)) 30px calc(178px + env(safe-area-inset-bottom));
@@ -68,10 +69,12 @@
.name-row {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.profile-name {
min-width: 0;
flex: 0 1 auto;
max-width: 280px;
color: #262038;
font-size: 40px;
@@ -83,6 +86,8 @@
}
.gender-chip {
flex: none;
margin-left: 20px;
width: 40px;
height: 40px;
border-radius: 22px;
@@ -477,7 +482,7 @@
width: 100%;
max-width: 650px;
min-height: 0;
height: calc(100vh - 150px - env(safe-area-inset-top) - env(safe-area-inset-bottom));
height: auto;
border-radius: 34px;
background: linear-gradient(180deg, #fbf7ff 0%, #f7f2ff 100%);
box-shadow: 0 28px 0 -14px rgba(193, 127, 255, 0.52), 26px 30px 0 -18px rgba(114, 160, 255, 0.46);
@@ -487,11 +492,11 @@
padding: 80px 32px 28px;
box-sizing: border-box;
position: relative;
max-height: calc(100vh - 150px - env(safe-area-inset-top) - env(safe-area-inset-bottom));
max-height: calc(100vh - 150px - var(--my-safe-top) - env(safe-area-inset-bottom));
overflow: hidden;
}
.zodiac-scroll { width: 100%; height: 0; min-height: 0; flex: 1; margin-top: 18px; }
.zodiac-scroll { width: 100%; height: auto; min-height: 0; max-height: calc(100vh - 440px - var(--my-safe-top) - env(safe-area-inset-bottom)); flex: 0 1 auto; margin-top: 18px; }
.zodiac-body { display: flex; flex-direction: column; align-items: center; padding: 0 10px 8px; box-sizing: border-box; }
.zodiac-body > .zodiac-large,
.zodiac-body > .zodiac-animal-name,
@@ -500,8 +505,6 @@
.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; }
@@ -516,13 +519,6 @@
.guide-label { flex: none; font-weight: 700; }
.guide-label.supportive { color: #56a476; }
.guide-label.cautious { color: #c58a62; }
.zodiac-rules-toggle { width: 100%; margin: 22px 0 0; color: #9574bb; font-size: 24px; line-height: 44px; }
.zodiac-rules { width: 100%; padding: 22px; box-sizing: border-box; border-radius: 20px; background: rgba(237,228,249,.65); }
.zodiac-rule-copy { display: block; color: #93849f; font-size: 22px; line-height: 1.8; }
.zodiac-rule-copy + .zodiac-rule-copy { margin-top: 12px; }
.zodiac-day { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 14px 0; border-bottom: 1px solid rgba(199,182,217,.4); font-size: 23px; line-height: 1.5; color: #928599; }
.zodiac-day.supportive { color: #53916d; }
.zodiac-day.cautious { color: #bb815f; }
.zodiac-week-note { margin-top: 20px; color: #a297ad; font-size: 21px; line-height: 1.7; text-align: center; }
.zodiac-modal .zodiac-confirm { flex: none; margin-top: 24px; }
@@ -607,14 +603,14 @@
width: 100%;
max-width: 750px;
margin: 0 auto;
padding: calc(130px + env(safe-area-inset-top)) 30px calc(220px + env(safe-area-inset-bottom));
padding: calc(130px + var(--my-safe-top)) 30px calc(220px + env(safe-area-inset-bottom));
background: linear-gradient(180deg, #b4a1fc 0%, #e2d4fc 28%, #eee8ff 51%, #edf5ff 100%);
}
.element-back {
position: fixed;
left: 30px;
top: calc(48px + env(safe-area-inset-top));
top: calc(48px + var(--my-safe-top));
z-index: 4;
width: 58px;
height: 58px;
@@ -723,7 +719,6 @@
}
.pillars-summary { font-size: 23px; color: #7b708e; }
.strength-factors { display: flex; flex-direction: column; gap: 14px; margin-top: 24px; color: #71677f; font-size: 25px; line-height: 1.7; }
.element-method-note { display: block; margin: 28px 10px 0; color: #81778e; font-size: 22px; line-height: 1.7; }
.element-empty { display: flex; flex-direction: column; gap: 30px; padding: 100px 30px; color: #65528e; font-size: 28px; line-height: 1.8; text-align: center; }
.element-empty button { padding: 22px; border-radius: 40px; color: #fff; background: #9279ed; font-size: 28px; }
+14 -25
View File
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from 'react'
import type { CSSProperties } from 'react'
import Taro, { useDidShow } from '@tarojs/taro'
import { Button, Image, ScrollView, Text, View } from '@tarojs/components'
import { RadarChart } from '../../components/RadarChart'
@@ -9,12 +10,13 @@ import { FiveElementIcon } from '../../components/FiveElementIcon'
import { BindingGate, DeityBindingFlow, DeityCardDialog } from '../../components/DeityBinding'
import { useTiangongSnapshot } from '../../hooks/useTiangongSnapshot'
import { deityCatalog } from '../../services/tiangong'
import { openWechatCustomerService } from '../../services/customerService'
import { DeityId, hasBoundDeity } from '../../utils/tiangongMock'
import { ZodiacMascot } from '../../components/ZodiacMascot'
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 { calculateWeeklyZodiacFortune, getBeijingDate } from '../../utils/zodiacWeekly'
import { isFrontendPreview } from '../../services/frontendPreview'
import { isAvatarDemo } from '../../services/avatarDemo'
import './index.scss'
@@ -53,6 +55,12 @@ const WaterMascot = ({ compact = false }: { compact?: boolean }) => (
)
export default function MyPage() {
const [safeAreaStyle] = useState<CSSProperties | undefined>(() => {
if (process.env.TARO_ENV !== 'weapp') return undefined
const info = typeof Taro.getWindowInfo === 'function' ? Taro.getWindowInfo() : Taro.getSystemInfoSync()
const top = Math.max(info.statusBarHeight || 0, info.safeArea?.top || 0)
return { '--my-safe-top': `${top}px` } as CSSProperties
})
const bindingSnapshot = useTiangongSnapshot()
const unlocked = hasBoundDeity(bindingSnapshot)
const boundDeities = deityCatalog.filter(item => bindingSnapshot.deities[item.id].unlocked)
@@ -62,7 +70,6 @@ export default function MyPage() {
const [profileError, setProfileError] = useState(false)
const [mode, setMode] = useState<PageMode>('home')
const [zodiacOpen, setZodiacOpen] = useState(false)
const [zodiacRulesOpen, setZodiacRulesOpen] = useState(false)
const [beijingDate, setBeijingDate] = useState(() => getBeijingDate())
const [birthdayVisible, setBirthdayVisible] = useState(false)
const previewNote = isFrontendPreview()
@@ -90,7 +97,6 @@ export default function MyPage() {
const openZodiac = () => {
setBeijingDate(getBeijingDate())
setZodiacRulesOpen(false)
setZodiacOpen(true)
}
@@ -117,11 +123,6 @@ export default function MyPage() {
const go = (url: string) => Taro.navigateTo({ url })
const openEnterpriseWechat = () => {
// TODO: replace with Enterprise WeChat customer-service config from backend.
Taro.showToast({ title: '企业微信入口待配置', icon: 'none' })
}
const renderTabs = () => <AppTabBar active='my' />
const renderBindingDialogs = () => <>
{bindingOpen && <DeityBindingFlow onClose={() => setBindingOpen(false)} />}
@@ -129,7 +130,7 @@ export default function MyPage() {
</>
const renderElement = () => !elementReport ? (
<View className={`my-page element-detail ${unlocked ? '' : 'binding-viewport-locked'}`} catchMove={!unlocked && !bindingOpen}>
<View className={`my-page element-detail ${unlocked ? '' : 'binding-viewport-locked'}`} style={safeAreaStyle} catchMove={!unlocked && !bindingOpen}>
{previewNote}
<Button className='element-back' onClick={() => setMode('home')}>‹</Button>
<View className='element-empty'>
@@ -140,7 +141,7 @@ export default function MyPage() {
{renderBindingDialogs()}
</View>
) : (
<View className={`my-page element-detail ${unlocked ? '' : 'binding-viewport-locked'}`} catchMove={!unlocked && !bindingOpen}>
<View className={`my-page element-detail ${unlocked ? '' : 'binding-viewport-locked'}`} style={safeAreaStyle} catchMove={!unlocked && !bindingOpen}>
{previewNote}
<Button className='element-back' onClick={() => setMode('home')}>‹</Button>
<View className={`element-protected ${unlocked ? '' : 'element-locked'}`}>
@@ -192,7 +193,6 @@ export default function MyPage() {
? `日主${elementReport.strength}时,${elementReport.unfavorable}不宜继续偏增。可留意${traitText(elementReport.unfavorable)}失衡带来的消耗。`
: '当前不划分固定偏忌,避免单一五行过度偏增。'}</Text>
</View>
<Text className='element-method-note'>普通扶抑简化参考算法 v1,仅作传统文化娱乐参考;不含从格、化气、合冲刑害及调候等特殊判断。按北京时间公历、节气月柱与零点换日计算;时辰以起始小时代表,不作真太阳时校正。</Text>
</View>
{!unlocked && <BindingGate onBind={() => setBindingOpen(true)} />}
</View>
@@ -215,7 +215,7 @@ export default function MyPage() {
}
return (
<View className='my-page my-home'>
<View className='my-page my-home' style={safeAreaStyle}>
{previewNote}
<View className='profile-area' onClick={() => go('/pages/profile-edit/index')}>
<View className='profile-avatar-button'>
@@ -224,7 +224,7 @@ export default function MyPage() {
<View className='profile-info'>
<View className='name-row'>
<Text className='profile-name'>{profile.nickname}</Text>
{!!genderSymbol(profile.gender) && <Text className='gender-chip'>{genderSymbol(profile.gender)}</Text>}
{!!genderSymbol(profile.gender) && <View className='gender-chip'><Text>{genderSymbol(profile.gender)}</Text></View>}
</View>
<View className='birthday-row'>
<View className='birthday-icon'>
@@ -281,7 +281,7 @@ export default function MyPage() {
</View>
<View className='settings-card'>
<Button className='settings-row' onClick={openEnterpriseWechat}>
<Button className='settings-row' onClick={openWechatCustomerService}>
<View className='menu-icon headset-icon'>
<View className='headset-band' />
<View className='headset-ear left' />
@@ -333,17 +333,6 @@ export default function MyPage() {
<View><Text className='guide-label supportive'>助力日</Text><Text>{weeklyFortune.supportiveDays.map((item) => `${item.label} ${item.weekday}`).join('、') || '本周无明显合助日'}</Text></View>
<View><Text className='guide-label cautious'>留意日</Text><Text>{weeklyFortune.cautiousDays.map((item) => `${item.label} ${item.weekday}`).join('、') || '本周无六冲、相害日'}</Text></View>
</View>
<Button className='zodiac-rules-toggle' onClick={() => setZodiacRulesOpen((open) => !open)}>{zodiacRulesOpen ? '收起判断依据' : '查看判断依据'}</Button>
{zodiacRulesOpen && <View className='zodiac-rules'>
<Text className='zodiac-rule-copy'>{ZODIAC_WEEKLY_RULES}</Text>
<Text className='zodiac-rule-copy'>本周合计:{weeklyFortune.score > 0 ? '+' : ''}{weeklyFortune.score}。逐日与出生年支“{weeklyFortune.branch}”比较:</Text>
{weeklyFortune.days.map((item) => <View key={item.date} className={`zodiac-day ${item.score > 0 ? 'supportive' : item.score < 0 ? 'cautious' : ''}`}>
<Text>{item.label} {item.weekday}</Text>
<Text>{item.branch}日 · {item.relation === '三合' ? '三合同组' : item.relation}</Text>
<Text>{item.score > 0 ? '+' : ''}{item.score}</Text>
</View>)}
<Text className='zodiac-rule-copy'>三合仅取同组两支的助力关系,不代表三合成局;不纳入刑、破或完整八字。</Text>
</View>}
<Text className='zodiac-week-note'>按北京时间周一至周日计算 · 合冲简化规则 v1{ '\n' }仅作传统文化娱乐参考,不作为现实决策依据</Text>
</>
) : (
+6 -5
View File
@@ -1,4 +1,5 @@
.edit-page {
--edit-safe-top: env(safe-area-inset-top, 0px);
min-height: 100vh;
box-sizing: border-box;
color: #211c3a;
@@ -11,7 +12,7 @@
// H5 keeps lazy-loaded page styles after navigation; contain all editor rules.
.edit-page {
.nav {
height: calc(112px + env(safe-area-inset-top));
height: calc(112px + var(--edit-safe-top));
padding: 0 32px 24px;
align-items: flex-end;
}
@@ -30,7 +31,7 @@
}
.profile-loading {
padding-top: calc(280px + env(safe-area-inset-top));
padding-top: calc(280px + var(--edit-safe-top));
text-align: center;
color: rgba(33, 28, 58, 0.58);
font-size: 28px;
@@ -41,7 +42,7 @@
max-width: 750px;
min-height: 100vh;
margin: 0 auto;
padding: calc(164px + env(safe-area-inset-top)) 64px calc(78px + env(safe-area-inset-bottom));
padding: calc(164px + var(--edit-safe-top)) 64px calc(78px + env(safe-area-inset-bottom));
box-sizing: border-box;
display: flex;
flex-direction: column;
@@ -331,7 +332,7 @@
@media screen and (max-height: 700px) {
.edit-content {
padding-top: calc(140px + env(safe-area-inset-top));
padding-top: calc(140px + var(--edit-safe-top));
}
.avatar-button {
@@ -360,7 +361,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 { padding: calc(160px + var(--edit-safe-top)) 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; }
}
+9 -1
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react'
import type { CSSProperties } from 'react'
import Taro, { useRouter } from '@tarojs/taro'
import { Button, Input, Picker, Text, View } from '@tarojs/components'
import areaData from 'china-area-data'
@@ -61,6 +62,13 @@ const errorMessage = (error: unknown, fallback: string) => error instanceof Erro
export default function ProfileEditPage() {
const registering = useRouter().params.mode === 'register'
const [safeAreaStyle] = useState<CSSProperties | undefined>(() => {
if (process.env.TARO_ENV !== 'weapp') return undefined
const info = typeof Taro.getWindowInfo === 'function' ? Taro.getWindowInfo() : Taro.getSystemInfoSync()
// WeChat reports physical pixels; keep this inset out of the rpx conversion.
const top = Math.max(info.statusBarHeight || 0, info.safeArea?.top || 0)
return { '--edit-safe-top': `${top}px` } as CSSProperties
})
const alive = useRef(true)
const savingRef = useRef(false)
const [profile, setProfile] = useState<UserProfile | null>(null)
@@ -194,7 +202,7 @@ export default function ProfileEditPage() {
}
return (
<View className='edit-page'>
<View className='edit-page' style={safeAreaStyle}>
<NavBar title={registering ? '完善注册资料' : '编辑个人信息'} back />
{loading ? (
<View className='profile-loading'>加载中...</View>
+12 -6
View File
@@ -3,6 +3,7 @@
.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; }
.tg-image-note { 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; }
@@ -17,13 +18,18 @@
.tg-panel-heading-line { display: block; font-size: 25px; font-weight: 600; line-height: 1.8; }
.tg-worship-stats { display: flex; flex-wrap: wrap; gap: 20px; margin-top: 22px; padding: 20px 24px; background: rgba(255,255,255,.25); border-radius: 12px; font-size: 23px; line-height: 1.8; }
.tg-waiting-dots { font-size: 64px; text-align: center; line-height: 1.8; letter-spacing: 18px; color: #7c9a7f; }
.tiangong-detail-page.invitation { height: auto; min-height: 100vh; overflow: visible; }
.invitation .tg-sacred-scene { flex: none; height: 730px; }
.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; }
.tiangong-detail-page.invitation { height: auto; min-height: 100vh; min-height: 100dvh; overflow: visible; background: #faf2d4; }
// 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 { 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; }
.tg-invite-name { display: block; padding: 30px 24px; font-size: 40px; line-height: 1.5; text-align: center; background: #d0e4d9; color: #916b2c; font-weight: 700; letter-spacing: 3px; }
.tg-introduction { display: block; margin: 26px 32px 18px; font-size: 26px; line-height: 1.8; color: #28342c; white-space: pre-wrap; overflow-wrap: break-word; }
.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-invite-description .tg-invite-main { width: 224px; height: 82px; margin: 18px auto 4px; padding: 0; border-radius: 44px; line-height: 82px; font-size: 30px; color: #27382d; background: radial-gradient(ellipse at 50% 35%,#f8fcf4,#d0e4d6 80%); box-shadow: inset 0 2px 8px rgba(255,255,255,.6); }
.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; }
+13 -5
View File
@@ -5,6 +5,7 @@ import { TiangongHeader, TiangongModal, TiangongMockNote } from '../../component
import { DeityBindingFlow } from '../../components/DeityBinding'
import { useTiangongSnapshot } from '../../hooks/useTiangongSnapshot'
import { getDeity, isTiangongDemo, tiangongMock, tiangongSession } from '../../services/tiangong'
import { resolveAssetUrl } from '../../services/request'
import { TiangongPending } from '../../components/TiangongPending'
import { hasLoginAccess } from '../../services/loginAccess'
import { useRequiredLogin } from '../../hooks/useRequiredLogin'
@@ -35,6 +36,7 @@ function TiangongDetailContent() {
const [error, setError] = useState('')
const [result, setResult] = useState('')
const [busy, setBusy] = useState(false)
const [failedImage, setFailedImage] = useState('')
const working = useRef(false)
const demo = isTiangongDemo()
const close = () => { if (!working.current) { setModal(''); setError('') } }
@@ -48,6 +50,12 @@ function TiangongDetailContent() {
const state = snapshot.deities[deity.id]
if (!state.available) return <View className='tg-invalid'><TiangongHeader title='天宫' onBack={back} /><Text>该神仙暂未配置,请返回天宫选择。</Text><Button onClick={back}>返回天宫</Button></View>
const invited = state.unlocked
// Production detail never substitutes a bundled prototype image or copy
// for the backend fields. Local materials belong to explicit demos only.
const detailImage = demo ? deity.statue : resolveAssetUrl(state.iconDetailUrl)
const showImage = !!detailImage && failedImage !== detailImage
const introduction = (demo ? deity.introduction : state.description)?.trim() || '暂无介绍'
const imageNote = detailImage ? '详情图片加载失败' : '详情图片暂未配置'
const perform = async (action: () => void | Promise<void>) => {
if (working.current) return
working.current = true; setBusy(true); setError('')
@@ -58,7 +66,7 @@ function TiangongDetailContent() {
const showBindingCard = () => { close(); setBindingCardOpen(true) }
const submitWish = () => perform(async () => { await tiangongSession.wish(deity.id, wish); setWish(''); showResult(demo ? '模拟祈愿已完成,内容未上传、未保存。' : '祈愿已记录。') })
const submitDedication = () => perform(async () => { await tiangongSession.dedicate(deity.id, name, blessing); setName(''); setBlessing(''); showResult(demo ? '模拟回向已完成,姓名和祝愿未上传、未保存。' : '回向已记录。') })
const panelHeader = (title: string, subtitle: string) => <View className='tg-panel-heading'><Image className='tg-panel-statue' src={deity.statue} mode='aspectFit' /><View className='tg-panel-copy'><Text className='tg-panel-heading-line'>{title}</Text><Text className='tg-panel-heading-line'>{subtitle}</Text></View></View>
const panelHeader = (title: string, subtitle: string) => <View className='tg-panel-heading'>{showImage && <Image className='tg-panel-statue' src={detailImage} mode='aspectFit' onError={() => setFailedImage(detailImage)} />}<View className='tg-panel-copy'><Text className='tg-panel-heading-line'>{title}</Text><Text className='tg-panel-heading-line'>{subtitle}</Text></View></View>
const dialogActions = (confirm: string, action: () => void, danger = false, disabled = false) => <View className='tg-modal-actions'><Button className='tg-button' onClick={close}>取消</Button><Button className={`tg-button ${danger ? 'danger' : 'primary'} ${busy || disabled ? 'is-disabled' : ''}`} disabled={busy || disabled} onClick={action}>{confirm}</Button></View>
return <View className={`tiangong-detail-page ${invited ? 'worship' : 'invitation'}`}>
@@ -69,7 +77,7 @@ function TiangongDetailContent() {
} /><TiangongMockNote />
<View className='tg-sacred-scene'>
<View className='tg-statue-space' style={{ backgroundImage: `url(${clouds})` }}>
<Image className={`tg-statue ${deity.missingStatue ? 'catalog-art' : ''}`} src={deity.statue} mode='aspectFit' />
{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>}
@@ -83,9 +91,9 @@ function TiangongDetailContent() {
}}><Text>{action[0]}</Text><Text>{action[1]}</Text></Button>)}
</View> : <View className='tg-invite-description'>
<Text className='tg-invite-name'>{deity.name}</Text>
<Text className='tg-introduction'>{deity.introduction || '原型未提供这位神仙的独立介绍,待确认后补充。'}</Text>
{deity.introduction && <Text className='tg-content-note'>原型介绍与“禄星”名称不一致,文案待确认。</Text>}
{deity.missingStatue && <Text className='tg-content-note'>原型未提供独立详情神像,暂使用同一神仙的列表原图。</Text>}
<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>
{demo && <Text className='tg-content-note'>仅演示恭请流程,不扣费、不建立真实供奉关系。</Text>}
</View>}
+36
View File
@@ -0,0 +1,36 @@
import Taro from '@tarojs/taro'
// Public entry parameters, not enterprise API credentials.
export const WECHAT_CUSTOMER_SERVICE = {
corpId: 'wwf8bbe2cad2e9e84b',
url: 'https://work.weixin.qq.com/kfid/kfc5a57c84cb0feb2fd'
} as const
let opening = false
// Keep the platform call in the original tap handler; do not fetch config or
// wait for another request first, since WeChat requires a user gesture.
export async function openWechatCustomerService() {
if (process.env.TARO_ENV !== 'weapp') {
Taro.showToast({ title: '请在微信小程序中联系客服', icon: 'none' })
return
}
if (opening) return
if (typeof Taro.openCustomerServiceChat !== 'function' || !Taro.canIUse('openCustomerServiceChat')) {
Taro.showToast({ title: '当前微信版本不支持,请更新微信后重试', icon: 'none' })
return
}
opening = true
try {
await Taro.openCustomerServiceChat({
corpId: WECHAT_CUSTOMER_SERVICE.corpId,
extInfo: { url: WECHAT_CUSTOMER_SERVICE.url }
})
} catch (error) {
if (!/cancel/i.test((error as { errMsg?: string })?.errMsg || '')) {
Taro.showToast({ title: '客服打开失败,请稍后重试', icon: 'none' })
}
} finally {
opening = false
}
}
+1 -1
View File
@@ -2,7 +2,7 @@ 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; description?: string }
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 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
+2 -2
View File
@@ -1,7 +1,7 @@
import { DeityId, emptyTiangongSnapshot, TiangongSnapshot } from './tiangongMock'
import { INCENSE_DURATION_MS } from './incenseRitual'
export type RemoteDeity = { id: number; name: string; holdingId?: number; active: boolean; incenseCount: number; days: number; iconUrl?: string; description?: string }
export type RemoteDeity = { id: number; name: string; holdingId?: number; active: boolean; incenseCount: number; days: number; iconUrl?: string; iconDetailUrl?: string; description?: string }
type Dependencies = {
key: () => string; load: () => Promise<RemoteDeity[]>; resolve: (name: string) => DeityId | undefined
offer: (holdingId: number, type: 2 | 3, content?: string) => Promise<{ id: number }>
@@ -51,7 +51,7 @@ export function createTiangongStore(deps: Dependencies) {
const active = item.active === true && Number.isSafeInteger(item.holdingId) && item.holdingId! > 0
next.deities[id] = { ...next.deities[id], available: true, unlocked: active,
holdingId: item.holdingId, days: Math.max(0, item.days || 0), incenseCount: Math.max(0, item.incenseCount || 0),
iconUrl: item.iconUrl, description: item.description,
iconUrl: item.iconUrl, iconDetailUrl: item.iconDetailUrl, description: item.description,
incenseUntil: active && old.holdingId === item.holdingId ? old.incenseUntil : 0,
lit: active && old.holdingId === item.holdingId && old.incenseUntil > now() }
})