diff --git a/docs/deity-card-style.md b/docs/deity-card-style.md new file mode 100644 index 0000000..4058dd1 --- /dev/null +++ b/docs/deity-card-style.md @@ -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`。 diff --git a/docs/tiangong-image-fields.md b/docs/tiangong-image-fields.md new file mode 100644 index 0000000..49937d3 --- /dev/null +++ b/docs/tiangong-image-fields.md @@ -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 均保留。本次未自动提交、推送或部署后端。 diff --git a/docs/tiangong-invitation-layout.md b/docs/tiangong-invitation-layout.md new file mode 100644 index 0000000..7526fbd --- /dev/null +++ b/docs/tiangong-invitation-layout.md @@ -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 下标,导致两项头像入口测试失败。本次保留这些页面改动,未修改头像逻辑或掩盖失败。 diff --git a/docs/wechat-customer-service.md b/docs/wechat-customer-service.md new file mode 100644 index 0000000..02b0eef --- /dev/null +++ b/docs/wechat-customer-service.md @@ -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张神仙图片压缩上传及数据库更新,本次未执行。 diff --git a/project.config.json b/project.config.json index 5a0242d..d7d3745 100644 --- a/project.config.json +++ b/project.config.json @@ -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": {} +} \ No newline at end of file diff --git a/scripts/avatarEntry.test.cjs b/scripts/avatarEntry.test.cjs index c8342e0..5dbb3c6 100644 --- a/scripts/avatarEntry.test.cjs +++ b/scripts/avatarEntry.test.cjs @@ -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() diff --git a/scripts/customerService.test.cjs b/scripts/customerService.test.cjs new file mode 100644 index 0000000..5194bae --- /dev/null +++ b/scripts/customerService.test.cjs @@ -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/) + } +}) diff --git a/scripts/deityCard.test.cjs b/scripts/deityCard.test.cjs new file mode 100644 index 0000000..78e1259 --- /dev/null +++ b/scripts/deityCard.test.cjs @@ -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) +}) diff --git a/scripts/invitationLayout.test.cjs b/scripts/invitationLayout.test.cjs new file mode 100644 index 0000000..e2977d3 --- /dev/null +++ b/scripts/invitationLayout.test.cjs @@ -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') +}) diff --git a/scripts/loginPresentation.test.cjs b/scripts/loginPresentation.test.cjs index b83b0ed..e4d256e 100644 --- a/scripts/loginPresentation.test.cjs +++ b/scripts/loginPresentation.test.cjs @@ -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]) } diff --git a/scripts/tiangongPresentation.test.cjs b/scripts/tiangongPresentation.test.cjs new file mode 100644 index 0000000..e256f7a --- /dev/null +++ b/scripts/tiangongPresentation.test.cjs @@ -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), /本地原型介绍/) +}) diff --git a/scripts/tiangongStore.test.cjs b/scripts/tiangongStore.test.cjs index 2d1efa1..686eabc 100644 --- a/scripts/tiangongStore.test.cjs +++ b/scripts/tiangongStore.test.cjs @@ -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('网络失败') }) diff --git a/src/components/DeityBinding.scss b/src/components/DeityBinding.scss index 0d864d0..11dfa76 100644 --- a/src/components/DeityBinding.scss +++ b/src/components/DeityBinding.scss @@ -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; } diff --git a/src/components/DeityBinding.tsx b/src/components/DeityBinding.tsx index 9274142..15a6eab 100644 --- a/src/components/DeityBinding.tsx +++ b/src/components/DeityBinding.tsx @@ -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? 须绑定神仙开启你的守护之旅 - + @@ -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 - {deity.name} - - {state.unlocked ? `已守护 ${state.days} 天 · 持有记录 ${state.holdingId}` : '请通过 NFC 绑定神仙。此处不模拟建立真实绑定关系。'} - - + 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 - + ✦{deity.name}✦ - {mode === 'view' && } + {demo && mode === 'view' && } - {menu && } - + {demo && menu && } + {canShowArt ? setFailedImage(art)} /> : {art ? '图片加载失败' : '图片暂未配置'}} 守护神仙 · {deity.name} - 唯一 ID:DEMO-{deityId.toUpperCase()}-001 - Mock 原型演示 · 不连接真实 NFC - {mode === 'bind' ? <> + {demo ? <>唯一 ID:DEMO-{deityId.toUpperCase()}-001Mock 原型演示 · 不连接真实 NFC : state.holdingId ? 持有记录:{state.holdingId} : null} + {demo && mode === 'bind' ? <> 每位神仙只能绑定一个微信号哦 - : ✓已守护 {state.days} 天} + : state.unlocked ? ✓已守护 {state.days} 天 : 请通过 NFC 绑定神仙。} {error && {error}} - {confirmUnbind && + {demo && confirmUnbind && ⓘ 确定要解绑吗? diff --git a/src/content/agreements.ts b/src/content/agreements.ts new file mode 100644 index 0000000..daa3591 --- /dev/null +++ b/src/content/agreements.ts @@ -0,0 +1,1416 @@ +// Content transcribed from the supplied 洪贞小程序 Word documents. +// Keep this bundled copy authoritative so older API content cannot replace it. +export type AgreementType = 'user' | 'privacy' +export type AgreementRun = { text: string; bold?: boolean } +export type AgreementBlock = + | { type: 'heading' | 'paragraph'; runs: AgreementRun[] } + | { type: 'table'; headers: string[]; rows: string[][] } +export type AgreementDocument = { title: string; blocks: AgreementBlock[] } + +export const agreements: Record = { + "user": { + "title": "小程序用户服务协议", + "blocks": [ + { + "type": "paragraph", + "runs": [ + { + "text": "欢迎使用河南洪贞文化科技有限公司(以下简称“本公司”)开发并运营的小程序及相关服务(以下统称“本服务”)。您在使用本服务前,应当认真阅读并充分理解本协议全部内容,特别是以加粗字体标示的与您权利义务有重大影响、免除或者限制本公司责任的条款,以及您开通或者使用特定功能时另行签署的专项规则。请您务必审慎阅读、充分理解各条款内容,并选择接受或者不接受。" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "除非您已阅读并接受本协议全部条款,否则您无权使用本服务。您通过注册、登录、勾选同意或者其他本公司认可的方式使用本服务,即视为您已阅读并同意受本协议约束。如您为无民事行为能力人或者限制民事行为能力人,应当在法定监护人陪同下阅读本协议,并在征得法定监护人同意后使用本服务。" + } + ] + }, + { + "type": "heading", + "runs": [ + { + "text": "第一条 定义与协议范围", + "bold": true + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "(一)本协议是您与本公司之间就您使用本服务所订立的协议。“用户”指注册、登录或者使用本服务的自然人、法人或者其他组织,在本协议中亦称为“您”。" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "(二)本协议内容包括本协议正文,以及本公司已经发布或者将来可能发布的与小程序相关的各项规则、公告、操作提示等(以下统称“平台规则”)。平台规则构成本协议不可分割的组成部分,与本协议正文具有同等法律效力。" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "(三)本协议适用于您在本服务项下的全部活动。您在使用本服务时,应当遵守中华人民共和国法律法规、政策及公序良俗,并对自身行为依法承担相应责任。" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "(四)本协议仅调整您与本公司之间的权利义务关系,不涉及您与其他用户之间因使用本服务而产生的法律关系,相关事宜由您与相关方自行协商处理。" + } + ] + }, + { + "type": "heading", + "runs": [ + { + "text": "第二条 协议的变更", + "bold": true + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "(一)本公司有权根据法律法规变化、监管要求或者业务发展需要,不时修改本协议,或者制定、修改平台规则,并将在小程序内以公告、系统消息等合理方式向您提示。" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "(二)修改后的内容自公告载明的生效之日起适用。若您在本协议或者平台规则变更后继续使用本服务," + }, + { + "text": "视为您已阅读、理解并接受变更后的内容", + "bold": true + }, + { + "text": ";若您不同意变更后的内容,应当立即停止使用本服务。" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "(三)就变更前您已通过本服务从事的活动及其法律后果,适用该等活动发生时的协议版本。" + } + ] + }, + { + "type": "heading", + "runs": [ + { + "text": "第三条 账号的注册、使用与管理", + "bold": true + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "(一)您应当按照页面提示填写真实、准确、完整、合法的信息完成注册,或者通过本公司支持的其他方式(包括但不限于微信授权登录)使用本服务。本公司将按照本协议及《隐私政策》的约定处理您的相关信息。" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "(二)您注册成功后取得的账号及登录凭证,应当由您妥善保管," + }, + { + "text": "并对以您的账号名义实施的全部行为及其后果承担责任", + "bold": true + }, + { + "text": "。" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "(三)您的账号仅限您本人(或者本单位)使用,不得出租、出借、转让、赠与或者与他人共享。如发现账号存在被盗用、密码泄露或者其他安全隐患,您应当及时通知本公司并配合采取相应措施。" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "(四)您可以设置昵称、头像等账号信息,但不得违反法律法规,不得侵犯或者涉嫌侵犯他人合法权益。如存在前述情形,本公司有权要求您限期更正,或者视情节采取限制功能、注销账号等措施。" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "(五)您有权依据法律法规及平台规则申请注销账号。账号注销后,您的个人信息将按照《隐私政策》及适用法律的规定处理。" + } + ] + }, + { + "type": "heading", + "runs": [ + { + "text": "第四条 服务内容", + "bold": true + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "(一)本公司通过小程序向您提供与本公司经营业务相关的软件服务及配套功能,具体服务内容以小程序页面实际展示的功能为准。" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "(二)本公司将根据业务发展需要对服务内容进行升级、调整或者增加、取消部分功能,并可能对部分功能设置使用条件或者相应收费标准(如有),具体以页面公示为准。" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "(三)您理解并同意,本服务的正常使用依赖于网络环境、移动终端设备以及第三方平台(包括但不限于微信平台)的正常运行,前述因素可能导致服务中断或者使用体验变化,本公司将在合理范围内提供协助。" + } + ] + }, + { + "type": "heading", + "runs": [ + { + "text": "第五条 用户行为规范", + "bold": true + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "(一)您在使用本服务时,不得制作、复制、发布、传播含有下列内容的信息:" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "1. 违反宪法确定的基本原则的;" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "2. 危害国家安全,泄露国家秘密,颠覆国家政权,破坏国家统一的;" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "3. 损害国家荣誉和利益的;" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "4. 煽动民族仇恨、民族歧视,破坏民族团结的;" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "5. 破坏国家宗教政策,宣扬邪教、封建迷信的;" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "6. 散布谣言,扰乱社会秩序,破坏社会稳定的;" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "7. 散布淫秽、色情、赌博、暴力、凶杀、恐怖信息或者教唆犯罪的;" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "8. 侮辱、诽谤他人,侵害他人名誉权、隐私权、肖像权、知识产权、商业秘密等合法权益的;" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "9. 含有虚假、欺诈、骚扰、广告推销、垃圾信息或者任何含有性、性暗示内容的;" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "10. 煽动非法集会、结社、游行、示威,聚众扰乱社会秩序的;" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "11. 以非法民间组织名义活动的;" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "12. 违反法律、行政法规禁止的其他内容。" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "(二)您不得利用本服务从事下列行为:" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "1. 利用技术手段或者其他方式干扰、破坏本服务及相关系统、网络的正常运行;" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "2. 对本小程序及相关软件进行反向工程、反向编译、破解、攻击或者植入恶意程序;" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "3. 批量注册账号、恶意刷量、虚构数据或者从事其他扰乱平台秩序的行为;" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "4. 未经授权获取、收集其他用户的个人信息或者本公司的非公开资料;" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "5. 利用本服务从事其他违反法律法规或者损害国家利益、社会公共利益的行为。" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "(三)如您违反本条约定,本公司有权依据情节轻重,采取警示、限制或者暂停功能、删除相关内容、终止服务、注销账号等一项或者多项措施;由此给本公司、其他用户或者第三方造成损失的,您应当依法承担相应的赔偿责任。" + } + ] + }, + { + "type": "heading", + "runs": [ + { + "text": "第六条 知识产权", + "bold": true + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "(一)本公司在本服务中提供的内容,包括但不限于软件、程序、界面设计、文字、图片、音频、视频、图表、商标、标识及Logo等,其著作权、商标权、专利权及其他知识产权均归本公司或者相应权利人所有,受法律保护。" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "(二)除法律法规另有规定外,您在使用本服务过程中自行产生的内容,其知识产权归您或者相应权利人所有;您授权本公司在为您提供本服务所必需的范围内使用、存储前述内容,并同意本公司有权按照法律法规及平台规则对违法违规内容进行处置。" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "(三)未经本公司或者相应权利人书面许可,任何第三方不得以任何方式使用、复制、传播本服务中包含的受知识产权保护的内容,亦不得基于该等内容创作衍生作品。" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "(四)任何未经本公司书面同意及相应权利人许可的获取或者使用行为,均可能构成侵权。为维护合法权益,本公司有权在发现侵权行为时依法采取必要的维权措施。" + } + ] + }, + { + "type": "heading", + "runs": [ + { + "text": "第七条 个人信息与隐私保护", + "bold": true + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "(一)本公司重视并依法保护您的个人信息。您使用本服务过程中涉及的个人信息收集、使用、存储、共享、删除等事宜,均按照《隐私政策》及适用法律的规定处理。" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "(二)本公司仅在实现处理目的所必要的范围内,以对个人权益影响最小的方式处理您的个人信息,并采取相应的安全技术措施和管理措施保障个人信息安全。" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "(三)您可以通过《隐私政策》载明的方式查阅、复制、更正、补充您的个人信息,撤回同意,申请注销账号或者行使其他法定权利。" + } + ] + }, + { + "type": "heading", + "runs": [ + { + "text": "第八条 责任限制与免责声明", + "bold": true + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "(一)您理解并同意,鉴于互联网服务的特殊性,因下列情形导致服务中断、延迟、数据丢失或者无法正常使用的," + }, + { + "text": "本公司不承担相应责任,但法律法规另有规定的除外", + "bold": true + }, + { + "text": ":" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "1. 不可抗力,包括但不限于自然灾害、政府行为、突发公共卫生事件等;" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "2. 基础网络故障、电力中断、通信线路故障等非本公司所能控制的情形;" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "3. 第三方平台(包括但不限于微信平台)服务异常、变更或者限制;" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "4. 计算机病毒、木马、黑客攻击或者其他恶意程序造成的破坏。" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "(二)本公司不对以下事项作出任何明示或者默示的保证:本服务将完全满足您的需求;本服务不受干扰、及时、安全、可靠或者无错误;您通过本服务获取的信息完全准确、完整。" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "(三)因您自身原因(包括但不限于操作不当、未妥善保管账号及密码、违反法律法规或者本协议约定)造成的损失,由您自行承担。" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "(四)您承诺向本公司提供的资料和信息真实、准确、完整、有效,且已经获得合法授权,不存在侵犯第三方合法权益的情形。因您提供虚假、冒用或者侵权信息,或者因您的违法违规行为导致本公司、第三方陷入争议、诉讼或者受到损失的,您应当赔偿由此产生的实际损失。" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "(五)" + }, + { + "text": "在法律允许的最大范围内,本公司因本协议或者本服务所承担的累计赔偿责任,以争议发生前十二个月内您因使用本服务实际向本公司支付的费用总额(如有)为限;如您未向本公司支付任何费用,则以前述法律允许的相应责任范围为限。", + "bold": true + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "(六)本公司依据本协议及平台规则对违法违规内容或者行为采取的处理措施,不构成本公司的义务或者承诺,本公司不保证能够及时发现、制止所有违法违规情形。" + } + ] + }, + { + "type": "heading", + "runs": [ + { + "text": "第九条 服务的变更、中断与终止", + "bold": true + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "(一)由于网络服务及第三方平台的特性,本公司可能对本服务进行升级、维护或者调整,由此可能造成服务临时中断或者内容变化,您对此予以理解。" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "(二)基于经营需要,本公司有权变更、限制、中止或者终止本服务的部分或者全部内容,并将在合理可行的范围内提前通知您;因不可抗力、紧急维护、监管要求等客观原因无法提前通知的,本公司将采取合理的补救措施。" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "(三)如您严重违反本协议或者平台规则,本公司有权不经另行通知,暂停或者终止向您提供服务,并视情节注销您的账号。" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "(四)本服务终止的,除法律法规另有规定外,本公司无需因此向您承担违约责任,但应当依法妥善处理您的个人信息,以及您已支付但尚未享受的服务费用(如有)。" + } + ] + }, + { + "type": "heading", + "runs": [ + { + "text": "第十条 通知与送达", + "bold": true + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "(一)本公司将通过小程序站内通知、系统消息、短信、电子邮件等合理方式向您发送通知。" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "(二)您应当确保注册时提供的联系方式真实、有效,并在联系方式变更后及时更新。因您提供的联系方式不准确、不完整或者未及时更新,导致无法接收通知的,相关后果由您自行承担。" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "(三)前述通知自发出之时视为送达;法律法规另有规定或者双方另有约定的,从其规定。" + } + ] + }, + { + "type": "heading", + "runs": [ + { + "text": "第十一条 商业信息推送", + "bold": true + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "(一)在符合法律法规规定并取得您同意(或者法律法规允许的其他情形)的前提下,本公司可能通过短信、邮件、站内消息等渠道,向您发送与本服务相关的服务通知、活动信息或者商业推广信息。" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "(二)您有权通过我们提供的退订方式或者其他合理途径拒绝接收商业推广信息,本公司将在合理期限内处理您的请求。" + } + ] + }, + { + "type": "heading", + "runs": [ + { + "text": "第十二条 法律适用与争议解决", + "bold": true + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "(一)本协议的订立、效力、解释、履行及争议解决,均适用中华人民共和国法律(为本协议之目的,不包括香港特别行政区、澳门特别行政区和台湾地区法律)。" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "(二)因本协议或者本服务产生的争议,双方应当首先友好协商解决;协商不成的," + }, + { + "text": "任何一方均可向本公司所在地有管辖权的人民法院提起诉讼", + "bold": true + }, + { + "text": "。" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "(三)本协议无意在您与本公司之间创设任何代理、合伙、合营、雇佣或者其他特定法律关系。" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "(四)本公司有权基于经营需要,将本协议项下的全部或者部分权利义务转让给关联公司或者依法承继的主体,并应以合理方式通知您;未经本公司书面同意,您不得转让本协议项下的权利义务。" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "(五)本协议任何条款被认定为无效、违法或者不可执行的,不影响其他条款的效力。" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "(六)本协议构成您与本公司就本服务相关事项达成的完整约定,取代双方此前就相同事项达成的任何口头或者书面约定。" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "本协议自您通过本公司认可的方式确认同意之日起,对您与本公司发生法律效力。" + } + ] + } + ] + }, + "privacy": { + "title": "小程序用户隐私保护政策", + "blocks": [ + { + "type": "heading", + "runs": [ + { + "text": "一、引言" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "欢迎使用河南洪贞文化科技有限公司(以下简称“我们”)开发并运营的小程序及相关服务(以下统称“本小程序”)。我们深知个人信息对您的重要性,将依照法律法规的要求,采取相应的安全保护措施,尽力保护您的个人信息安全。" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "本政策旨在向您说明,在使用本小程序及相关服务的过程中,我们如何收集、使用、存储、共享与保护您的个人信息,以及您可以如何管理您的个人信息。请您在使用本小程序前,仔细阅读并充分理解本政策的全部内容,特别是以加粗字体标示的条款。您使用或者继续使用本小程序,即表示您已阅读并同意本政策的约定。" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "本政策依据《中华人民共和国个人信息保护法》《中华人民共和国网络安全法》《中华人民共和国数据安全法》《中华人民共和国未成年人保护法》《未成年人网络保护条例》以及微信小程序平台的有关规范要求制定,适用于本小程序在微信平台内提供的全部服务。" + } + ] + }, + { + "type": "heading", + "runs": [ + { + "text": "二、适用范围" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "本政策适用于您通过本小程序(含后续可能上线的相关版本与功能模块)进行的全部操作,以及由此产生的个人信息处理活动,包括但不限于注册登录、浏览内容、使用功能、参与互动、开通付费服务等场景。" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "本小程序基于微信平台运行。您通过微信授权登录本小程序时,微信平台将依据其自身的隐私保护规则处理相关账号数据,我们仅在本政策及您授权范围内获取并使用必要的信息。" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "本政策不适用于下列情形:通过本小程序跳转至第三方网站或者第三方服务后,该等第三方对您个人信息的处理;您自行向第三方提供的信息。" + } + ] + }, + { + "type": "heading", + "runs": [ + { + "text": "三、我们如何收集您的信息" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "(一)您主动提供的信息" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "1. 账号信息:当您注册或者登录本小程序时,可能需要提供昵称、头像、手机号码(用于账号验证与安全保护)等信息;如您选择通过微信等第三方账号登录,我们将获取您授权提供的第三方账号基本信息(如昵称、头像、OpenID/UnionID),该等信息仅用于账号关联、身份识别与登录状态维持。" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "2. 支付信息:当您购买会员服务或者付费内容时,支付行为由微信支付等第三方支付机构完成,我们仅获取支付结果(如交易单号、支付金额、支付状态)用于确认交易与开通权益,不收集、不存储您的银行卡号、支付密码等敏感支付信息。" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "(二)我们自动收集的信息" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "1. 设备信息:包括设备型号、操作系统及版本、网络状态、设备标识信息(在适用法律允许且您授权同意的范围内)等,用于保障本小程序正常运行、优化使用体验及开展安全验证。" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "2. 位置信息:当您使用地图探索、附近内容推荐等基于位置的功能时,在取得您的授权同意后,我们可能通过微信平台提供的位置接口获取您的位置信息(属于敏感个人信息),仅用于向您推荐附近的内容或者规划路线。您可以随时通过微信或者设备系统设置关闭位置权限;关闭后,基于位置的功能将无法使用,但不影响其他功能。" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "3. 使用信息:包括您浏览的内容、点击的功能模块、页面停留时长、搜索关键词等,用于分析用户偏好、改进服务质量及进行内容推荐。" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "4. 日志信息:包括登录日志、操作日志、异常日志等,用于排查故障、保障账号安全及满足合规审计要求。" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "(三)信息收集的基本原则" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "我们遵循“合法、正当、必要”的原则收集个人信息,仅收集与实现服务功能直接相关的信息;超出前述范围收集信息,或者处理敏感个人信息的,将另行取得您的同意(包括单独同意)。" + } + ] + }, + { + "type": "heading", + "runs": [ + { + "text": "四、我们如何使用您的信息" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "(一)为您提供核心服务:包括账号管理、内容展示、地图导航、社区互动、会员服务等功能。" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "(二)保障服务安全:识别异常登录与异常操作、防范欺诈与滥用行为,保护您的账号及交易安全。" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "(三)优化服务体验:根据您的使用习惯改进功能与界面设计,向您提供更符合需求的内容与活动提醒。" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "(四)履行法定义务:包括应对监管要求、配合有权机关依法调查、开展合规审计等。" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "我们不会将您的个人信息用于本政策未载明的其他用途;确需变更处理目的或者处理方式的,将依法重新取得您的同意。" + } + ] + }, + { + "type": "heading", + "runs": [ + { + "text": "五、个人信息的共享与披露" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "(一)第三方服务共享" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "为向您提供相关功能,我们可能在必要范围内与下列第三方共享您的个人信息:" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "1. 地图服务提供商:为实现地图探索、路线规划与地点标注功能,在取得您授权后,我们可能向其共享您的位置信息(经必要的脱敏处理)。" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "2. 支付服务提供商:为完成付费交易,我们可能向支付机构共享交易单号、支付金额等交易信息,用于处理支付流程。" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "3. 统计分析服务商:为改进服务质量,我们可能向其共享经匿名化或者去标识化处理的使用数据,用于数据分析。" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "上述第三方的具体名称及其隐私政策,我们将通过页面公示或者提供链接的方式向您明示。" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "(二)共享的基本原则" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "1. 我们仅在取得您同意或者法律法规允许的情形下共享您的个人信息,并要求接收方仅为约定目的、按约定方式处理信息。" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "2. 我们要求第三方采取与自身同等的安全保护措施,并通过签署数据处理协议等方式明确其数据安全义务。" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "3. 如第三方变更信息使用目的或者使用范围,我们将要求其重新取得您的同意,并及时向您公示。" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "(三)禁止情形" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "我们不会出售您的个人信息;未经您明确同意,我们不会将您的个人信息用于与服务无关的目的,也不会向无关第三方提供。" + } + ] + }, + { + "type": "heading", + "runs": [ + { + "text": "六、个人信息的存储与保护" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "(一)存储地域与期限" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "1. 我们在中华人民共和国境内存储您的个人信息,原则上不向境外提供;确需向境外提供的,将依法履行告知、取得您的单独同意或者完成安全评估等程序。" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "2. 我们仅在实现处理目的所必需的期限内保存您的个人信息;超出保存期限的,将依法予以删除或者匿名化处理,法律法规另有规定的除外。" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "(二)安全保护措施" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "1. 技术措施:采用加密传输(如HTTPS)、访问权限控制、数据脱敏等技术手段,防止您的个人信息被泄露、篡改或者丢失。" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "2. 管理措施:建立数据安全管理制度,对接触个人信息的员工进行隐私保护培训并实施权限管控,定期开展安全自查与审计。" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "(三)安全事件应急处理" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "如发生或者可能发生个人信息泄露、篡改、丢失的,我们将立即启动应急预案,采取补救措施,并按照法律法规要求及时告知您及履行向有关主管部门报告的义务。" + } + ] + }, + { + "type": "heading", + "runs": [ + { + "text": "七、您对个人信息的权利" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "您依法享有以下权利,并可以通过下列方式行使:" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "(一)查阅与复制:您可以在小程序“个人中心”等页面查看您的账号信息与使用记录;如需复制,可以通过文末联系方式向我们提出,我们将在15个工作日内提供。" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "(二)更正与补充:如您发现您的个人信息不准确或者不完整,可以自行在相关页面修改,或者联系我们申请更正与补充,我们将在15个工作日内处理。" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "(三)删除:您可以自行删除您发布的内容;对于其他依法应予删除的个人信息,您可以联系我们提出删除请求,我们将在15个工作日内核实并处理。" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "(四)撤回同意:您可以在微信或者设备系统的“权限设置”中关闭相应授权,也可以联系我们撤回对处理个人信息的同意。撤回同意不影响撤回前基于您同意已经进行的处理。" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "(五)账号注销:您可以在小程序内提交账号注销申请,我们将在15个工作日内完成注销处理(法律法规另有规定的除外)。注销后,您的个人信息将被删除或者匿名化处理,且无法恢复,请您在注销前妥善备份相关信息。" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "(六)投诉与反馈:如您对我们的个人信息处理活动有疑问或者投诉,可以通过文末联系方式与我们联系,我们将在15个工作日内响应并处理;如您对我们的处理结果不满意,您还可以向网信、市场监管等主管部门投诉举报,或者依法向人民法院提起诉讼。" + } + ] + }, + { + "type": "heading", + "runs": [ + { + "text": "八、未成年人个人信息保护" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "(一)我们重视未成年人个人信息保护。若您为未满18周岁的未成年人,请在监护人指导下阅读本政策,并在征得监护人同意后使用本小程序。" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "(二)我们不会主动面向儿童(不满14周岁的未成年人)收集个人信息。如确因业务需要处理儿童个人信息,我们将严格遵守《中华人民共和国未成年人保护法》《未成年人网络保护条例》《儿童个人信息网络保护规定》等法律法规的要求,征得儿童监护人的同意,并采取专门的安全保护措施。" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "(三)如监护人发现被监护人使用本小程序时提供了个人信息,可以通过文末联系方式与我们联系,我们将在核实后依法及时删除。" + } + ] + }, + { + "type": "heading", + "runs": [ + { + "text": "九、广告标识符与个性化服务" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "(一)在适用法律允许且您同意的范围内,我们可能使用广告标识符(如OAID或者微信平台提供的同类匿名标识)用于投放与您需求相关的内容或者活动广告,但不会将广告标识符与您的姓名、手机号码等个人身份信息或者永久性设备标识相关联。" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "(二)您可以在设备系统中开启“限制广告跟踪”功能,或者按照小程序内的相关设置关闭个性化推荐;关闭后,我们将停止基于广告标识符的定向推送,但不影响您使用基本功能。" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "(三)广告标识符重置后,未经您的同意,我们不会将新的标识符与历史数据重新关联。" + } + ] + }, + { + "type": "heading", + "runs": [ + { + "text": "十、个人信息保护清单" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "(一)已收集个人信息清单" + } + ] + }, + { + "type": "table", + "headers": [ + "信息类别", + "收集目的", + "使用场景" + ], + "rows": [ + [ + "基本账户信息", + "账号识别、身份验证", + "注册登录、个人资料展示" + ], + [ + "设备信息", + "服务优化、安全保障", + "应用运行、异常检测" + ], + [ + "使用信息", + "体验优化、内容推荐", + "个性化推荐、功能改进" + ], + [ + "支付结果信息", + "交易记录、会员权益确认", + "会员开通、服务续费" + ] + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "(二)与第三方共享信息清单" + } + ] + }, + { + "type": "table", + "headers": [ + "信息类别", + "接收方", + "使用目的" + ], + "rows": [ + [ + "位置信息(经脱敏处理)", + "地图服务提供商", + "路线规划、地点标注" + ], + [ + "交易信息", + "支付服务提供商", + "处理支付流程" + ], + [ + "匿名化使用信息", + "统计分析服务商", + "数据分析、服务优化" + ] + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "注:上述清单随业务及合作方变化定期更新,具体以页面公示为准。" + } + ] + }, + { + "type": "heading", + "runs": [ + { + "text": "十一、本政策的更新" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "(一)我们可能根据法律法规变化、监管要求或者业务调整适时修订本政策。修订后的政策将通过在微信平台内公告、弹窗提示等合理方式发布,并自发布之日起适用。" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "(二)对于涉及扩大信息收集范围、改变信息使用目的等重大变更,我们将以显著方式提示您,并依法重新取得您的同意(如需)。您在本政策更新后继续使用本小程序的,视为接受更新后的政策。" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "(三)本政策更新前已经收集的个人信息,仍按照收集时的政策约定处理,法律法规另有规定的除外。" + } + ] + }, + { + "type": "heading", + "runs": [ + { + "text": "十二、如何联系我们" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "如您对本政策或者您的个人信息处理有任何疑问、意见或者建议,可以通过以下方式与我们联系:" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "公司名称:河南洪贞文化科技有限公司" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "联系邮箱:[hongzhenkeji@126.com]" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "联系电话:[18603838459]" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "联系地址:[河南省郑州市金水区宏康路55号海亮时代5号商业楼12层1236号]" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "我们将尽快核实并处理您的问题,一般在15个工作日内予以回复;涉及复杂事项需要延长的,我们将及时向您说明。" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "河南洪贞文化科技有限公司" + } + ] + }, + { + "type": "paragraph", + "runs": [ + { + "text": "[发布日期:____年__月__日]" + } + ] + } + ] + } +} diff --git a/src/pages/agreement/index.scss b/src/pages/agreement/index.scss index 4f582bb..72e3be9 100644 --- a/src/pages/agreement/index.scss +++ b/src/pages/agreement/index.scss @@ -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; } diff --git a/src/pages/agreement/index.tsx b/src/pages/agreement/index.tsx index 9fcb362..fea9e2b 100644 --- a/src/pages/agreement/index.tsx +++ b/src/pages/agreement/index.tsx @@ -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('user') - const [content, setContent] = useState>({ - user: sampleUser, - privacy: samplePrivacy + const [safeAreaStyle] = useState(() => { + 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 ( - + @@ -38,11 +28,28 @@ export default function AgreementPage() { - {active === 'user' ? '用户协议' : '隐私政策'} - {content[active]} - 一、协议的范围{'\n'}1.1 本协议是您与那吉之间关于使用那吉软件服务所订立的协议。所有规则均为本协议不可分割的一部分,与协议正文具有同等法律效力。 - 二、适用范围{'\n'}本政策适用于您通过本应用进行的所有操作及我们因此收集的个人信息,涵盖注册、登录、使用功能、参与互动等场景。 - 三、个人信息的收集与使用{'\n'}我们遵循合法、正当、必要原则,仅收集为实现服务功能所必需的信息。 + {document.title} + {document.blocks.map((block, index) => block.type === 'table' ? ( + + {block.rows.map((row, rowIndex) => ( + + {row.map((cell, cellIndex) => ( + + {block.headers[cellIndex]}:{cell} + + ))} + + ))} + + ) : ( + + + {block.runs.map((run, runIndex) => ( + {run.text} + ))} + + + ))} diff --git a/src/pages/my/index.scss b/src/pages/my/index.scss index 37cd219..cbad5e1 100644 --- a/src/pages/my/index.scss +++ b/src/pages/my/index.scss @@ -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; } diff --git a/src/pages/my/index.tsx b/src/pages/my/index.tsx index afaf885..810986a 100644 --- a/src/pages/my/index.tsx +++ b/src/pages/my/index.tsx @@ -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(() => { + 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('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 = () => const renderBindingDialogs = () => <> {bindingOpen && setBindingOpen(false)} />} @@ -129,7 +130,7 @@ export default function MyPage() { const renderElement = () => !elementReport ? ( - + {previewNote} @@ -140,7 +141,7 @@ export default function MyPage() { {renderBindingDialogs()} ) : ( - + {previewNote} @@ -192,7 +193,6 @@ export default function MyPage() { ? `日主${elementReport.strength}时,${elementReport.unfavorable}不宜继续偏增。可留意${traitText(elementReport.unfavorable)}失衡带来的消耗。` : '当前不划分固定偏忌,避免单一五行过度偏增。'} - 普通扶抑简化参考算法 v1,仅作传统文化娱乐参考;不含从格、化气、合冲刑害及调候等特殊判断。按北京时间公历、节气月柱与零点换日计算;时辰以起始小时代表,不作真太阳时校正。 {!unlocked && setBindingOpen(true)} />} @@ -215,7 +215,7 @@ export default function MyPage() { } return ( - + {previewNote} go('/pages/profile-edit/index')}> @@ -224,7 +224,7 @@ export default function MyPage() { {profile.nickname} - {!!genderSymbol(profile.gender) && {genderSymbol(profile.gender)}} + {!!genderSymbol(profile.gender) && {genderSymbol(profile.gender)}} @@ -281,7 +281,7 @@ export default function MyPage() { - - {zodiacRulesOpen && - {ZODIAC_WEEKLY_RULES} - 本周合计:{weeklyFortune.score > 0 ? '+' : ''}{weeklyFortune.score}。逐日与出生年支“{weeklyFortune.branch}”比较: - {weeklyFortune.days.map((item) => 0 ? 'supportive' : item.score < 0 ? 'cautious' : ''}`}> - {item.label} {item.weekday} - {item.branch}日 · {item.relation === '三合' ? '三合同组' : item.relation} - {item.score > 0 ? '+' : ''}{item.score} - )} - 三合仅取同组两支的助力关系,不代表三合成局;不纳入刑、破或完整八字。 - } 按北京时间周一至周日计算 · 合冲简化规则 v1{ '\n' }仅作传统文化娱乐参考,不作为现实决策依据 ) : ( diff --git a/src/pages/profile-edit/index.scss b/src/pages/profile-edit/index.scss index 21fb937..f9cc366 100644 --- a/src/pages/profile-edit/index.scss +++ b/src/pages/profile-edit/index.scss @@ -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; } } diff --git a/src/pages/profile-edit/index.tsx b/src/pages/profile-edit/index.tsx index e618b3f..7dc9db9 100644 --- a/src/pages/profile-edit/index.tsx +++ b/src/pages/profile-edit/index.tsx @@ -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(() => { + 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(null) @@ -194,7 +202,7 @@ export default function ProfileEditPage() { } return ( - + {loading ? ( 加载中... diff --git a/src/pages/tiangong-detail/index.scss b/src/pages/tiangong-detail/index.scss index bc4b246..16f3dde 100644 --- a/src/pages/tiangong-detail/index.scss +++ b/src/pages/tiangong-detail/index.scss @@ -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; } diff --git a/src/pages/tiangong-detail/index.tsx b/src/pages/tiangong-detail/index.tsx index 59637fd..ca3cbc0 100644 --- a/src/pages/tiangong-detail/index.tsx +++ b/src/pages/tiangong-detail/index.tsx @@ -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 该神仙暂未配置,请返回天宫选择。 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) => { 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) => {title}{subtitle} + const panelHeader = (title: string, subtitle: string) => {showImage && setFailedImage(detailImage)} />}{title}{subtitle} const dialogActions = (confirm: string, action: () => void, danger = false, disabled = false) => return @@ -69,7 +77,7 @@ function TiangongDetailContent() { } /> - + {showImage ? setFailedImage(detailImage)} /> : {imageNote}} {invited && 上香 {state.incenseCount}{ritual.pending > 0 ? ' · 提交中' : ''}} {invited && } @@ -83,9 +91,9 @@ function TiangongDetailContent() { }}>{action[0]}{action[1]})} : {deity.name} - {deity.introduction || '原型未提供这位神仙的独立介绍,待确认后补充。'} - {deity.introduction && 原型介绍与“禄星”名称不一致,文案待确认。} - {deity.missingStatue && 原型未提供独立详情神像,暂使用同一神仙的列表原图。} + {introduction} + {demo && deity.introduction && 原型介绍与“禄星”名称不一致,文案待确认。} + {demo && deity.missingStatue && 原型未提供独立详情神像,暂使用同一神仙的列表原图。} {demo && 仅演示恭请流程,不扣费、不建立真实供奉关系。} } diff --git a/src/services/customerService.ts b/src/services/customerService.ts new file mode 100644 index 0000000..83990a0 --- /dev/null +++ b/src/services/customerService.ts @@ -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 + } +} diff --git a/src/utils/tiangongMock.ts b/src/utils/tiangongMock.ts index e6db20c..25112a6 100644 --- a/src/utils/tiangongMock.ts +++ b/src/utils/tiangongMock.ts @@ -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; 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 diff --git a/src/utils/tiangongStore.ts b/src/utils/tiangongStore.ts index f11a298..6f5e575 100644 --- a/src/utils/tiangongStore.ts +++ b/src/utils/tiangongStore.ts @@ -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; 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() } })