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

+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('网络失败') })