Compare commits

2 Commits
Author SHA1 Message Date
大哥大哥的大哥哥 65755c9e96 gengxin 2026-09-09 14:47:29 +08:00
大哥大哥的大哥哥 4d9da40abd ig 2026-09-09 14:47:14 +08:00
854 changed files with 412159 additions and 45 deletions
Vendored
BIN
View File
Binary file not shown.
@@ -0,0 +1,28 @@
# 唐侦探原生页面接入
`native/tang-detective/` 是原生微信页面、数据和素材的原字节快照。原 `app.js``sitemap.json` 不导入;`app.json` 仅用作构建元数据,`app.wxss` 输出为游戏页面局部导入的 `shared.wxss`。不要直接修改快照。源文件哈希登记在 `tang-detective-source-manifest.json`
`tang-detective-native-plugin.mjs` 仅在微信构建启用。它以 `writeBundle: { order: 'post', sequential: true }` 在 uni 产物生成后完成以下工作,监听重建时同样执行:
1. 将原页面和分包输出到 `tang-detective/` 命名空间,修改本地绝对路径、动态分包根及路径白名单。
2. 给 24 个页面补齐横屏、自定义导航配置和局部共享样式。
3. 逐字节覆盖 `native-adapter/tang-detective/` 的适配文件;适配文件必须写最终输出路径,不进行第二次路径替换。
4. 将全部页面的唯一顶层 `Page({` 注册转换为相对引入根 `utils/tangPage.js` 的调用;由适配层负责账号存档初始化。适配页面保留原 `Page({` 写法。
5. 根据 `vite.config.ts` 传入的 `API_BASE_URL` 生成 CommonJS `utils/platformConfig.js`。不读取密钥和原应用入口。
6. 增量合并宿主 `app.json` 的页面、分包和预加载;不覆盖宿主 `app.js`、全局样式、工程配置和其他页面。
适配层不能替换媒体、应用入口或导入状态。`.native-import-state.json` 记录输出文件归属和哈希;只删除清单内且未经外部修改的过期文件,避免误删用户文件。它不会清理其他命名空间。
运行检查:
```sh
node --test build/tang-detective-native-plugin.test.mjs
npm run build:mp-weixin
node build/validate-tang-detective-output.mjs
```
静态测试覆盖 473 份源文件哈希、204 份媒体、24 个原生页面、120 张章节画页的实际图片选择、第一章两个播放器的包内资源、全部相对模块依赖、重复构建、宿主文件保护与适配文件保护。构建后校验脚本进一步检查当前真实产物并输出各包原始文件字节数。
静态导入不代表微信原生 WXML 编译、模拟器/真机播放、包体积验收或上传通过。原生全量内容约 28.6 MiB,接入会增加主包内容,必须保留实际体积报告。H5 构建不会包含原生页面;完整 H5 体验需要独立页面适配,不能据此声称跨端可玩。
如需刷新快照,先核对来源变化,再运行 `node build/import-tang-detective-native.mjs <源 miniprogram 目录>`。脚本遇到已存在但哈希不同的文件会停止,避免无提示覆盖已有快照;差异更新需要单独审查。
@@ -0,0 +1,30 @@
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { listFiles, sha256 } from './tang-detective-native-plugin.mjs'
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const sourceDirectory = process.argv[2]
if (!sourceDirectory) throw new Error('Usage: node build/import-tang-detective-native.mjs <source-miniprogram-directory>')
const targetDirectory = path.join(projectRoot, 'native/tang-detective')
const allowedRootFiles = new Set(['app.json', 'app.wxss'])
const allowedDirectories = /^(?:assets|data|utils|pages|package-[a-z0-9-]+)\//
const selectedFiles = listFiles(sourceDirectory).filter(relative => allowedRootFiles.has(relative) || allowedDirectories.test(relative))
if (!selectedFiles.includes('app.json') || !selectedFiles.includes('pages/home/home.js')) throw new Error('Source is not the expected native Tang Detective program')
const files = selectedFiles.map(relative => {
const bytes = fs.readFileSync(path.join(sourceDirectory, relative))
const destination = path.join(targetDirectory, relative)
const hash = sha256(bytes)
if (fs.existsSync(destination) && sha256(fs.readFileSync(destination)) !== hash) {
throw new Error(`Existing snapshot differs; refusing to overwrite: ${relative}`)
}
return { path: relative, bytes: bytes.length, sha256: hash }
})
for (const file of files) {
const destination = path.join(targetDirectory, file.path)
fs.mkdirSync(path.dirname(destination), { recursive: true })
fs.copyFileSync(path.join(sourceDirectory, file.path), destination)
}
const manifest = { version: 1, excludedRootFiles: ['app.js', 'sitemap.json'], files }
fs.writeFileSync(path.join(projectRoot, 'build/tang-detective-source-manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`)
console.log(`Imported ${files.length} byte-preserved native files; no source App or project configuration was imported.`)
@@ -0,0 +1,212 @@
import fs from 'node:fs'
import path from 'node:path'
import crypto from 'node:crypto'
import vm from 'node:vm'
import { fileURLToPath } from 'node:url'
const buildDirectory = path.dirname(fileURLToPath(import.meta.url))
export const DEFAULT_MEDIA_MANIFEST_PATH = path.join(buildDirectory, 'tang-detective-cos-manifest.json')
export const SOURCE_MANIFEST_PATH = path.join(buildDirectory, 'tang-detective-source-manifest.json')
const RUNTIME_HELPER_PATH = path.resolve(buildDirectory, '../native-adapter/tang-detective/utils/cosMedia.js')
const SHA256 = /^[a-f0-9]{64}$/
const TYPES = {
'.jpg': ['image', 'image/jpeg'], '.jpeg': ['image', 'image/jpeg'],
'.png': ['image', 'image/png'], '.webp': ['image', 'image/webp'],
'.gif': ['image', 'image/gif'], '.svg': ['image', 'image/svg+xml'], '.avif': ['image', 'image/avif'],
'.mp3': ['audio', 'audio/mpeg'], '.wav': ['audio', 'audio/wav'],
'.aac': ['audio', 'audio/aac'], '.m4a': ['audio', 'audio/mp4'], '.ogg': ['audio', 'audio/ogg'],
'.mp4': ['video', 'video/mp4'], '.webm': ['video', 'video/webm'], '.mov': ['video', 'video/quicktime'],
}
export const isMediaFile = value => Boolean(TYPES[path.extname(value).toLowerCase()])
const hash = value => crypto.createHash('sha256').update(value).digest('hex')
const json = value => `${JSON.stringify(value, null, 2)}\n`
function assert(condition, message) {
if (!condition) throw new Error(`Invalid Tang Detective COS manifest: ${message}`)
}
function safeRelative(value) {
return typeof value === 'string' && value.length > 0 && !value.startsWith('/')
&& !/[\\?#\u0000-\u0020]/.test(value)
&& value.split('/').every(part => part && part !== '.' && part !== '..')
}
function httpsUrl(value) {
try {
assert(typeof value === 'string' && !/["'`<>\\\s]/.test(value), 'URL contains unsafe literal characters')
const parsed = new URL(value)
assert(parsed.protocol === 'https:' && !parsed.username && !parsed.password
&& !parsed.search && !parsed.hash, 'URLs must use unsigned HTTPS')
return parsed
} catch (error) {
throw new Error(`Invalid Tang Detective COS manifest: invalid HTTPS URL (${error.message})`)
}
}
export function validateCosMediaManifest(mediaManifest, { sourceDirectory, sourceManifestPath = SOURCE_MANIFEST_PATH } = {}) {
assert(mediaManifest && mediaManifest.schemaVersion === 1, 'schemaVersion must be 1')
const sourceBytes = fs.readFileSync(sourceManifestPath)
assert(mediaManifest.sourceManifestSha256 === hash(sourceBytes), 'source manifest SHA-256 mismatch')
const sourceManifest = JSON.parse(sourceBytes)
const expected = new Map(sourceManifest.files.filter(file => isMediaFile(file.path)).map(file => [file.path, file]))
const destination = mediaManifest.destination
assert(destination && typeof destination.bucket === 'string' && destination.bucket.trim()
&& typeof destination.region === 'string' && destination.region.trim(), 'destination bucket and region are required')
const base = httpsUrl(destination.baseUrl)
assert(Array.isArray(mediaManifest.entries) && mediaManifest.entries.length === expected.size, 'media coverage is incomplete')
const entries = new Map()
const objects = new Map()
for (const entry of mediaManifest.entries) {
assert(entry && safeRelative(entry.sourcePath) && expected.has(entry.sourcePath), 'unknown or unsafe sourcePath')
assert(!entries.has(entry.sourcePath), `duplicate sourcePath: ${entry.sourcePath}`)
const recorded = expected.get(entry.sourcePath)
const [kind, contentType] = TYPES[path.extname(entry.sourcePath).toLowerCase()]
assert(entry.kind === kind && entry.contentType === contentType, `media type mismatch: ${entry.sourcePath}`)
assert(SHA256.test(entry.sha256) && entry.sha256 === recorded.sha256
&& Number.isSafeInteger(entry.bytes) && entry.bytes === recorded.bytes, `source metadata mismatch: ${entry.sourcePath}`)
const actual = fs.readFileSync(path.join(sourceDirectory, entry.sourcePath))
assert(actual.length === entry.bytes && hash(actual) === entry.sha256, `source bytes changed: ${entry.sourcePath}`)
assert(entry.uploaded === true && entry.remoteVerifiedSha256 === entry.sha256, `remote verification missing: ${entry.sourcePath}`)
const expectedObjectKey = `tang-detective/season-01/media-v1/${entry.sha256}${path.extname(entry.sourcePath)}`
assert(entry.objectKey === expectedObjectKey, `object key does not match immutable media contract: ${entry.sourcePath}`)
if (kind === 'audio' || kind === 'video') {
assert(entry.rangeVerified === true, `media range verification missing: ${entry.sourcePath}`)
}
const url = httpsUrl(entry.url)
const expectedUrl = `${base.href.replace(/\/$/, '')}/${entry.objectKey.split('/').map(encodeURIComponent).join('/')}`
assert(url.href === expectedUrl && entry.url === url.href, `URL does not match destination and object key: ${entry.sourcePath}`)
const identity = `${entry.sha256}:${entry.bytes}:${entry.contentType}`
assert(!objects.has(entry.url) || objects.get(entry.url) === identity, `conflicting object contents: ${entry.sourcePath}`)
objects.set(entry.url, identity)
entries.set(entry.sourcePath, Object.freeze({ ...entry }))
}
return { entries, sourceManifestSha256: mediaManifest.sourceManifestSha256,
manifestSha256: hash(json(mediaManifest)), destination: { ...destination }, objectCount: objects.size }
}
export function loadCosMediaManifest({ mediaManifest, mediaManifestPath = DEFAULT_MEDIA_MANIFEST_PATH, sourceDirectory, sourceManifestPath } = {}) {
// Explicit null is useful for a local/offline comparison without touching a
// verified manifest owned by another task. Undefined uses automatic discovery.
if (mediaManifest === null) return null
if (mediaManifest === undefined) {
if (!fs.existsSync(mediaManifestPath)) return null
mediaManifest = JSON.parse(fs.readFileSync(mediaManifestPath, 'utf8'))
}
return validateCosMediaManifest(mediaManifest, { sourceDirectory, sourceManifestPath })
}
function relativeHelper(relative, helper = 'utils/cosMedia.js') {
const result = path.posix.relative(path.posix.dirname(relative), helper)
return result.startsWith('.') ? result : `./${result}`
}
function replaceOnce(source, before, after, filename) {
assert(source.split(before).length === 2, `conversion anchor changed: ${filename}`)
return source.replace(before, after)
}
function evaluateData(source, filename) {
const context = { module: { exports: {} } }
vm.runInNewContext(source, context, { filename, timeout: 1000 })
return context.module.exports
}
export function applyCosMediaOutput(files, media, { namespace = 'tang-detective' } = {}) {
if (!media) return
const byUrl = new Map([...media.entries.values()].map(entry => [entry.url, entry]))
const lookup = value => {
if (byUrl.has(value)) return byUrl.get(value)
let relative = value.replace(/^\//, '')
if (relative.startsWith(`${namespace}/`)) relative = relative.slice(namespace.length + 1)
return media.entries.get(relative)
}
const staticPath = /(["'`])(\/(?:[a-z0-9-]+\/)?(?:assets|package-[a-z0-9-]+)\/[^"'`\n$]*\.(?:jpe?g|png|webp|gif|svg|avif|mp3|wav|aac|m4a|ogg|mp4|webm|mov))\1/gi
for (const [relative, bytes] of files) {
if (isMediaFile(relative)) { files.delete(relative); continue }
if (!/\.(?:js|json|wxml|wxss|wxs)$/.test(relative) || relative === 'utils/cosMedia.js') continue
let source = bytes.toString('utf8')
const helper = `require(${JSON.stringify(relativeHelper(relative))})`
if (relative.endsWith('/data/releaseAssetManifest.js')) {
const exported = evaluateData(source, relative)
for (const asset of Object.values(exported.releaseAssets)) {
if (!asset.localSeed) continue
const entry = lookup(asset.localSeed)
assert(entry && entry.sha256 === asset.sha256 && entry.kind === asset.kind, `release asset mismatch: ${relative}`)
asset.localSeed = ''
asset.remoteUrl = entry.url
}
const share = media.entries.get('assets/share/guixiang-story-share-preview-v1.jpg')
assert(share, 'share preview entry is missing')
exported.releaseAssets['image.tang.share-preview'] = {
kind: 'image', localSeed: '', remoteUrl: share.url,
remotePath: share.objectKey, sha256: share.sha256,
}
files.set(relative, Buffer.from(`module.exports = ${json(exported)}`))
continue
}
source = source.replace(staticPath, (match, quote, value) => {
const entry = lookup(value)
assert(entry, `unregistered static media: ${relative}: ${value}`)
return `${quote}${entry.url}${quote}`
})
if (relative.endsWith('/pages/chapter/chapterPages.js')) {
source = replaceOnce(source, ' pageSequence = attachPlayableVisuals(pageSequence, chapter)',
` pageSequence = ${helper}.mapMedia(pageSequence)\n pageSequence = attachPlayableVisuals(pageSequence, chapter)`, relative)
}
if (relative.endsWith('/data/playableVisualPolicy.js')) {
const previous = ' && clean(releaseAsset.localSeed)\n && clean(releaseAsset.localSeed) === clean(page.illustrationAsset),'
source = replaceOnce(source, previous,
` && ${helper}.verifiedUrl(releaseAsset.remoteUrl, releaseAsset.sha256, 'image')\n && clean(releaseAsset.remoteUrl) === clean(page.illustrationAsset),`, relative)
}
if (relative.endsWith('/utils/comicPageModel.js')) {
source = replaceOnce(source, ' if (localSeed) {\n return {\n src: localSeed,',
` const remoteUrl = releaseAsset && ${helper}.verifiedUrl(\n releaseAsset.remoteUrl, releaseAsset.sha256, 'image',\n )\n if (localSeed || remoteUrl) {\n return {\n src: localSeed || remoteUrl,`, relative)
source = replaceOnce(source, " source: 'local-seed',", " source: localSeed ? 'local-seed' : 'remote-url',", relative)
// Keep getReviewedAudioSrc and isPackagedPath fail-closed. Approved
// remote full-page audio uses the existing asynchronous verified player.
}
if (relative.endsWith('/utils/assetManager.js')) {
source = replaceOnce(source, ' if (!cdnBaseUrl || !asset.remotePath) {',
` const remoteUrl = asset.remoteUrl\n ? ${helper}.verifiedUrl(asset.remoteUrl, asset.sha256, asset.kind) : ''\n if (asset.remoteUrl && !remoteUrl) return fallback(assetId, 'remote-integrity-failed')\n if (!remoteUrl && (!cdnBaseUrl || !asset.remotePath)) {`, relative)
source = replaceOnce(source, 'download(assetPlatform, joinPath(cdnBaseUrl, asset.remotePath))',
'download(assetPlatform, remoteUrl || joinPath(cdnBaseUrl, asset.remotePath))', relative)
}
if (relative.endsWith('/pages/chapter/chapter.js')) {
const start = source.indexOf(' prepareSharePreview() {')
const end = source.indexOf(' loadChapter(', start)
assert(start !== -1 && end > start, `share conversion anchor changed: ${relative}`)
source = source.slice(0, start)
+ ` prepareSharePreview() {\n return ${helper}.prepareSharePreview(this, this.getAssetManager())\n },\n\n`
+ source.slice(end)
source = replaceOnce(source, " const localPath = String(this.data.sharePreviewLocalPath || '').trim()",
" const localPath = '' // Cached share bytes are revalidated in prepareSharePreview.", relative)
source = replaceOnce(source, ' if (!pageData.currentPage) return',
` if (!pageData.currentPage) return\n ${helper}.beginComicImage(this, pageData)`, relative)
source = replaceOnce(source, ' onComicImageLoad() {',
` onComicImageLoad(event) {\n if (!${helper}.isCurrentComicImageEvent(this, event)) return`, relative)
const errorStart = source.indexOf(' onComicImageError() {')
const errorEnd = source.indexOf(' applyLayoutMetrics(', errorStart)
assert(errorStart !== -1 && errorEnd > errorStart, `image error conversion anchor changed: ${relative}`)
let handler = source.slice(errorStart, errorEnd)
handler = replaceOnce(handler, ' onComicImageError() {',
` onComicImageError(event) {\n if (!${helper}.recordComicImageError(this, event)) return`, relative)
handler = replaceOnce(handler, ' actorFallback\n',
` actorFallback\n && ${helper}.canUseComicFallback(this, actorFallback)\n`, relative)
handler = replaceOnce(handler, ' fallback\n',
` fallback\n && ${helper}.canUseComicFallback(this, fallback)\n`, relative)
handler = handler.replaceAll('this.setData({', `${helper}.applyComicImageFallback(this, {`)
source = source.slice(0, errorStart) + handler + source.slice(errorEnd)
}
if (relative.endsWith('/pages/chapter/chapter.wxml')) {
assert(source.includes('binderror="onComicImageError"'), `image event conversion anchor changed: ${relative}`)
source = source.replaceAll('binderror="onComicImageError"',
'data-cos-page-id="{{currentPageId}}" data-cos-image-src="{{comicImageSrc}}" data-cos-image-generation="{{comicImageGeneration}}" binderror="onComicImageError"')
}
files.set(relative, Buffer.from(source))
}
files.set('utils/cosMedia.js', fs.readFileSync(RUNTIME_HELPER_PATH))
files.set('utils/cosMediaManifest.js', Buffer.from(`module.exports = ${json({
namespace,
entries: [...media.entries.values()].map(({ sourcePath, kind, bytes, sha256, url }) => ({ sourcePath, kind, bytes, sha256, url })),
})}`))
}
@@ -0,0 +1,522 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import vm from 'node:vm'
import { createRequire } from 'node:module'
import { fileURLToPath } from 'node:url'
import { copyNativeProgram, listFiles, sha256 } from './tang-detective-native-plugin.mjs'
import { isMediaFile, loadCosMediaManifest, validateCosMediaManifest, SOURCE_MANIFEST_PATH } from './tang-detective-cos-media.mjs'
import { validateNativeOutput } from './validate-tang-detective-output.mjs'
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const sourceDirectory = path.join(projectRoot, 'native/tang-detective')
const overlayDirectory = path.join(projectRoot, 'native-adapter/tang-detective')
const sourceBytes = fs.readFileSync(SOURCE_MANIFEST_PATH)
const sourceManifest = JSON.parse(sourceBytes)
const sourceRequire = createRequire(path.join(projectRoot, 'source-cos-test.cjs'))
const sourceMedia = sourceManifest.files.filter(file => isMediaFile(file.path))
function write(directory, relative, value) {
const filename = path.join(directory, relative)
fs.mkdirSync(path.dirname(filename), { recursive: true })
fs.writeFileSync(filename, value)
}
function fakeVerifiedManifest() {
// Deliberately test-only, reserved .test domain. This is never written to the
// real manifest location and is not evidence of any external upload.
const baseUrl = 'https://tang-assets.example.test'
return {
schemaVersion: 1, sourceManifestSha256: sha256(sourceBytes),
destination: { bucket: 'test-bucket', region: 'test-region', baseUrl },
entries: sourceMedia.map(file => {
const kind = file.path.endsWith('.jpg') ? 'image' : 'audio'
const objectKey = `tang-detective/season-01/media-v1/${file.sha256}${path.extname(file.path)}`
return { sourcePath: file.path, kind, contentType: kind === 'image' ? 'image/jpeg' : 'audio/mpeg',
bytes: file.bytes, sha256: file.sha256, objectKey, url: `${baseUrl}/${objectKey}`,
uploaded: true, remoteVerifiedSha256: file.sha256, ...(kind === 'audio' ? { rangeVerified: true } : {}) }
}),
}
}
function fixture(t, { remote = true } = {}) {
const temporary = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'tang-cos-unit-')))
// Every path removed below was created exclusively by this test.
t.after(() => fs.rmSync(temporary, { recursive: true, force: true }))
const outputDirectory = path.join(temporary, 'output')
write(outputDirectory, 'app.json', JSON.stringify({ pages: ['pages/index/index'], window: { navigationStyle: 'default' } }))
write(outputDirectory, 'app.js', '/* retained host entry */')
const mediaManifest = remote ? fakeVerifiedManifest() : null
const options = { sourceDirectory, overlayDirectory, outputDirectory, mediaManifest }
const report = copyNativeProgram(options)
const root = path.join(outputDirectory, 'tang-detective')
const localRequire = createRequire(path.join(outputDirectory, 'test.cjs'))
return { temporary, root, options, report, outputDirectory, mediaManifest,
require: relative => localRequire(path.join(root, relative)) }
}
function mockPlatform(responses) {
const files = new Map()
const downloads = []
let sequence = 0
let failDownloads = false
const buffer = value => typeof value === 'string' ? Buffer.from(value) : Buffer.from(value)
const fsApi = {
mkdir: options => options.success({}),
access: options => files.has(options.path) ? options.success({}) : options.fail(new Error('missing')),
readFile(options) {
if (!files.has(options.filePath)) { options.fail(new Error('missing')); return }
const data = files.get(options.filePath)
options.success({ data: options.encoding === 'utf8' ? buffer(data).toString('utf8') : buffer(data) })
},
writeFile(options) { files.set(options.filePath, buffer(options.data)); options.success({}) },
saveFile(options) {
if (!files.has(options.tempFilePath)) { options.fail(new Error('missing')); return }
files.set(options.filePath, files.get(options.tempFilePath))
files.delete(options.tempFilePath)
options.success({ savedFilePath: options.filePath })
},
stat(options) { options.success({ stats: { size: buffer(files.get(options.path)).length } }) },
unlink(options) { files.delete(options.filePath); options.success({}) },
}
const platform = {
env: { USER_DATA_PATH: '/test-owned-user-data' },
getFileSystemManager: () => fsApi,
downloadFile(options) {
downloads.push(options.url)
if (failDownloads || !responses.has(options.url)) { options.fail(new Error('network unavailable')); return }
const body = responses.get(options.url)
const tempFilePath = `/test-owned-temp/${++sequence}`
files.set(tempFilePath, Buffer.from(body))
options.success({ statusCode: 200, tempFilePath, fileSize: body.length })
},
}
return { platform, files, downloads, failDownloads: value => { failDownloads = value } }
}
function managerFixture(context) {
const { releaseAssets } = context.require('package-game/data/releaseAssetManifest.js')
const managerApi = context.require('package-game/utils/assetManager.js')
const responses = new Map(context.mediaManifest.entries.map(entry => [entry.url, fs.readFileSync(path.join(sourceDirectory, entry.sourcePath))]))
const mock = mockPlatform(responses)
const manager = managerApi.createAssetManager({ manifest: releaseAssets, assetPlatform: mock.platform, cdnBaseUrl: '' })
return { ...mock, manager, managerApi, releaseAssets, responses }
}
test('verified manifest requires all 204 original paths, hashes, exact HTTPS objects and upload receipts', () => {
const media = validateCosMediaManifest(fakeVerifiedManifest(), { sourceDirectory })
assert.equal(media.entries.size, 204)
assert.equal(media.objectCount, 180)
const mutations = [
manifest => { manifest.entries.pop() },
manifest => { manifest.entries[1] = { ...manifest.entries[0] } },
manifest => { manifest.sourceManifestSha256 = '0'.repeat(64) },
manifest => { manifest.entries[0].sha256 = '0'.repeat(64) },
manifest => { manifest.entries[0].bytes += 1 },
manifest => { manifest.entries[0].remoteVerifiedSha256 = '0'.repeat(64) },
manifest => { manifest.entries[0].uploaded = false },
manifest => { manifest.entries[0].url = manifest.entries[0].url.replace('https:', 'http:') },
manifest => { manifest.entries[0].url += '?signature=not-allowed' },
manifest => { manifest.entries[0].url = 'https://other.example.test/file.jpg' },
manifest => { manifest.destination.baseUrl = "https://tang-assets.example.test/quote'" },
manifest => { manifest.entries[0].sourcePath = '../outside.jpg' },
manifest => { manifest.entries[0].objectKey = 'mutable-name.jpg' },
manifest => {
const entry = manifest.entries[0]
entry.objectKey = `wrong-prefix/${entry.sha256}${path.extname(entry.sourcePath)}`
entry.url = `${manifest.destination.baseUrl}/${entry.objectKey}`
},
manifest => {
const entry = manifest.entries[0]
entry.objectKey = `tang-detective/season-01/media-v1/${entry.sha256.slice(0, 12)}${path.extname(entry.sourcePath)}`
entry.url = `${manifest.destination.baseUrl}/${entry.objectKey}`
},
manifest => { delete manifest.entries.find(entry => entry.kind === 'audio').rangeVerified },
manifest => { manifest.entries.find(entry => entry.kind === 'audio').rangeVerified = false },
manifest => { manifest.entries[0].contentType = 'text/html' },
]
for (const mutate of mutations) {
const manifest = fakeVerifiedManifest()
mutate(manifest)
assert.throws(() => validateCosMediaManifest(manifest, { sourceDirectory }), /Invalid Tang Detective COS manifest/)
}
})
test('video manifest also requires a true range receipt and its exact immutable object key', t => {
const context = fixture(t)
const source = path.join(context.temporary, 'video-source')
const sourcePath = 'assets/video/test.mp4'
const bytes = Buffer.from('offline transport contract fixture')
write(source, sourcePath, bytes)
const record = { path: sourcePath, bytes: bytes.length, sha256: sha256(bytes) }
const smallSource = JSON.stringify({ files: [record] })
write(context.temporary, 'video-source-manifest.json', smallSource)
const sourceManifestPath = path.join(context.temporary, 'video-source-manifest.json')
const objectKey = `tang-detective/season-01/media-v1/${record.sha256}.mp4`
const manifest = { ...context.mediaManifest, sourceManifestSha256: sha256(smallSource), entries: [{
sourcePath, kind: 'video', contentType: 'video/mp4', bytes: record.bytes, sha256: record.sha256,
objectKey, url: `${context.mediaManifest.destination.baseUrl}/${objectKey}`,
uploaded: true, remoteVerifiedSha256: record.sha256, rangeVerified: true,
}] }
assert.equal(validateCosMediaManifest(manifest, { sourceDirectory: source, sourceManifestPath }).entries.size, 1)
for (const value of [undefined, false, 'true']) {
manifest.entries[0].rangeVerified = value
assert.throws(() => validateCosMediaManifest(manifest, { sourceDirectory: source, sourceManifestPath }), /range verification missing/)
}
})
test('source byte drift fails even when a manifest still claims the expected hash', t => {
const context = fixture(t)
const source = path.join(context.temporary, 'changed-source')
const original = context.mediaManifest.entries[0]
write(source, original.sourcePath, 'changed source bytes')
const smallSource = JSON.stringify({ files: [{ path: original.sourcePath, bytes: original.bytes, sha256: original.sha256 }] })
write(context.temporary, 'small-source-manifest.json', smallSource)
const manifest = { ...context.mediaManifest, sourceManifestSha256: sha256(smallSource), entries: [original] }
assert.throws(() => validateCosMediaManifest(manifest, { sourceDirectory: source,
sourceManifestPath: path.join(context.temporary, 'small-source-manifest.json') }), /source bytes changed/)
})
test('missing manifest stays local; a verified remote import removes only owned output media and remains deterministic', t => {
const context = fixture(t, { remote: false })
const missing = path.join(context.temporary, 'not-uploaded.json')
assert.equal(loadCosMediaManifest({ sourceDirectory, mediaManifestPath: missing }), null)
assert.equal(copyNativeProgram({ ...context.options, mediaManifest: undefined, mediaManifestPath: missing }).sizes.mediaFileCount, 204)
assert.equal(context.report.sizes.mediaFileCount, 204)
assert.equal(fs.existsSync(path.join(context.root, 'utils/cosMedia.js')), false)
const manifest = fakeVerifiedManifest()
write(context.temporary, 'verified-test-only.json', JSON.stringify(manifest))
const options = { ...context.options, mediaManifest: undefined, mediaManifestPath: path.join(context.temporary, 'verified-test-only.json') }
const report = copyNativeProgram(options)
assert.equal(report.sizes.mediaFileCount, 0)
assert.equal(report.media.sourceMediaFiles, 204)
assert.equal(listFiles(context.root).filter(isMediaFile).length, 0)
const state = fs.readFileSync(path.join(context.root, '.native-import-state.json'), 'utf8')
copyNativeProgram(options)
assert.equal(fs.readFileSync(path.join(context.root, '.native-import-state.json'), 'utf8'), state)
assert.equal(fs.readFileSync(path.join(context.outputDirectory, 'app.js'), 'utf8'), '/* retained host entry */')
for (const file of sourceManifest.files) {
assert.equal(sha256(fs.readFileSync(path.join(sourceDirectory, file.path))), file.sha256, file.path)
}
for (const file of ['platformCore.js', 'storage.js', 'tangPage.js']) {
assert.deepEqual(fs.readFileSync(path.join(context.root, 'utils', file)), fs.readFileSync(path.join(overlayDirectory, 'utils', file)), file)
}
})
test('invalid manifest and externally edited old media are rejected before output mutation', t => {
const context = fixture(t, { remote: false })
const before = fs.readFileSync(path.join(context.root, '.native-import-state.json'))
const invalid = fakeVerifiedManifest()
invalid.entries.pop()
assert.throws(() => copyNativeProgram({ ...context.options, mediaManifest: invalid }), /media coverage/)
assert.deepEqual(fs.readFileSync(path.join(context.root, '.native-import-state.json')), before)
const edited = sourceMedia.at(-1).path
write(context.root, edited, 'external user edit')
assert.throws(() => copyNativeProgram({ ...context.options, mediaManifest: fakeVerifiedManifest() }), /Stale native output has external edits/)
assert.equal(fs.readFileSync(path.join(context.root, edited), 'utf8'), 'external user edit')
assert.equal(sha256(fs.readFileSync(path.join(context.root, sourceMedia[0].path))), sourceMedia[0].sha256)
})
test('all 120 selected comic pages retain source content hashes and 112 formal / 8 provisional status', t => {
const context = fixture(t)
const season = context.require('data/season.js')
const originalSeason = sourceRequire(path.join(sourceDirectory, 'data/season.js'))
const originalPages = sourceRequire(path.join(sourceDirectory, 'package-game/pages/chapter/chapterPages.js'))
const media = context.require('utils/cosMedia.js')
const counts = { pages: 0, formal: 0, provisional: 0 }
for (let number = 1; number <= 15; number++) {
const packageRoot = number === 1 ? 'package-game' : `package-chapter-${String(number).padStart(2, '0')}`
const { buildComicPageModel } = context.require(`${packageRoot}/pages/chapter/chapterPages.js`)
const comic = context.require(`${packageRoot}/utils/comicPageModel.js`)
const { releaseAssets } = context.require(`${packageRoot}/data/releaseAssetManifest.js`)
const chapter = season.chapters[number - 1]
const original = originalPages.buildComicPageModel(originalSeason.chapters[number - 1], number)
const model = buildComicPageModel(chapter, number)
assert.equal(model.pageSequence.length, 8)
for (const [index, page] of model.pageSequence.entries()) {
const sourcePage = original.pageSequence[index]
const expected = sha256(fs.readFileSync(path.join(sourceDirectory, sourcePage.illustrationAsset)))
const assetId = `comic.${page.pageId.toLowerCase().replaceAll('-', '.')}`
const asset = releaseAssets[assetId]
const image = comic.buildComicImageState(page, asset, {})
assert.equal(media.entryFor(image.src).sha256, expected, page.pageId)
assert.equal(image.src, page.illustrationAsset, page.pageId)
assert.equal(asset.remoteUrl, image.src)
assert.equal(asset.localSeed, '')
assert.match(image.src, /^https:\/\//)
assert.equal(image.source, 'remote-url')
assert.equal(page.playableVisual.formalReleaseEligible, sourcePage.playableVisual.formalReleaseEligible)
assert.equal(page.playableVisual.reviewStatus, sourcePage.playableVisual.reviewStatus)
assert.equal(media.entryFor(image.fallback).kind, 'image')
assert.equal(comic.isPackagedPath(image.src), false)
counts.pages++
if (page.playableVisual.formalReleaseEligible) counts.formal++
if (page.playableVisual.runtimeTier === 'experience-provisional') counts.provisional++
}
}
assert.deepEqual(counts, { pages: 120, formal: 112, provisional: 8 })
assert.equal(media.resolve('/tang-detective/assets/unknown.jpg'), '')
assert.equal(media.mapMedia({ image: '/tang-detective/assets/unknown.jpg' }).image, '')
})
test('source and adapter static images, cast, C01 player art and all eight listening tracks use exact registry URLs', t => {
const context = fixture(t)
const media = context.require('utils/cosMedia.js')
const cover = media.resolve('/assets/comic/s01-c01/S01-C01-P02-missing-table-ensemble-v1.jpg')
assert.ok(fs.readFileSync(path.join(context.root, 'pages/home/home.wxml'), 'utf8').includes(`src="${cover}"`))
for (const person of context.require('data/cast.js')) assert.equal(media.entryFor(person.asset).kind, 'image')
let count = 0
for (const packageRoot of ['package-audio-c01-a', 'package-audio-c01-b']) {
const pages = context.require(`${packageRoot}/data/audioPages.js`)
let player
vm.runInNewContext(fs.readFileSync(path.join(context.root, packageRoot, 'pages/player/player.js'), 'utf8'), {
require(specifier) {
if (specifier.endsWith('/utils/tangPage.js')) return definition => { player = definition }
if (specifier === '../../data/audioPages') return pages
throw new Error(`Unexpected player dependency: ${specifier}`)
},
})
assert.equal(player.data.reviewStatus, 'technical-qa-pass-human-listening-pending')
for (const page of Object.values(pages)) {
assert.equal(page.reviewStatus, 'technical-qa-pass-human-listening-pending')
assert.equal(media.entryFor(page.audioSrc).sha256, page.sha256)
assert.equal(media.entryFor(page.imageSrc).kind, 'image')
let prepared = ''
player.onReady.call({ _page: page, _unloaded: false, createAudioContext: src => { prepared = src } })
assert.equal(prepared, page.audioSrc)
count++
}
}
assert.equal(count, 8)
const sharePage = fs.readFileSync(path.join(context.root, 'pages/share/share.js'), 'utf8')
assert.ok(sharePage.includes(media.resolve('/assets/share/guixiang-story-share-preview-v1.jpg')))
})
test('remote transport does not open unreviewed audio, promote reserved cues or accept arbitrary HTTPS as approved', async t => {
const context = fixture(t)
const { manager, downloads, releaseAssets } = managerFixture(context)
const pages = context.require('package-game/data/remotePageAudioManifest.js')
const playerPages = context.require('package-audio-player/data/remotePageAudioManifest.js')
const comic = context.require('package-game/utils/comicPageModel.js')
assert.deepEqual(pages.remotePageAudioPages, {})
assert.deepEqual(playerPages.remotePageAudioPages, {})
for (let chapter = 2; chapter <= 15; chapter++) {
for (let page = 1; page <= 8; page++) {
const id = `S01-C${String(chapter).padStart(2, '0')}-P${String(page).padStart(2, '0')}`
assert.equal(pages.getApprovedRemotePageAudio(id, releaseAssets), null)
}
}
for (const id of ['audio.cast-audition.v1', 'audio.s01.c02.s01-c02-ms001', 'audio.s01.c03.s01-c03-ms001']) {
assert.equal((await manager.resolve(id)).reason, 'audio-unapproved')
}
const approvedId = 'audio.s01.c01.s01-c01-ms007'
assert.equal(releaseAssets[approvedId].reviewStatus, 'approved')
assert.equal(comic.getReviewedAudioSrc({ status: 'approved', assetId: approvedId, src: releaseAssets[approvedId].remoteUrl }, releaseAssets), '')
assert.equal(comic.getReviewedAudioSrc({ status: 'approved', assetId: approvedId, src: 'https://unknown.example.test/file.mp3' }, releaseAssets), '')
assert.equal(downloads.length, 0)
})
test('asset manager downloads exact URLs with an empty CDN base, verifies downloaded/cache bytes, and rejects substituted URLs', async t => {
const context = fixture(t)
const { manager, downloads, files, releaseAssets, managerApi, platform, responses } = managerFixture(context)
const id = 'comic.s01.c01.p01'
const first = await manager.resolve(id)
assert.equal(first.available, true)
assert.equal(first.persistent, true)
assert.equal(downloads[0], releaseAssets[id].remoteUrl)
assert.equal(sha256(files.get(first.uri)), releaseAssets[id].sha256)
assert.equal((await manager.resolve(id)).source, 'cache')
assert.equal(downloads.length, 1)
files.set(first.uri, Buffer.from('tampered cache'))
assert.equal((await manager.resolve(id)).reason, 'remote-integrity-failed')
assert.equal(files.has(first.uri), false)
const second = await manager.resolve(id)
assert.equal(second.available, true)
assert.equal(downloads.length, 2)
const replaced = { ...releaseAssets[id], remoteUrl: 'https://unknown.example.test/bad.jpg' }
const blocked = managerApi.createAssetManager({ manifest: { unregistered: replaced }, assetPlatform: platform })
assert.equal((await blocked.resolve('unregistered')).reason, 'remote-integrity-failed')
assert.equal(downloads.length, 2)
const badId = 'comic.s01.c01.p03'
responses.set(releaseAssets[badId].remoteUrl, Buffer.from('corrupt download'))
assert.equal((await manager.resolve(badId)).reason, 'remote-integrity-failed')
assert.equal([...files.keys()].some(name => name.startsWith('/test-owned-temp/')), false)
})
test('share preview rehashes cache, fails safely on corruption/network errors, and ignores completion after unload', async t => {
const context = fixture(t)
const { manager, files, downloads, failDownloads } = managerFixture(context)
const media = context.require('utils/cosMedia.js')
const page = { data: {}, __tangShowGeneration: 1, __tangVisible: true,
setData(update) { Object.assign(this.data, update) } }
const first = await media.prepareSharePreview(page, manager)
assert.match(first, /^\/test-owned-user-data\//)
assert.equal(page.data.sharePreviewLocalPath, first)
const cached = await media.prepareSharePreview(page, manager)
assert.equal(cached, first)
assert.equal(downloads.length, 1)
files.set(first, Buffer.from('corrupted thumbnail'))
assert.equal(await media.prepareSharePreview(page, manager), '')
assert.equal(page.data.sharePreviewLocalPath, '')
failDownloads(true)
assert.equal(await media.prepareSharePreview(page, manager), '')
failDownloads(false)
assert.ok(await media.prepareSharePreview(page, manager))
let finish
const delayed = { resolve: () => new Promise(resolve => { finish = resolve }) }
const pending = media.prepareSharePreview(page, delayed)
await Promise.resolve()
page.__tangDead = true
const prior = page.data.sharePreviewLocalPath
finish({ available: true, uri: '/should-not-be-shared' })
assert.equal(await pending, '')
assert.equal(page.data.sharePreviewLocalPath, prior)
const chapterScript = fs.readFileSync(path.join(context.root, 'package-game/pages/chapter/chapter.js'), 'utf8')
assert.ok(chapterScript.includes('.prepareSharePreview(this, this.getAssetManager())'))
assert.ok(chapterScript.includes("const localPath = '' // Cached share bytes are revalidated"))
assert.ok(!chapterScript.includes('filePath: SHARE_PREVIEW_IMAGE'))
})
test('real-output static validator supports COS mode and rejects extra packaged media', t => {
const context = fixture(t)
const report = validateNativeOutput(context.outputDirectory, { mediaManifest: context.mediaManifest })
assert.deepEqual(report.errors, [])
assert.equal(report.passed, true)
assert.equal(report.mediaFiles, 204)
assert.equal(report.packagedMediaFiles, 0)
assert.equal(report.mediaMode, 'cos')
write(context.root, 'assets/unowned-video.mp4', 'unexpected packaged video')
const invalid = validateNativeOutput(context.outputDirectory, { mediaManifest: context.mediaManifest })
assert.equal(invalid.passed, false)
assert.ok(invalid.errors.includes('Unexpected native packaged media in COS mode'))
})
test('actual converted chapter image handler exhausts fallbacks and ignores duplicate/late events across page visits', t => {
const context = fixture(t)
let definition
const modules = new Map()
function load(filename) {
if (!path.extname(filename)) filename += '.js'
if (filename.endsWith('/utils/tangPage.js')) return options => { definition = options }
if (filename.endsWith('/utils/storage.js')) return {}
if (modules.has(filename)) return modules.get(filename).exports
const module = { exports: {} }
modules.set(filename, module)
vm.runInNewContext(fs.readFileSync(filename, 'utf8'), { module,
require: specifier => load(path.resolve(path.dirname(filename), specifier)),
}, { filename })
return module.exports
}
load(path.join(context.root, 'package-game/pages/chapter/chapter.js'))
const chapter = context.require('data/season.js').chapters[0]
const model = context.require('package-game/pages/chapter/chapterPages.js').buildComicPageModel(chapter, 1)
let updates = 0
const page = { ...definition, data: { ...definition.data, chapter, chapterNumber: 1 }, _comicModel: model,
setData(patch) { updates++; Object.assign(this.data, patch) } }
const reader = index => ({ valid: true, currentPageIndex: index,
currentPageId: model.pageSequence[index].pageId, completedEventIds: [], chapterFinished: false })
const event = () => ({ currentTarget: { dataset: {
cosPageId: page.data.currentPageId, cosImageSrc: page.data.comicImageSrc,
cosImageGeneration: page.data.comicImageGeneration,
} } })
page.applyComicReaderState(reader(2), false)
assert.ok(page.data.comicImageActorFallback)
assert.ok(page.data.comicImageFallback)
const originalImage = page.data.comicImageSrc
const originalPage = JSON.stringify(page.data.currentPage)
const interactionEnabled = page.data.currentInteractionEnabled
const firstEvent = event()
page.onComicImageError(firstEvent)
assert.equal(page.data.comicImageSource, 'actor-fallback')
assert.equal(page.data.comicImageSrc, page.data.comicImageActorFallback)
const actorUpdates = updates
page.onComicImageError(firstEvent)
assert.equal(updates, actorUpdates, 'duplicate primary failure must not skip the actor fallback')
const actorEvent = event()
page.onComicImageError(actorEvent)
assert.equal(page.data.comicImageSrc, page.data.comicImageFallback)
const sceneEvent = event()
page.onComicImageError(sceneEvent)
assert.equal(page.data.comicImageSrc, '')
assert.equal(page.data.comicImageSource, 'text-fallback')
assert.ok(page.data.comicImageError)
assert.equal(JSON.stringify(page.data.currentPage), originalPage)
assert.equal(page.data.currentInteractionEnabled, interactionEnabled)
const terminalUpdates = updates
for (let index = 0; index < 10; index++) page.onComicImageError(event())
page.onComicImageLoad(sceneEvent)
assert.equal(updates, terminalUpdates, 'late events must leave terminal text intact')
page.applyComicReaderState(reader(2), false)
assert.equal(page.data.comicImageSrc, '', 'same-page interactions must not revive a failed URL')
page.applyComicReaderState(reader(3), false)
const nextImage = page.data.comicImageSrc
assert.ok(nextImage)
page.onComicImageError(firstEvent)
assert.equal(page.data.comicImageSrc, nextImage, 'previous-page event must be ignored')
page.applyComicReaderState(reader(2), false)
assert.equal(page.data.comicImageSrc, originalImage, 'revisiting a page starts a new attempt')
page.onComicImageError(firstEvent)
assert.equal(page.data.comicImageSrc, originalImage, 'same page ID from an earlier visit must be ignored')
page.onComicImageError(event())
assert.equal(page.data.comicImageSrc, page.data.comicImageActorFallback)
const template = fs.readFileSync(path.join(context.root, 'package-game/pages/chapter/chapter.wxml'), 'utf8')
assert.equal((template.match(/data-cos-image-generation=/g) || []).length,
(template.match(/binderror="onComicImageError"/g) || []).length)
})
test('output preflight rejects root, file, directory and stale-media symlinks without touching identical external targets', t => {
const context = fixture(t, { remote: false })
const snapshots = target => {
if (fs.lstatSync(target).isDirectory()) {
return listFiles(target).map(relative => [relative, sha256(fs.readFileSync(path.join(target, relative)))])
}
return sha256(fs.readFileSync(target))
}
const scenarios = [
{ target: context.outputDirectory },
{ target: context.root },
{ target: path.join(context.outputDirectory, 'app.json') },
{ target: path.join(context.root, '.native-import-state.json') },
{ target: path.join(context.root, 'utils/storage.js') },
{ target: path.join(context.root, 'utils') },
{ target: path.join(context.root, 'assets/scenes'), remote: true },
{ target: path.join(context.root, sourceMedia.at(-1).path), remote: true },
{ target: path.join(context.root, sourceMedia.at(-1).path), remote: true, dangling: true },
]
for (const [index, scenario] of scenarios.entries()) {
const external = path.join(context.temporary, `external-preserved-${index}`)
fs.renameSync(scenario.target, external)
const before = snapshots(external)
fs.symlinkSync(scenario.dangling ? `${external}-missing` : external, scenario.target)
try {
assert.throws(() => copyNativeProgram({ ...context.options,
mediaManifest: scenario.remote ? fakeVerifiedManifest() : null }), /Native output forbids symbolic links/)
assert.deepEqual(snapshots(external), before, scenario.target)
} finally {
fs.unlinkSync(scenario.target)
fs.renameSync(external, scenario.target)
}
// Even a cleanup conflict near the end must leave earlier media untouched.
assert.equal(sha256(fs.readFileSync(path.join(context.root, sourceMedia[0].path))), sourceMedia[0].sha256)
}
const externalNewFile = path.join(context.temporary, 'same-content-new-helper.js')
const newHelper = path.join(context.root, 'utils/cosMedia.js')
fs.copyFileSync(path.join(overlayDirectory, 'utils/cosMedia.js'), externalNewFile)
fs.symlinkSync(externalNewFile, newHelper)
const newHash = sha256(fs.readFileSync(externalNewFile))
try {
assert.throws(() => copyNativeProgram({ ...context.options, mediaManifest: fakeVerifiedManifest() }), /Native output forbids symbolic links/)
assert.equal(sha256(fs.readFileSync(externalNewFile)), newHash)
} finally { fs.unlinkSync(newHelper) }
const alias = path.join(context.temporary, 'ancestor-alias')
fs.symlinkSync(context.temporary, alias, 'dir')
assert.equal(copyNativeProgram({ ...context.options, outputDirectory: path.join(alias, 'output') }).sizes.mediaFileCount, 204)
const local = validateNativeOutput(context.outputDirectory, { mediaManifest: null })
assert.deepEqual(local.errors, [])
assert.equal(local.passed, true)
assert.equal(local.mediaMode, 'local')
assert.equal(local.packagedMediaFiles, 204)
assert.ok(local.relativeDependencies > 300)
})
@@ -0,0 +1,376 @@
import fs from 'node:fs'
import path from 'node:path'
import crypto from 'node:crypto'
import { applyCosMediaOutput, DEFAULT_MEDIA_MANIFEST_PATH, isMediaFile, loadCosMediaManifest, SOURCE_MANIFEST_PATH } from './tang-detective-cos-media.mjs'
export const NAMESPACE = 'tang-detective'
const TEXT_EXTENSIONS = new Set(['.js', '.json', '.wxml', '.wxss', '.wxs'])
const PAGE_WINDOW_KEYS = ['navigationStyle', 'pageOrientation', 'backgroundColor', 'backgroundTextStyle']
const STATE_FILE = '.native-import-state.json'
const BOOT_MASK_WXML = '<view wx:if="{{tangBootPending}}" class="tang-boot-mask" catchtap="tangIgnoreBootTap" catchtouchmove="tangIgnoreBootTap">正在读取阅读存档…</view>'
const BOOT_MASK_WXSS = `
.tang-boot-mask {
position: fixed;
inset: 0;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 2147483647;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
padding: 24px;
background: #201711;
color: #f3e5bd;
font-size: 18px;
text-align: center;
}
`
export function sha256(value) {
return crypto.createHash('sha256').update(value).digest('hex')
}
export function listFiles(directory) {
if (!fs.existsSync(directory)) return []
const result = []
function visit(current, prefix = '') {
for (const entry of fs.readdirSync(current, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
const relative = prefix ? `${prefix}/${entry.name}` : entry.name
if (entry.isSymbolicLink()) throw new Error(`Native import does not follow symbolic links: ${relative}`)
if (entry.isDirectory()) visit(path.join(current, entry.name), relative)
else if (entry.isFile()) result.push(relative)
}
}
visit(directory)
return result
}
function assertNamespace(namespace) {
if (!/^[a-z][a-z0-9-]*$/.test(namespace)) throw new Error(`Invalid native namespace: ${namespace}`)
}
function outputPath(directory, relative) {
if (typeof relative !== 'string' || !relative || path.isAbsolute(relative)
|| relative.includes('\\') || relative.split('/').some(part => !part || part === '.' || part === '..')) {
throw new Error(`Unsafe native output path: ${relative}`)
}
return path.join(directory, relative)
}
function lstatOrNull(filename) {
try { return fs.lstatSync(filename) } catch (error) {
if (error.code === 'ENOENT') return null
throw error
}
}
/** Ancestors such as macOS /tmp may be aliases, but the supplied output root
* itself and every component below it must be actual directories/files. */
export function createNativeOutputGuard(directory) {
const suppliedRoot = path.resolve(directory)
const suppliedStat = lstatOrNull(suppliedRoot)
if (suppliedStat?.isSymbolicLink()) throw new Error(`Native output forbids symbolic links: ${suppliedRoot}`)
if (!suppliedStat?.isDirectory()) throw new Error(`Native output root must be an existing directory: ${suppliedRoot}`)
const root = fs.realpathSync(suppliedRoot)
function check(relative, expectedType = 'file') {
const rootStat = lstatOrNull(root)
if (rootStat?.isSymbolicLink()) throw new Error(`Native output forbids symbolic links: ${root}`)
if (!rootStat?.isDirectory() || fs.realpathSync(root) !== root) throw new Error(`Native output root changed: ${root}`)
const destination = outputPath(root, relative)
const parts = relative.split('/')
let current = root
for (const [index, part] of parts.entries()) {
current = path.join(current, part)
const stat = lstatOrNull(current)
if (!stat) break
if (stat.isSymbolicLink()) throw new Error(`Native output forbids symbolic links: ${current}`)
const real = fs.realpathSync(current)
const inside = path.relative(root, real)
if (path.isAbsolute(inside) || inside === '..' || inside.startsWith(`..${path.sep}`)) {
throw new Error(`Native output escaped its real directory: ${current}`)
}
const needsDirectory = index < parts.length - 1 || expectedType === 'directory'
if (needsDirectory ? !stat.isDirectory() : !stat.isFile()) {
throw new Error(`Unexpected native output path type: ${current}`)
}
}
return destination
}
function write(relative, data) {
const destination = check(relative)
fs.mkdirSync(path.dirname(destination), { recursive: true })
check(relative)
// Re-check parent components after mkdir and prevent following a replaced
// final-file link between lstat and opening the destination.
const descriptor = fs.openSync(destination,
fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_TRUNC | fs.constants.O_NOFOLLOW)
try { fs.writeFileSync(descriptor, data) } finally { fs.closeSync(descriptor) }
}
return { root, path: check, write }
}
function jsonBytes(value) {
return `${JSON.stringify(value, null, 2)}\n`
}
/** Transform only the imported copy. Remote URLs, hashes, and relative requires stay intact. */
export function transformNativeText(source, relativePath, { namespace = NAMESPACE } = {}) {
assertNamespace(namespace)
if (!TEXT_EXTENSIONS.has(path.extname(relativePath))) return source
return source
.replace(/(["'`])\/(?=(?:assets|pages|package-[a-z0-9-]+)\/)/g, `$1/${namespace}/`)
.replace(/(["'`])(?=package-(?:game|chapter-|audio-))/g, `$1${namespace}/`)
.replaceAll(
String.raw`/^\/package-[a-z0-9-]+\//i`,
String.raw`/^\/${namespace}\/package-[a-z0-9-]+\//i`,
)
}
export function createNativeManifest(sourceApp, { namespace = NAMESPACE } = {}) {
assertNamespace(namespace)
const sourcePackages = sourceApp.subPackages || sourceApp.subpackages || []
const packageNames = new Map(sourcePackages.map(item => [item.name || item.root, `${namespace}-${item.name || item.root}`]))
const pages = (sourceApp.pages || []).map(item => `${namespace}/${item}`)
const subPackages = sourcePackages.map(item => ({
...item,
root: `${namespace}/${item.root}`,
name: packageNames.get(item.name || item.root),
pages: [...item.pages],
}))
const preloadRule = Object.fromEntries(Object.entries(sourceApp.preloadRule || {}).map(([page, rule]) => [
`${namespace}/${page}`,
{ ...rule, packages: rule.packages.map(name => {
const mapped = packageNames.get(name)
|| subPackages.find(item => item.root === `${namespace}/${name}`)?.name
if (!mapped) throw new Error(`Unregistered native preload package: ${name}`)
return mapped
}) },
]))
const allPages = [...pages, ...subPackages.flatMap(item => item.pages.map(page => `${item.root}/${page}`))]
if (new Set(allPages).size !== allPages.length) throw new Error('Duplicate native page route')
return { namespace, pages, subPackages, preloadRule, allPages }
}
/** Append native routes without replacing the host's app settings or route ordering. */
export function mergeNativeAppManifest(hostApp, nativeManifest) {
const result = structuredClone(hostApp)
const packageKey = Object.hasOwn(hostApp, 'subpackages') && !Object.hasOwn(hostApp, 'subPackages')
? 'subpackages' : 'subPackages'
const hostPages = result.pages || []
const hostPackages = result[packageKey] || []
const nativePages = new Set(nativeManifest.allPages)
for (const item of hostPackages) {
const proposed = nativeManifest.subPackages.find(candidate => candidate.root === item.root)
if (proposed) {
if (JSON.stringify(item) !== JSON.stringify(proposed)) throw new Error(`Native subpackage conflicts with host: ${item.root}`)
continue
}
if ((item.pages || []).some(page => nativePages.has(`${item.root}/${page}`))) {
throw new Error(`Native page conflicts with host subpackage: ${item.root}`)
}
if (nativeManifest.subPackages.some(candidate => candidate.name === item.name)) {
throw new Error(`Native subpackage name conflicts with host: ${item.name}`)
}
}
for (const page of hostPages) {
if (nativePages.has(page) && !nativeManifest.pages.includes(page)) {
throw new Error(`Native subpackage page is already a host main page: ${page}`)
}
}
result.pages = [...hostPages, ...nativeManifest.pages.filter(page => !hostPages.includes(page))]
result[packageKey] = [...hostPackages, ...nativeManifest.subPackages.filter(item => !hostPackages.some(existing => existing.root === item.root))]
const preloadRule = { ...(result.preloadRule || {}) }
for (const [page, rule] of Object.entries(nativeManifest.preloadRule)) {
if (preloadRule[page] && JSON.stringify(preloadRule[page]) !== JSON.stringify(rule)) {
throw new Error(`Native preload conflicts with host: ${page}`)
}
preloadRule[page] = rule
}
result.preloadRule = preloadRule
return result
}
export function wrapNativePage(source, relativePath) {
const wrapperPath = path.posix.relative(path.posix.dirname(relativePath), 'utils/tangPage.js')
const topLevelCalls = [...source.matchAll(/^Page\(\{/gm)]
if (topLevelCalls.length !== 1) throw new Error(`Expected one top-level native Page registration: ${relativePath}`)
return source.replace(/^Page\(\{/m, `require(${JSON.stringify(wrapperPath)})({`)
}
function collectOutputFiles(sourceDirectory, overlayDirectory, sourceApp, nativeManifest, apiBaseUrl, media) {
const files = new Map()
const sourcePages = new Set(nativeManifest.allPages.map(page => page.slice(nativeManifest.namespace.length + 1)))
const pageDefaults = Object.fromEntries(PAGE_WINDOW_KEYS
.filter(key => sourceApp.window?.[key] !== undefined)
.map(key => [key, sourceApp.window[key]]))
for (const relative of listFiles(sourceDirectory)) {
if (['app.js', 'app.json', 'sitemap.json'].includes(relative)) continue
const original = fs.readFileSync(outputPath(sourceDirectory, relative))
if (relative === 'app.wxss') {
files.set('shared.wxss', original)
continue
}
const extension = path.extname(relative)
if (!TEXT_EXTENSIONS.has(extension)) {
files.set(relative, original)
continue
}
let content = transformNativeText(original.toString('utf8'), relative, nativeManifest)
const isPage = sourcePages.has(relative.slice(0, -extension.length))
if (isPage && extension === '.json') content = jsonBytes({ ...pageDefaults, ...JSON.parse(content) })
if (isPage && extension === '.wxss') {
content = `@import "/${nativeManifest.namespace}/shared.wxss";\n${content}`
}
files.set(relative, Buffer.from(content))
}
// Adapters contain final output paths. Do not transform them a second time.
for (const relative of listFiles(overlayDirectory)) {
// Remote helpers are activated together with a verified manifest only.
if (relative === 'utils/cosMedia.js') continue
if (['app.js', 'app.json', 'app.wxss', 'sitemap.json', STATE_FILE].includes(relative)
|| /(^|\/)project(?:\.private)?\.config\.json$/.test(relative)) {
throw new Error(`Adapter cannot replace the host app or import state: ${relative}`)
}
if (isMediaFile(relative)) {
throw new Error(`Adapter cannot replace source media: ${relative}`)
}
files.set(relative, fs.readFileSync(outputPath(overlayDirectory, relative)))
}
files.set('utils/platformConfig.js', Buffer.from(`module.exports = ${JSON.stringify({ apiBaseUrl })};\n`))
// Media conversion also covers the adapter's final paths, without repeating
// namespace conversion or changing the host/account lifecycle adapters.
applyCosMediaOutput(files, media, nativeManifest)
if (!files.has('utils/tangPage.js')) throw new Error('Native adapter is missing utils/tangPage.js')
files.set('shared.wxss', Buffer.from(`${files.get('shared.wxss').toString('utf8')}\n${BOOT_MASK_WXSS}`))
for (const page of sourcePages) {
for (const extension of ['.js', '.json', '.wxml', '.wxss']) {
if (!files.has(`${page}${extension}`)) throw new Error(`Native page file is missing: ${page}${extension}`)
}
const pageJson = JSON.parse(files.get(`${page}.json`).toString('utf8'))
if (pageJson.navigationStyle !== 'custom' || pageJson.pageOrientation !== 'landscape') {
throw new Error(`Adapter lost native page configuration: ${page}`)
}
files.set(`${page}.js`, Buffer.from(wrapNativePage(files.get(`${page}.js`).toString('utf8'), `${page}.js`)))
files.set(`${page}.wxml`, Buffer.from(`${files.get(`${page}.wxml`).toString('utf8')}\n${BOOT_MASK_WXML}\n`))
}
return files
}
export function summarizePackageSizes(files, nativeManifest) {
const result = { sourceFileBytes: 0, mainPackageBytes: 0, subPackages: {}, mediaFileCount: 0, mediaBytes: 0 }
for (const [relative, value] of files) {
const fullPath = `${nativeManifest.namespace}/${relative}`
const subpackage = nativeManifest.subPackages.find(item => fullPath.startsWith(`${item.root}/`))
result.sourceFileBytes += value.length
if (subpackage) result.subPackages[subpackage.root] = (result.subPackages[subpackage.root] || 0) + value.length
else result.mainPackageBytes += value.length
if (isMediaFile(relative)) {
result.mediaFileCount += 1
result.mediaBytes += value.length
}
}
return result
}
/** Reusable by the Vite hook and by static tests; no build or server is started here. */
export function copyNativeProgram({ sourceDirectory, outputDirectory, overlayDirectory, namespace = NAMESPACE, apiBaseUrl = '', mediaManifest, mediaManifestPath }) {
assertNamespace(namespace)
const outputGuard = createNativeOutputGuard(outputDirectory)
outputDirectory = outputGuard.root
const media = loadCosMediaManifest({ sourceDirectory, mediaManifest, mediaManifestPath })
const sourceApp = JSON.parse(fs.readFileSync(path.join(sourceDirectory, 'app.json'), 'utf8'))
const hostAppPath = outputGuard.path('app.json')
const hostApp = JSON.parse(fs.readFileSync(hostAppPath, 'utf8'))
const nativeManifest = createNativeManifest(sourceApp, { namespace })
const mergedApp = mergeNativeAppManifest(hostApp, nativeManifest)
const files = collectOutputFiles(sourceDirectory, overlayDirectory, sourceApp, nativeManifest, apiBaseUrl, media)
const nativeOutputDirectory = outputGuard.path(namespace, 'directory')
const nativeTarget = relative => {
// Validate the untrusted old-state relative path before adding namespace.
const target = outputPath(nativeOutputDirectory, relative)
return outputGuard.path(path.relative(outputDirectory, target))
}
const statePath = nativeTarget(STATE_FILE)
const previousState = fs.existsSync(statePath) ? JSON.parse(fs.readFileSync(statePath, 'utf8')) : null
if (previousState && previousState.namespace !== namespace) throw new Error('Native output ownership mismatch')
const previousFiles = new Map((previousState?.files || []).map(item => [item.path, item.sha256]))
// Refuse to overwrite files whose ownership or later edits cannot be established.
for (const [relative, value] of files) {
const destination = nativeTarget(relative)
if (!fs.existsSync(destination)) continue
const existingHash = sha256(fs.readFileSync(destination))
if (existingHash !== previousFiles.get(relative) && existingHash !== sha256(value)) {
throw new Error(`Refusing to overwrite unowned native output: ${relative}`)
}
}
const staleFiles = []
for (const [relative, expectedHash] of previousFiles) {
if (files.has(relative)) continue
const stalePath = nativeTarget(relative)
if (fs.existsSync(stalePath)) {
if (sha256(fs.readFileSync(stalePath)) !== expectedHash) throw new Error(`Stale native output has external edits: ${relative}`)
staleFiles.push(relative)
}
}
// Check every stale file before removing any: a conflict late in the media
// list must not leave a previously usable local build partially stripped.
for (const relative of staleFiles) fs.unlinkSync(nativeTarget(relative))
for (const [relative, value] of files) outputGuard.write(`${namespace}/${relative}`, value)
const state = {
version: 1,
namespace,
pages: nativeManifest.allPages,
files: [...files].map(([relative, value]) => ({ path: relative, bytes: value.length, sha256: sha256(value) })),
sizes: summarizePackageSizes(files, nativeManifest),
...(media ? { media: { mode: 'cos', sourceManifestSha256: media.sourceManifestSha256,
manifestSha256: media.manifestSha256, sourceMediaFiles: media.entries.size, objectCount: media.objectCount } } : {}),
validationBoundary: 'Static copy and route integration only; no device, upload, content review, or release approval.',
}
outputGuard.write(`${namespace}/${STATE_FILE}`, jsonBytes(state))
outputGuard.write('app.json', jsonBytes(mergedApp))
return { ...state, nativeManifest }
}
export default function tangDetectiveNativePlugin(options = {}) {
let root
let buildOutput
const sourceRelative = options.sourceDirectory || 'native/tang-detective'
const overlayRelative = options.overlayDirectory || 'native-adapter/tang-detective'
return {
name: 'tang-detective-native-pages',
enforce: 'post',
apply: () => process.env.UNI_PLATFORM === 'mp-weixin',
configResolved(config) {
root = config.root
buildOutput = path.resolve(root, config.build.outDir)
},
buildStart() {
this.addWatchFile(options.mediaManifestPath || DEFAULT_MEDIA_MANIFEST_PATH)
this.addWatchFile(SOURCE_MANIFEST_PATH)
for (const directory of [path.resolve(root, sourceRelative), path.resolve(root, overlayRelative)]) {
this.addWatchFile(directory)
for (const relative of listFiles(directory)) this.addWatchFile(path.join(directory, relative))
}
},
// Sequential post-order runs after normal write hooks and is repeated for watch rebuilds.
writeBundle: {
order: 'post',
sequential: true,
handler(outputOptions) {
const report = copyNativeProgram({
sourceDirectory: path.resolve(root, sourceRelative),
overlayDirectory: path.resolve(root, overlayRelative),
outputDirectory: outputOptions.dir ? path.resolve(root, outputOptions.dir) : buildOutput,
namespace: options.namespace || NAMESPACE,
apiBaseUrl: options.apiBaseUrl || '',
mediaManifest: options.mediaManifest,
mediaManifestPath: options.mediaManifestPath,
})
this.warn(`唐侦探原生页面已合并:${report.pages.length} 页,原生文件 ${report.sizes.sourceFileBytes} bytes,其中主包新增 ${report.sizes.mainPackageBytes} bytes;此结果不代表包体积或发布验收通过。`)
},
},
}
}
@@ -0,0 +1,262 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import vm from 'node:vm'
import { createRequire } from 'node:module'
import { fileURLToPath } from 'node:url'
import nativePlugin, {
copyNativeProgram,
createNativeManifest,
listFiles,
mergeNativeAppManifest,
sha256,
transformNativeText,
wrapNativePage,
} from './tang-detective-native-plugin.mjs'
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const sourceDirectory = path.join(projectRoot, 'native/tang-detective')
const sourceApp = JSON.parse(fs.readFileSync(path.join(sourceDirectory, 'app.json'), 'utf8'))
const mediaPattern = /\.(?:jpg|jpeg|png|webp|mp3|wav|aac|m4a|ogg)$/i
function write(directory, relative, content) {
const filename = path.join(directory, relative)
fs.mkdirSync(path.dirname(filename), { recursive: true })
fs.writeFileSync(filename, content)
}
function fixture(t) {
const temporary = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'tang-native-test-')))
// This directory is created and exclusively owned by this individual test.
t.after(() => fs.rmSync(temporary, { recursive: true, force: true }))
const outputDirectory = path.join(temporary, 'output')
const overlayDirectory = path.join(temporary, 'overlay')
const hostApp = {
pages: ['pages/index/index'],
subPackages: [{ root: 'tongji', pages: ['endless-game/index'] }],
window: { navigationStyle: 'default', pageOrientation: 'portrait', backgroundColor: '#ffffff' },
tabBar: { list: [{ pagePath: 'pages/index/index', text: '首页' }] },
permission: { 'scope.record': { desc: '通话' } },
preloadRule: { 'pages/index/index': { network: 'wifi', packages: ['tongji'] } },
}
write(outputDirectory, 'app.json', JSON.stringify(hostApp))
write(outputDirectory, 'app.js', '/* host App entry must stay unchanged */')
write(outputDirectory, 'app.wxss', '/* host global styles must stay unchanged */')
write(outputDirectory, 'project.config.json', '{"description":"host project"}')
write(outputDirectory, 'pages/index/index.js', '/* host home page */')
write(outputDirectory, 'tongji/endless-game/index.js', '/* existing game */')
write(overlayDirectory, 'utils/tangPage.js', 'module.exports = function (definition) { return Page(definition) }\n')
const options = { sourceDirectory, outputDirectory, overlayDirectory, apiBaseUrl: 'https://api.example.test', mediaManifest: null }
return { ...options, options, hostApp }
}
function packageOwner(relative, manifest) {
return manifest.subPackages.find(item => relative.startsWith(`${item.root}/`))?.root || 'main'
}
test('imported snapshot matches every recorded source hash and preserves all 204 media files', () => {
const manifest = JSON.parse(fs.readFileSync(path.join(projectRoot, 'build/tang-detective-source-manifest.json'), 'utf8'))
assert.equal(manifest.files.length, 473)
assert.equal(listFiles(sourceDirectory).length, manifest.files.length)
assert.equal(manifest.files.filter(item => mediaPattern.test(item.path)).length, 204)
for (const file of manifest.files) {
const bytes = fs.readFileSync(path.join(sourceDirectory, file.path))
assert.equal(bytes.length, file.bytes, file.path)
assert.equal(sha256(bytes), file.sha256, file.path)
}
assert.equal(fs.existsSync(path.join(sourceDirectory, 'app.js')), false)
})
test('path conversion includes dynamic roots and packaged-path regex without changing external URLs or requires', () => {
const source = [
"const a = '/pages/share/share?x=1'",
'const b = "/assets/share/card.jpg"',
"const c = 'package-game'",
'const d = `package-chapter-${number}`',
'const e = `/${packageRoot}/assets/comic/page.jpg`',
"const f = require('../../../utils/storage')",
"const remote = 'https://cdn.example.test/assets/share/card.jpg'",
String.raw`const valid = /^\/package-[a-z0-9-]+\//i`,
].join('\n')
const result = transformNativeText(source, 'example.js')
assert.match(result, /'\/tang-detective\/pages\/share\/share\?x=1'/)
assert.match(result, /"\/tang-detective\/assets\/share\/card.jpg"/)
assert.match(result, /'tang-detective\/package-game'/)
assert.ok(result.includes('`tang-detective/package-chapter-${number}`'))
assert.ok(result.includes('`/${packageRoot}/assets/comic/page.jpg`'))
assert.ok(result.includes("require('../../../utils/storage')"))
assert.ok(result.includes("'https://cdn.example.test/assets/share/card.jpg'"))
assert.ok(result.includes(String.raw`/^\/tang-detective\/package-[a-z0-9-]+\//i`))
assert.equal(transformNativeText(result, 'example.js'), result)
new vm.Script(result)
})
test('manifest merge preserves host configuration, supports both package spellings, and rejects conflicting routes', () => {
const manifest = createNativeManifest(sourceApp)
assert.equal(manifest.pages.length, 6)
assert.equal(manifest.subPackages.length, 18)
assert.equal(manifest.allPages.length, 24)
const host = { pages: ['pages/index/index'], subpackages: [{ root: 'tongji', pages: ['pages/index'] }], window: { pageOrientation: 'portrait' } }
const merged = mergeNativeAppManifest(host, manifest)
assert.deepEqual(host, { pages: ['pages/index/index'], subpackages: [{ root: 'tongji', pages: ['pages/index'] }], window: { pageOrientation: 'portrait' } })
assert.deepEqual(merged.window, host.window)
assert.equal(merged.subPackages, undefined)
assert.deepEqual(mergeNativeAppManifest(merged, manifest), merged)
assert.deepEqual(merged.preloadRule['tang-detective/package-game/pages/chapter/chapter'].packages, ['tang-detective-audio-c01-a'])
assert.throws(() => mergeNativeAppManifest({ pages: [manifest.subPackages[0].root + '/pages/chapter/chapter'] }, manifest), /already a host main page/)
assert.throws(() => mergeNativeAppManifest({ subPackages: [{ root: manifest.subPackages[0].root, pages: ['wrong'] }] }, manifest), /conflicts with host/)
})
test('all native pages copy into owned output with legal relative requires and unchanged host/media bytes', t => {
const context = fixture(t)
const guardedFiles = ['app.js', 'app.wxss', 'project.config.json', 'pages/index/index.js', 'tongji/endless-game/index.js']
const before = new Map(guardedFiles.map(file => [file, sha256(fs.readFileSync(path.join(context.outputDirectory, file)))]))
// The final adapter path must be copied literally, without a second prefix pass.
const overlay = "module.exports = { home: '/tang-detective/pages/home/home', host: '/pages/index/index' }\n"
write(context.overlayDirectory, 'utils/overlayProbe.js', overlay)
// Final safety UI is added after adapters, including a fully replaced home template.
const homeTemplate = '<view>adapted home</view>'
write(context.overlayDirectory, 'pages/home/home.wxml', homeTemplate)
const report = copyNativeProgram(context.options)
assert.equal(report.pages.length, 24)
assert.equal(report.sizes.mediaFileCount, 204)
const nativeDirectory = path.join(context.outputDirectory, 'tang-detective')
assert.equal(fs.readFileSync(path.join(nativeDirectory, 'utils/overlayProbe.js'), 'utf8'), overlay)
assert.ok(fs.readFileSync(path.join(nativeDirectory, 'pages/home/home.wxml'), 'utf8').startsWith(homeTemplate))
const sharedStyle = fs.readFileSync(path.join(nativeDirectory, 'shared.wxss'), 'utf8')
assert.equal((sharedStyle.match(/\.tang-boot-mask\s*\{/g) || []).length, 1)
const maskStyle = sharedStyle.slice(sharedStyle.indexOf('.tang-boot-mask'))
for (const declaration of ['position: fixed', 'inset: 0', 'z-index: 2147483647', 'background: #201711', 'color: #f3e5bd', 'font-size: 18px', 'align-items: center', 'justify-content: center']) {
assert.ok(maskStyle.includes(declaration), declaration)
}
const localRequire = createRequire(path.join(context.outputDirectory, 'package.cjs'))
assert.deepEqual(localRequire(path.join(nativeDirectory, 'utils/platformConfig.js')), { apiBaseUrl: context.apiBaseUrl })
for (const file of guardedFiles) assert.equal(sha256(fs.readFileSync(path.join(context.outputDirectory, file))), before.get(file), file)
const merged = JSON.parse(fs.readFileSync(path.join(context.outputDirectory, 'app.json'), 'utf8'))
for (const key of ['window', 'tabBar', 'permission']) assert.deepEqual(merged[key], context.hostApp[key])
assert.deepEqual(merged.preloadRule['pages/index/index'], context.hostApp.preloadRule['pages/index/index'])
for (const relative of listFiles(sourceDirectory).filter(file => mediaPattern.test(file))) {
assert.equal(sha256(fs.readFileSync(path.join(nativeDirectory, relative))), sha256(fs.readFileSync(path.join(sourceDirectory, relative))), relative)
}
for (const page of report.pages) {
const config = JSON.parse(fs.readFileSync(path.join(context.outputDirectory, `${page}.json`), 'utf8'))
assert.equal(config.navigationStyle, 'custom', page)
assert.equal(config.pageOrientation, 'landscape', page)
const style = fs.readFileSync(path.join(context.outputDirectory, `${page}.wxss`), 'utf8')
assert.ok(style.startsWith('@import "/tang-detective/shared.wxss";'), page)
const script = fs.readFileSync(path.join(context.outputDirectory, `${page}.js`), 'utf8')
assert.doesNotMatch(script, /^Page\(\{/m, page)
assert.match(script, /require\("\.\.\/(?:\.\.\/)*utils\/tangPage\.js"\)\(\{/, page)
const template = fs.readFileSync(path.join(context.outputDirectory, `${page}.wxml`), 'utf8')
assert.equal((template.match(/class="tang-boot-mask"/g) || []).length, 1, page)
assert.ok(template.trimEnd().endsWith('<view wx:if="{{tangBootPending}}" class="tang-boot-mask" catchtap="tangIgnoreBootTap" catchtouchmove="tangIgnoreBootTap">正在读取阅读存档…</view>'), page)
}
let dependencyCount = 0
for (const relative of listFiles(nativeDirectory).filter(file => file.endsWith('.js'))) {
const filename = path.join(nativeDirectory, relative)
const script = fs.readFileSync(filename, 'utf8')
new vm.Script(script, { filename: relative })
for (const [, specifier] of script.matchAll(/require\(['"]([^'"]+)['"]\)/g)) {
assert.ok(specifier.startsWith('.'), `${relative}: native dependencies must be relative (${specifier})`)
const resolved = localRequire.resolve(path.resolve(path.dirname(filename), specifier))
const resolvedRelative = path.relative(context.outputDirectory, resolved).split(path.sep).join('/')
assert.ok(resolvedRelative.startsWith('tang-detective/'), `${relative}: dependency escaped namespace`)
const caller = packageOwner(`tang-detective/${relative}`, report.nativeManifest)
const dependency = packageOwner(resolvedRelative, report.nativeManifest)
assert.ok(dependency === 'main' || caller === dependency, `${relative} imports a sibling subpackage: ${specifier}`)
dependencyCount += 1
}
}
assert.ok(dependencyCount > 300)
assert.equal(fs.existsSync(path.join(nativeDirectory, 'app.js')), false)
assert.equal(fs.existsSync(path.join(nativeDirectory, 'app.json')), false)
assert.equal(fs.existsSync(path.join(nativeDirectory, 'sitemap.json')), false)
const firstState = fs.readFileSync(path.join(nativeDirectory, '.native-import-state.json'), 'utf8')
copyNativeProgram(context.options)
assert.equal(fs.readFileSync(path.join(nativeDirectory, '.native-import-state.json'), 'utf8'), firstState)
assert.equal(JSON.parse(fs.readFileSync(path.join(context.outputDirectory, 'app.json'), 'utf8')).pages.length, 7)
})
test('120 chapter page image selections and C01 full tracks remain in main or their own subpackage', t => {
const context = fixture(t)
const report = copyNativeProgram(context.options)
const localRequire = createRequire(path.join(context.outputDirectory, 'package.cjs'))
const root = path.join(context.outputDirectory, 'tang-detective')
const season = localRequire(path.join(root, 'data/season.js'))
const routing = localRequire(path.join(root, 'utils/chapterRoute.js'))
function assertLocalAsset(asset, owner) {
if (!asset) return
assert.ok(asset.startsWith('/tang-detective/'), asset)
const relative = asset.slice(1)
const targetOwner = packageOwner(relative, report.nativeManifest)
assert.ok(targetOwner === 'main' || targetOwner === owner, `Illegal sibling asset read: ${owner} -> ${asset}`)
assert.ok(fs.existsSync(path.join(context.outputDirectory, relative)), `Missing selected asset: ${asset}`)
}
let pages = 0
for (let number = 1; number <= 15; number += 1) {
const packageRoot = routing.chapterPackageRoot(number)
assert.ok(report.pages.includes(routing.chapterRoute(number).split('?')[0].slice(1)))
const chapter = season.chapters.find(item => item.chapterNumber === number)
const chapterPages = localRequire(path.join(context.outputDirectory, packageRoot, 'pages/chapter/chapterPages.js'))
const modelUtils = localRequire(path.join(context.outputDirectory, packageRoot, 'utils/comicPageModel.js'))
const { releaseAssets } = localRequire(path.join(context.outputDirectory, packageRoot, 'data/releaseAssetManifest.js'))
assert.equal(modelUtils.isPackagedPath(`/${packageRoot}/assets/example.jpg`), true)
assert.equal(modelUtils.isPackagedPath('/package-game/assets/example.jpg'), false)
const model = chapterPages.buildComicPageModel(chapter, number)
assert.equal(model.pageSequence.length, 8)
for (const page of model.pageSequence) {
const [, seasonNumber, chapterNumber, pageNumber] = page.pageId.match(/^S(\d+)-C(\d+)-P(\d+)$/)
const asset = releaseAssets[`comic.s${seasonNumber}.c${chapterNumber}.p${pageNumber}`]
const image = modelUtils.buildComicImageState(page, asset, {})
assertLocalAsset(image.src, packageRoot)
assertLocalAsset(image.fallback, packageRoot)
pages += 1
}
}
assert.equal(pages, 120)
for (const audioRoot of ['tang-detective/package-audio-c01-a', 'tang-detective/package-audio-c01-b']) {
const tracks = localRequire(path.join(context.outputDirectory, audioRoot, 'data/audioPages.js'))
for (const track of Object.values(tracks)) {
assertLocalAsset(track.audioSrc, audioRoot)
assertLocalAsset(track.imageSrc, audioRoot)
assert.equal(track.reviewStatus, 'technical-qa-pass-human-listening-pending')
}
}
})
test('overlay protection and owned cleanup preserve media, host state, and external edits', t => {
const context = fixture(t)
write(context.overlayDirectory, 'utils/obsolete.js', 'module.exports = 1')
copyNativeProgram(context.options)
fs.unlinkSync(path.join(context.overlayDirectory, 'utils/obsolete.js'))
copyNativeProgram(context.options)
assert.equal(fs.existsSync(path.join(context.outputDirectory, 'tang-detective/utils/obsolete.js')), false)
write(context.outputDirectory, 'tang-detective/utils/storage.js', '/* external user edit */')
assert.throws(() => copyNativeProgram(context.options), /unowned native output/)
write(context.overlayDirectory, 'app.js', 'App({})')
assert.throws(() => copyNativeProgram(context.options), /cannot replace the host app/)
fs.unlinkSync(path.join(context.overlayDirectory, 'app.js'))
write(context.overlayDirectory, 'assets/replacement.jpg', 'not an allowed media replacement')
assert.throws(() => copyNativeProgram(context.options), /cannot replace source media/)
})
test('page registration rejects ambiguous input and Vite hook remains post-sequential and WeChat-only', () => {
assert.equal(wrapNativePage('Page({\n})', 'pages/home/home.js'), 'require("../../utils/tangPage.js")({\n})')
assert.throws(() => wrapNativePage('Page({})\nPage({})', 'pages/home/home.js'), /one top-level/)
const plugin = nativePlugin()
assert.equal(plugin.enforce, 'post')
assert.equal(plugin.writeBundle.order, 'post')
assert.equal(plugin.writeBundle.sequential, true)
const previous = process.env.UNI_PLATFORM
try {
process.env.UNI_PLATFORM = 'h5'
assert.equal(plugin.apply(), false)
process.env.UNI_PLATFORM = 'mp-weixin'
assert.equal(plugin.apply(), true)
} finally {
if (previous === undefined) delete process.env.UNI_PLATFORM
else process.env.UNI_PLATFORM = previous
}
})
@@ -0,0 +1,146 @@
{
"passed": true,
"nativePages": 24,
"mediaFiles": 204,
"relativeDependencies": 328,
"nativeSizes": {
"sourceFileBytes": 30098730,
"mainPackageBytes": 1903370,
"subPackages": {
"tang-detective/package-audio-c01-a": 1733274,
"tang-detective/package-audio-c01-b": 1231110,
"tang-detective/package-audio-player": 107278,
"tang-detective/package-chapter-02": 1571087,
"tang-detective/package-chapter-03": 1995560,
"tang-detective/package-chapter-04": 1180764,
"tang-detective/package-chapter-05": 1605611,
"tang-detective/package-chapter-06": 1707290,
"tang-detective/package-chapter-07": 1663468,
"tang-detective/package-chapter-08": 1747028,
"tang-detective/package-chapter-09": 1754206,
"tang-detective/package-chapter-10": 1783701,
"tang-detective/package-chapter-11": 1547432,
"tang-detective/package-chapter-12": 1709680,
"tang-detective/package-chapter-13": 1960330,
"tang-detective/package-chapter-14": 1877271,
"tang-detective/package-chapter-15": 1757833,
"tang-detective/package-game": 1262437
},
"mediaFileCount": 204,
"mediaBytes": 24052082
},
"outputSizes": {
"totalFileBytes": 32595927,
"mainPackageFileBytes": 3157378,
"subPackages": {
"doctor": 25092,
"tang-detective/package-audio-c01-a": 1733274,
"tang-detective/package-audio-c01-b": 1231110,
"tang-detective/package-audio-player": 107278,
"tang-detective/package-chapter-02": 1571087,
"tang-detective/package-chapter-03": 1995560,
"tang-detective/package-chapter-04": 1180764,
"tang-detective/package-chapter-05": 1605611,
"tang-detective/package-chapter-06": 1707290,
"tang-detective/package-chapter-07": 1663468,
"tang-detective/package-chapter-08": 1747028,
"tang-detective/package-chapter-09": 1754206,
"tang-detective/package-chapter-10": 1783701,
"tang-detective/package-chapter-11": 1547432,
"tang-detective/package-chapter-12": 1709680,
"tang-detective/package-chapter-13": 1960330,
"tang-detective/package-chapter-14": 1877271,
"tang-detective/package-chapter-15": 1757833,
"tang-detective/package-game": 1262437,
"tongji": 574912,
"training": 173057,
"TUICallKit/src/Components": 172402,
"TUIKit": 297726
}
},
"errors": [],
"boundary": "Static source/asset/dependency/manifest verification only. Raw file sizes are not WeChat upload package sizes; no simulator, device, audio listening, upload, or release acceptance was performed.",
"verificationDate": "2026-09-08",
"builds": {
"weixinExitCode": 0,
"h5ExitCode": 0
},
"tests": {
"nativeImport": 7,
"platform": 27,
"realPageLifecycle": 10,
"total": 44,
"failed": 0
},
"nativeCompiler": {
"nativePages": 24,
"results": [
{
"tool": "wcc",
"exitCode": 0,
"passed": true,
"generatedOutputBytes": 1183577,
"diagnostics": "",
"error": null
},
{
"tool": "wcsc",
"exitCode": 0,
"passed": true,
"generatedOutputBytes": 1346916,
"diagnostics": "",
"error": null
}
],
"boundary": "Installed compiler syntax check only, not simulator/device, networking or upload acceptance."
},
"provenance": {
"sourceFiles": 473,
"sourceDrift": [],
"sha256": {
"main.js": "ef7412ffcb94520615c4289c45ca63929de44f2258d73aa7134cb3dacebd7b11",
"package.json": "63ffaf1f3224ab685e6ba9c1895030d66400d8371b37786cb73e2e41b868b16c",
"pages.json": "3987ba37f2175ed100058861d5053726413232d6599fff4da807654d2661a17a",
"tongji/pages/weekly.vue": "c8033a6237c46f5e1d170628ba82a8638a4275388b4be5f6c9336d0552981cb7",
"config/api.js": "c0efc1db4daa21e91a404784170c12efc127a6fd042f476ad131aa4a2642ff74",
"vite.config.ts": "28efc83adbd45629731b83c7702e184ba775d32091150ede54874d0975bd1352",
"build/tang-detective-native-plugin.mjs": "7b64e465a246b7696019d17d7777a58a7ffa3a4ad4bfe7187ac09f5495954a30",
"dist/build/mp-weixin/app.json": "bd6aa0028069fd2b0801681e2e62ab8faf3ddc9544ea5375e8f7db5c33e6c92e",
"dist/build/mp-weixin/tang-detective/.native-import-state.json": "8518b430dbe156a19a6dd317034e8935090e7be805337ab35c29c944f5721b32",
"native-adapter/tang-detective/pages/catalog/catalog.js": "0f98ca3ec3c7f4d296afea27d6e2ec86f9a1456805c3cf12fafe622e2c597b90",
"native-adapter/tang-detective/pages/home/home.js": "880b7561c6a33ac0d45986ea492cdac56ad9fa837198c93f546291db7b2b1f35",
"native-adapter/tang-detective/pages/home/home.wxml": "d2ad74344fef441594817d9ecdb7273a743536a013b50e3bdaacd065882fe10d",
"native-adapter/tang-detective/pages/home/home.wxss": "c00be0cf7c8bbf4ed50f9ad0eeb80922424b6b7e0c58f89edc873c2625774c2c",
"native-adapter/tang-detective/utils/identityHash.js": "72b404e159ebca86469f9dbe822b7876e9cf9da823785676d51fd698e84fa34a",
"native-adapter/tang-detective/utils/platformBridge.js": "d0fac62908d4caab65864d0c401f621561525aa32be12d536ecb9070988e9aaa",
"native-adapter/tang-detective/utils/platformCore.js": "398f822d4dd492f962957d18e997138eda3dc10e42e6b0b0e8c453f40f324927",
"native-adapter/tang-detective/utils/progressContract.js": "fb3e1107298ca11983f1136dc13105588c815c3240e289baab2bcaab88cf71f0",
"native-adapter/tang-detective/utils/storage.js": "f6028151609f3a02efd633ca7d696300ee7e6e5eeb06ee0edcee01f1e08f9835",
"native-adapter/tang-detective/utils/tangPage.js": "9aeafc0b944b4adc87179144d52f351a60ce20b0c6b9e466c32418367db47e35"
}
},
"independentReview": {
"originalFindings": 5,
"closedByOfflineRetest": 5,
"productionApproval": false
},
"notRun": [
"PHP contract scripts: PHP runtime unavailable",
"MySQL and SQL migration",
"live HTTP API/authentication integration",
"simulator interaction and real-device testing",
"audio listening and media rights approval",
"upload and production deployment"
],
"resources": {
"taskProcesses": "exited",
"taskServersStarted": 0,
"browserOrPlayerOpened": false,
"preserved": [
"source snapshots",
"dependencies and SDK",
"build outputs",
"user-existing applications and services"
]
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,110 @@
import fs from 'node:fs'
import path from 'node:path'
import vm from 'node:vm'
import { createRequire } from 'node:module'
import { fileURLToPath } from 'node:url'
import { createNativeManifest, createNativeOutputGuard, listFiles, sha256 } from './tang-detective-native-plugin.mjs'
import { isMediaFile, loadCosMediaManifest } from './tang-detective-cos-media.mjs'
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
export function validateNativeOutput(outputDirectory, { mediaManifest, mediaManifestPath } = {}) {
const outputGuard = createNativeOutputGuard(outputDirectory)
const output = outputGuard.root
const nativeRoot = outputGuard.path('tang-detective', 'directory')
const sourceRoot = path.join(projectRoot, 'native/tang-detective')
const sourceApp = JSON.parse(fs.readFileSync(path.join(sourceRoot, 'app.json'), 'utf8'))
const manifest = createNativeManifest(sourceApp)
const hostApp = JSON.parse(fs.readFileSync(outputGuard.path('app.json'), 'utf8'))
const imported = JSON.parse(fs.readFileSync(outputGuard.path('tang-detective/.native-import-state.json'), 'utf8'))
const sourceManifest = JSON.parse(fs.readFileSync(path.join(projectRoot, 'build/tang-detective-source-manifest.json'), 'utf8'))
const localRequire = createRequire(path.join(output, 'native-validation.cjs'))
const errors = []
const check = (condition, message) => { if (!condition) errors.push(message) }
const remoteMode = imported.media?.mode === 'cos'
let media = null
if (remoteMode) {
try {
media = loadCosMediaManifest({ sourceDirectory: sourceRoot, mediaManifest, mediaManifestPath })
check(Boolean(media), 'COS output requires its verified media manifest')
if (media) {
check(imported.media.manifestSha256 === media.manifestSha256, 'COS manifest changed after import')
check(imported.media.sourceManifestSha256 === media.sourceManifestSha256, 'COS source manifest changed after import')
}
} catch (error) { errors.push(error.message) }
}
const allRoutes = [...hostApp.pages, ...(hostApp.subPackages || hostApp.subpackages || []).flatMap(item => item.pages.map(page => `${item.root}/${page}`))]
const packageOwner = relative => manifest.subPackages.find(item => relative.startsWith(`${item.root}/`))?.root || 'main'
let mediaFiles = 0
for (const file of sourceManifest.files) {
check(sha256(fs.readFileSync(path.join(sourceRoot, file.path))) === file.sha256, `Source snapshot drift: ${file.path}`)
if (!isMediaFile(file.path)) continue
mediaFiles += 1
outputGuard.path(`tang-detective/${file.path}`)
if (remoteMode) {
check(!fs.existsSync(path.join(nativeRoot, file.path)), `Packaged media remains in COS mode: ${file.path}`)
check(media?.entries.get(file.path)?.sha256 === file.sha256, `Remote media mapping changed: ${file.path}`)
} else {
check(sha256(fs.readFileSync(path.join(nativeRoot, file.path))) === file.sha256, `Media changed: ${file.path}`)
}
}
for (const file of imported.files) {
check(sha256(fs.readFileSync(outputGuard.path(`tang-detective/${file.path}`))) === file.sha256, `Native output changed after import: ${file.path}`)
}
const packagedMediaFiles = listFiles(nativeRoot).filter(isMediaFile).length
if (remoteMode) check(packagedMediaFiles === 0, 'Unexpected native packaged media in COS mode')
for (const page of manifest.allPages) {
check(allRoutes.filter(route => route === page).length === 1, `Page registration missing or duplicated: ${page}`)
for (const extension of ['.js', '.json', '.wxml', '.wxss']) check(fs.existsSync(path.join(output, `${page}${extension}`)), `Missing page file: ${page}${extension}`)
const config = JSON.parse(fs.readFileSync(path.join(output, `${page}.json`), 'utf8'))
check(config.navigationStyle === 'custom' && config.pageOrientation === 'landscape', `Native display config missing: ${page}`)
const script = fs.readFileSync(path.join(output, `${page}.js`), 'utf8')
check(!/^Page\(\{/m.test(script) && /require\(["']\.\.\/(?:\.\.\/)*utils\/tangPage\.js["']\)\(\{/.test(script), `Page lifecycle wrapper missing: ${page}`)
const style = fs.readFileSync(path.join(output, `${page}.wxss`), 'utf8')
check(/@import\s+["']\/tang-detective\/shared\.wxss["'];/.test(style), `Native style import missing: ${page}`)
}
let dependencies = 0
for (const relative of listFiles(nativeRoot).filter(file => file.endsWith('.js'))) {
const filename = path.join(nativeRoot, relative)
const script = fs.readFileSync(filename, 'utf8')
try { new vm.Script(script, { filename: relative }) } catch (error) { errors.push(error.message) }
for (const [, specifier] of script.matchAll(/require\(['"]([^'"]+)['"]\)/g)) {
dependencies += 1
if (!specifier.startsWith('.')) { errors.push(`Non-relative native dependency: ${relative} -> ${specifier}`); continue }
try {
const resolved = localRequire.resolve(path.resolve(path.dirname(filename), specifier))
const dependency = path.relative(output, resolved).split(path.sep).join('/')
check(dependency.startsWith('tang-detective/'), `Dependency escaped namespace: ${relative} -> ${specifier}`)
check(packageOwner(dependency) === 'main' || packageOwner(dependency) === packageOwner(`tang-detective/${relative}`), `Illegal sibling package import: ${relative} -> ${specifier}`)
} catch (error) { errors.push(`Unresolved require: ${relative} -> ${specifier}: ${error.message}`) }
}
}
const outputSizes = { totalFileBytes: 0, mainPackageFileBytes: 0, subPackages: {} }
const outputPackages = hostApp.subPackages || hostApp.subpackages || []
for (const relative of listFiles(output)) {
const bytes = fs.statSync(path.join(output, relative)).size
outputSizes.totalFileBytes += bytes
const subpackage = outputPackages.find(item => relative.startsWith(`${item.root}/`))
if (subpackage) outputSizes.subPackages[subpackage.root] = (outputSizes.subPackages[subpackage.root] || 0) + bytes
else outputSizes.mainPackageFileBytes += bytes
}
return {
passed: errors.length === 0,
nativePages: manifest.allPages.length,
mediaFiles,
packagedMediaFiles,
mediaMode: remoteMode ? 'cos' : 'local',
relativeDependencies: dependencies,
nativeSizes: imported.sizes,
outputSizes,
errors,
boundary: 'Static source/asset/dependency/manifest verification only. Raw file sizes are not WeChat upload package sizes; no simulator, device, audio listening, upload, or release acceptance was performed.',
}
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const report = validateNativeOutput(path.resolve(projectRoot, process.argv[2] || 'dist/build/mp-weixin'))
if (process.argv[3]) fs.writeFileSync(path.resolve(projectRoot, process.argv[3]), `${JSON.stringify(report, null, 2)}\n`)
console.log(JSON.stringify(report, null, 2))
if (!report.passed) process.exitCode = 1
}
+3
View File
@@ -0,0 +1,3 @@
// Shared by the host API client and the native Tang Detective build adapter.
// This is a public endpoint, never a place for tokens or API secrets.
export const API_BASE_URL = 'https://admin.zhenyangtang.com.cn/'
@@ -0,0 +1,56 @@
# 唐侦探素材 COS 迁移
## 当前状态(2026-09-08
**尚未上传,也没有切换真实 COS 地址。** 数据库端口可达,但其 TLS 自签名证书不受 Node.js 默认信任库、项目 `server/public/cacert.pem` 或本机系统信任库信任。没有关闭证书/主机名校验,没有读取到 COS 密钥,没有执行任何 COS 写操作,也没有改数据库或桶权限。
需提供配置数据库的可信 CA 证书文件路径,或已授权可用的 SSH 连接名;不要把数据库密码、COS 密钥或登录 token 发到聊天、清单或前端文件中。自签名证书不等于一定存在安全问题,但不能把“能连通”当成“身份已验证”。
473 个原字节快照文件中:160 个 JPG、44 个 MP3、0 个视频,共 204 个原路径、24,052,082 字节,按内容与扩展名去重后为 180 个对象。此次范围为实际迁入游戏的媒体,不包括源码外的草稿、试听实验或重新生成素材。原目录与 `native/tang-detective/` 均保留。
## 上传与校验
独立工具位于 `scripts/tang-cos/`,不会加入小程序运行时。使用 Node.js 22 或更新版本,在前端根目录运行:
```sh
npm ci --prefix scripts/tang-cos --ignore-scripts --no-audit --no-fund
node scripts/tang-cos/upload.mjs inventory
node --test scripts/tang-cos/upload.test.mjs
```
在得到服务器管理员提供的可信 CA 文件后,将 `TANG_DB_CA_FILE` 设为该文件路径;若证书已受系统信任,可用 Node.js 的 `--use-system-ca`。不得把抓取到的陌生对端证书直接设为信任根。工具始终启用证书链和主机名校验。
```sh
node scripts/tang-cos/upload.mjs inspect
node scripts/tang-cos/upload.mjs upload
```
`inspect` 只通过只读 SQL 查询 `server/config/database.php` 配置的 `${prefix}config` 表中 `storage/default``storage/qcloud` 两项。仅输出目标桶、地域、公开域名和凭据存在状态,不输出数据库连接秘密或 COS 密钥。当前没有 `server/.env`;若以后新增,工具会停止而非猜测 PHP 环境解析规则,应在原生服务器运行环境中确认配置读取方式。
`upload` 使用腾讯官方 `cos-nodejs-sdk-v5`,只执行逐对象 HEAD/PUT,不经过会写文件表的后台上传接口。每个对象键为:
```text
tang-detective/season-01/media-v1/<完整SHA256>.<原扩展名>
```
上传前核验所有快照文件;已有对象必须大小及 SHA-256 元数据相符,否则停止。PUT 使用 MD5 传输校验、SHA-256 元数据及禁止覆盖请求头,不设置 ACL、不删除对象。内容寻址降低误覆盖风险;禁止覆盖请求头不能替代已有对象检查。
每个对象上传后必须通过**无凭据的精确 HTTPS 地址**完整下载,核对大小、SHA-256 和媒体类型;音频/视频还检查 `Range` 分段读取。首个对象无法公开读取便停止,绝不自动把桶改为公开。此时最多留下首个已写入对象,由回执记录,工具不自动删除。
只有全部对象成功后才生成 `build/tang-detective-cos-manifest.json`,一一保留 204 个原路径映射。该文件不存在时构建继续使用本地媒体;不得手写虚假成功清单。逐对象上传/复用及验证证据写入 `build/tang-detective-cos-upload-receipt.json`;每次显式上传均有独立运行ID,旧回执保留到 `build/tang-detective-cos-upload-attempts/`。前置配置失败也记录为本次失败,不能借用上次成功结论。PUT 发出前持久化 `unknown`,响应不确定时做只读复查,未能确认则保持未知,不写成“确定未上传”。工具错误只输出脱敏错误码,不输出签名 URL 或原始 SDK 错误。数据库建连、查询和关闭分别有8秒、8秒、2秒截止时间,超时仅销毁本任务连接。
## 后续验收边界
本轮已执行的本地验证:
- `npm run test:tang`:72/72通过(既有44项、远程素材13项、上传工具15项);远程测试只用独占临时目录中的模拟地址与响应。
- 微信生产构建、H5生产构建均退出0;未启动网页预览或开发服务。
- 实际微信产物校验通过:24页、204媒体、`mediaMode: local`。原始文件总量32,595,927字节、主包3,157,378字节,和原本地模式一致;**不能把模拟远程输出的节省量说成实际包体积已解决**。
- 本机微信编译器检查24页WXML/WXSS通过;不等于模拟器交互或真机运行。
- 原来源和迁入快照各473文件逐项SHA-256复核,无漂移。
- 默认本地模式以及模拟远程模式的相对模块依赖检查均通过;远程模式覆盖120页原图哈希、112正式/8临时层级、8段第一回试听状态、断网图片最终文字回退、迟到事件、分享坏缓存和输出符号链接保护。
- 真实COS上传、无凭据远程下载、真实音频Range和微信合法域名尚未验证;没有真实启用清单或上传回执。
完整下载哈希证明文件传输一致,不代表声音已获听审、商用/克隆授权、医学审核或微信真机通过。第一回内部试听与未配置的后续整页音频审核状态均不得因上传而提升。
完成真实上传、地址切换和构建后,仍需核对微信后台的请求/下载合法域名及对应 HTTPS 证书,实测首屏、翻页、分享、试听、后台暂停和断网回退。此次未修改微信后台配置,也未部署服务器或发布小程序。
@@ -0,0 +1,39 @@
# 唐侦探微信开发者工具检查
日期:2026-09-08。结论:前端宿主可启动,但服务端阅读接口检查未通过,不能认定完整接入可用。
## 实际观察
- 微信开发者工具 RC 2.02.2607171;迁移项目运行日志标注基础库 3.17.2。
- 当前项目路径:`/Users/dagedagededagege/Pictures/xuetang/TUICallKit-Vue3/dist/build/mp-weixin`
- 实际模拟器显示 `pages/index/index` 的宿主首页。
- 控制台两次读取均可见:
- `GET https://admin.zhenyangtang.com.cn/api/tang/catalog 404`
- `GET https://admin.zhenyangtang.com.cn/api/tang/progress 404`
- 这些证据说明当时配置地址上的章节目录和阅读存档请求失败;仅凭404不能区分未部署、路由失效、代理转发或地址指向错误。没有因此修改服务器或部署代码。
## 操作与证据边界
主Agent先识别出原先打开的是 `Work/TUICallKit-Vue3/zyt-migration/...` 的旧三消项目,再经导入对话框选中本次迁移目录。在准备隔离测试副本期间,工具报告用户改变了应用状态;重新读取发现用户已将原迁移项目打开并操作。主Agent没有点击该导入对话框的“创建”,没有把用户后续操作记为自己完成的测试。
因此,本轮已核实的是正确项目的实际界面与失败请求;不是主Agent完成了一遍《唐侦探》玩法流程。入口、C01/C15翻页、试听播放/暂停、退出后声音停止、真机及云端保存均没有本轮完整通过证据。
只读源码核对发现宿主启动可能自动登录,返回学堂还可能关联健康上下文;不能直接把同AppID的新目录当作独立游客。主Agent准备过独立临时副本:原app入口字节保留,前置内存存储和业务请求阻断。代码审查在假wx环境中验证了15种替换失败均会阻止宿主执行,但副本**没有导入开发者工具运行**,不能用这项检查冒充模拟器测试或全包沙箱认证。
本轮没有修改产品源码、没有清理用户存储、没有主动提交健康资料或执行上传/发布。用户打开的原项目可能已自动发起其他请求;本轮没有审计其全部请求及副作用,不声称该原项目全程离线。
## 当时产物标识
| 文件 | SHA-256 |
| --- | --- |
| `app.js` | `1161a9f9dab28d3ae592e3b13bcdf3ffaf81a214e2d7e751d7003c143865e66c` |
| `app.json` | `bd6aa0028069fd2b0801681e2e62ab8faf3ddc9544ea5375e8f7db5c33e6c92e` |
| `tang-detective/.native-import-state.json` | `8518b430dbe156a19a6dd317034e8935090e7be805337ab35c29c944f5721b32` |
## 下一步
先由具备权限的执行者确认当前域名的服务端部署及路由,使两个GET接口返回契约规定的响应,再在独立测试账号下验证保存接口、完整页面路径和试听。COS仍未上传,不应把当前本地媒体表现当作云端媒体通过。
实际专业Agent:微信开发专业Agent核对测试路线与自动写入边界;代码审查专业Agent复核临时隔离代码。未启动其他专业Agent。
资源:临时副本未运行,收尾删除该任务独占副本;原素材与产物保留。现有旧项目以及用户新打开的迁移项目窗口均保留,没有关闭用户工作窗口。本轮没有启动播放器、开发服务或常驻后台进程。
@@ -0,0 +1,78 @@
# 唐侦探接入学堂:本地实现与交接
日期:2026-09-08。目标仓库为 `Pictures/xuetang`,前端 `TUICallKit-Vue3`,后端 `server`
已完成本地代码接入。**尚不是已部署、可上传或真机验收通过的版本**:全量素材保留后包体积仍需处理;数据库迁移、真实接口联调和真机体验未执行。
COS 素材迁移补充见 [TANG-DETECTIVE-COS.md](TANG-DETECTIVE-COS.md):现已核齐204个媒体原路径,但配置数据库证书验证未通过,真实上传和地址切换尚未执行。下面的本地包体积/测试数据为迁移接入基线,不应当作远程素材模式的验收结果。
## 入口与保留内容
血糖管理页 `tongji/pages/weekly.vue` 增加《唐侦探》图标卡片,与现有《识糖小课堂》三消并列。点击经 `tongji/tang-detective/index.vue` 进入原生横屏故事;游戏首页增加“返回学堂”和阅读存档状态。入口沿用宿主图标、绿色卡片和文字层级,不改三消、血糖记录、通话或登录流程。
保留原十五回、六个主页面、十五个章节页面、三个音频播放器,共24页及18个原生分包;图片和音频原字节复制,不生成或重新编码。
原来源:`/Users/dagedagededagege/Documents/Codex/2026-07-22/new-chat/wechat-miniprogram/miniprogram`。原目录未修改。473文件快照在 `native/tang-detective/`SHA-256清单在 `build/tang-detective-source-manifest.json`。不导入旧 `app.js`、私有工程配置或凭据;原 `app.json` 仅作为构建元数据。迁移修改均在构建插件和 `native-adapter/tang-detective/` 中完成。
H5可构建并显示明确的微信运行提示,**没有把原生游戏转换成网页可玩版本**。原配音的人工审核和远程音频配置状态保持不变;不能把迁移当作试听、授权或发布验收。
## 接口与存档
三个独立接口使用现有 `token` 请求头:
| 方法 | 路径 | 用途 |
| --- | --- | --- |
| GET | `/api/tang/catalog` | 十五回合法章节、事件、页码、卡片目录 |
| GET | `/api/tang/progress` | 当前登录用户的阅读存档 |
| POST | `/api/tang/saveProgress` | 修订匹配的替换/重置、幂等确认 |
前端和宿主共用 `config/api.js``API_BASE_URL`;原生构建生成独立的只含公开地址的 `utils/platformConfig.js`。需要调试另一套服务器时修改该公开地址并重建;不要在文件内写token或密钥。当前默认地址沿用宿主配置,本轮未请求该地址。
存档桥仅上传白名单ID、已完成事件、当前页和修订;不上传健康回答、正文快照、音频、偏好、患者信息或账号凭据。后端新增独立用户存档表,不写三消排行榜。
游客记录仅留本机,不自动转到登录账号。服务端确认用户ID后隔离本机存档;token指纹只用于本机身份映射,不保存另一份token。续签同一账号可恢复其本机待同步队列。旧账号页面回调不能写入新账号存档。原版旧存储键保留、不自动导入。
断网保留本机进度;保存请求串行、600ms合并,超时8秒。超时重试复用原请求ID和正文。发生版本/故事代次冲突时暂停自动覆盖,首页点击存档状态选择云端或本机,确认前保留可恢复的本机备份。重置保留收藏,服务端递增故事代次。永久契约错误不在页面切换时自动重试。
后端完整契约、错误码和验证边界见 `../server/docs/tang-detective-progress.md`(从仓库根:`server/docs/tang-detective-progress.md`),机器契约为 `server/docs/tang-detective.openapi.yaml`
## 怎样运行
在前端目录执行:
```sh
npm ci --ignore-scripts --no-audit --no-fund
# 仅首次、TUICallKit尚不存在时,按项目原README准备SDK:
test -e TUICallKit || cp -R node_modules/@trtc/calls-uikit-wx-uniapp TUICallKit
test -f static/RTCCallEngine.wasm.br || cp node_modules/@trtc/call-engine-lite-wx/RTCCallEngine.wasm.br static/RTCCallEngine.wasm.br
npm run test:tang
npm run build:mp-weixin
npm run check:tang-output
node scripts/check-tang-native-compiler.cjs
```
原生编译检查使用本机已安装的微信开发者工具内 `wcc`/`wcsc`,不打开应用、不上传、不播放。其他机器可通过 `TANG_WECHAT_COMPILER_DIR` 指向其编译器目录。
微信产物为 `dist/build/mp-weixin`。使用微信开发者工具打开该目录进行后续模拟器/真机检查;本轮没有自动打开用户已有工具窗口。调试构建仍用原项目 `npm run dev:mp-weixin`,开发监听及工具窗口由启动者负责关闭。
后端部署前由有权限的人核对数据库前缀、备份及运行环境,再审阅 `server/sql/1.9.20260908/add_tang_detective_progress.sql`;该脚本本轮未执行。先具备表和接口,再验证真实登录同步。不要运行现有三消上报接口来验证唐侦探。
## 实测与缺口
- 微信生产构建、H5生产构建:退出码0。
- `npm run test:tang` 共44项通过:7项导入测试、27项存档桥/基础生命周期测试、10项加载真实章节与播放器的生命周期回归;不访问真实API或数据库。
- 产物校验:24页、204份媒体、328个相对模块依赖;473份源快照与原目录哈希无漂移。
- 本机微信编译器:24页WXML、WXSS编译退出码0,无诊断输出。只证明本机编译语法,不证明页面交互/设备表现。
- 后端15章、60事件、120页、15卡ID逐项核对通过;OpenAPI YAML解析通过。
- 两份PHP契约脚本已写,因本机没有PHP运行时而**未执行**;也没有真实MySQL、HTTP中间件、并发数据库或线上联调证据。
- 未做模拟器交互、横屏小屏布局、真机、音频试听、目标老人/医生审核、上传或公开发布。
独立代码审查发现并复核闭环了5项迁移问题:离线重置后的新阅读保护、卸载资源清理、旧账号确认回调隔离、队列落盘失败后的恢复、播放器延迟初始化生命周期。复核仍限本地代码与模拟平台接口,不升级为真机或服务器验收。
当前原始文件主包3,157,378字节、总包32,595,927字节(约3.16MB和32.60MB,非微信上传压缩体积)。精确分包数据与哈希见 `build/tang-detective-native-validation.json`。**不得直接宣称可上传**;需要后续分包/受控静态资源托管方案及真实包分析,不应靠删除章节、改画质或关闭检查掩盖问题。
## 回退与资源
要停用入口,可回退本次入口/路由/构建插件接入;不要删除原项目、用户存档或新增存档表。保留快照和适配层便于逐项比较。没有提交或推送Git,没有部署服务器。
本任务的构建/测试/原生编译子进程均结束;测试临时目录已清理。依赖、SDK、本地构建结果和交接文件保留。没有打开浏览器、播放器或本地服务;用户已有应用与服务未触碰。
+3 -2
View File
@@ -1,5 +1,6 @@
import App from './App' import App from './App'
var baseUrl ='https://admin.zhenyangtang.com.cn/'; import { API_BASE_URL } from './config/api.js'
var baseUrl = API_BASE_URL;
function joinApiUrl(base, path) { function joinApiUrl(base, path) {
const b = String(base || '').replace(/\/+$/, '') const b = String(base || '').replace(/\/+$/, '')
@@ -168,4 +169,4 @@ export function createApp() {
app app
} }
} }
// #endif // #endif
@@ -0,0 +1,99 @@
const chapters = require('../../data/chapters')
const {
getProgress,
saveProgress,
resetStoryProgress,
getSettings,
} = require('../../utils/storage')
const { chapterRoute } = require('../../utils/chapterRoute')
const bridge = require('../../utils/platformBridge')
Page({
data: {
chapters: [],
fontScale: 'large',
catalogListEnded: false,
},
onShow() {
const progress = getProgress()
const settings = getSettings()
const completed = new Set(progress.completedChapters)
const lastChapter = Number(progress.lastChapter) || 1
const completedHotspots = progress.completedHotspots || {}
this.setData({
chapters: chapters.map((chapter) => ({
...chapter,
completed: completed.has(chapter.chapterId),
current: chapter.number === lastChapter,
readingLabel: completed.has(chapter.chapterId)
? '重新翻看这一回'
: chapter.number === lastChapter
? '上次读到这里'
: '翻开这一回',
eventCount: Array.isArray(completedHotspots[chapter.chapterId])
? completedHotspots[chapter.chapterId].length
: 0,
})),
lastChapter,
fontScale: settings.fontScale === 'xlarge' ? 'xlarge' : 'large',
catalogListEnded: false,
})
},
markListEnd() {
if (!this.data.catalogListEnded) {
this.setData({ catalogListEnded: true })
}
},
openChapter(event) {
const chapter = Number(event.currentTarget.dataset.chapter)
const progress = getProgress()
const chapterMeta = chapters.find((item) => item.number === chapter)
const replay = Boolean(
chapterMeta
&& progress.completedChapters.includes(chapterMeta.chapterId),
)
progress.lastChapter = chapter
saveProgress(progress)
wx.navigateTo({ url: chapterRoute(chapter, { replay }) })
},
restartStory() {
const expectedScope = bridge.getScope()
const visibleGeneration = this.__tangShowGeneration
wx.showModal({
title: '从第一回重新开始?',
content: '关卡进度会清空;已经收藏的“我的桂香岁月”和大字设置会保留。',
confirmText: '重新开始',
cancelText: '先不清空',
confirmColor: '#9f3028',
success: (result) => {
if (!result || !result.confirm || this.__tangDead || !this.__tangVisible
|| visibleGeneration !== this.__tangShowGeneration
|| expectedScope !== bridge.getScope()) return
if (!resetStoryProgress(expectedScope)) {
wx.showToast({ title: '进度暂时没有清空,请稍后再试', icon: 'none' })
return
}
wx.redirectTo({ url: chapterRoute(1) })
},
})
},
goBack() {
wx.reLaunch({ url: '/tang-detective/pages/home/home' })
},
onShareAppMessage() {
return {
title: '唐侦探:翻开十五回桂香故事',
path: '/tang-detective/pages/catalog/catalog',
}
},
onShareTimeline() {
return { title: '唐侦探:翻开十五回桂香故事', query: '' }
},
})
@@ -0,0 +1,184 @@
const memoryCards = require('../../data/memoryCards')
const { getProgress } = require('../../utils/storage')
const { getCollectedMemories } = require('../../utils/memoryCollection')
const { chapterRoute } = require('../../utils/chapterRoute')
const bridge = require('../../utils/platformBridge')
const STATUS_TEXT = {
guest: '游客阅读 · 仅存本机', offline: '离线存档 · 点此重试', pending: '本机已保存 · 等待同步',
connecting: '正在连接存档…', syncing: '本机已保存 · 正在同步', synced: '阅读存档已同步',
conflict: '存档有冲突 · 点此选择', 'auth-expired': '登录已失效 · 本机记录保留',
'storage-error': '本机未存成功 · 请检查空间', 'version-error': '存档版本不一致 · 仅本机阅读',
'sync-error': '存档未同步 · 请更新后重试',
}
const {
getHomeLayoutMetrics,
getHomeLayoutStyle,
hasReadingProgress,
} = require('./homeLayout')
Page({
data: {
tangStatusText: '正在读取存档…',
coverOpened: false,
completedCount: 0,
lastChapter: 1,
hasReadingProgress: false,
memoryCount: 0,
homeLayoutStyle: [
'height:390px',
'--home-top-inset:8px',
'--home-right-inset:10px',
'--home-bottom-inset:7px',
'--home-left-inset:10px',
].join(';'),
},
onLoad() {
this.unsubscribeTang = bridge.subscribe(() => this.refreshTangStatus())
this.refreshTangStatus()
this.applyHomeLayout()
},
onShow() {
this.refreshHomeProgress()
this.refreshTangStatus()
},
onUnload() {
if (this.unsubscribeTang) this.unsubscribeTang()
},
refreshHomeProgress() {
const progress = getProgress()
this.setData({
completedCount: progress.completedChapters.length,
lastChapter: progress.lastChapter || 1,
hasReadingProgress: hasReadingProgress(progress),
memoryCount: getCollectedMemories(progress, memoryCards).length,
})
this.applyHomeLayout()
},
refreshTangStatus() {
this.setData({ tangStatusText: STATUS_TEXT[bridge.getStatus()] || '本机阅读存档' })
},
async syncTang() {
const scope = bridge.getScope()
const visibleGeneration = this.__tangShowGeneration
const stillActive = () => !this.__tangDead && this.__tangVisible
&& this.__tangShowGeneration === visibleGeneration && bridge.getScope() === scope
if (!wx.getStorageSync('token')) {
wx.showToast({ title: '请先在学堂登录,游客记录不会自动上传', icon: 'none' })
return
}
await bridge.open(true)
if (!stillActive()) return
if (bridge.getStatus() !== 'conflict') { this.refreshHomeProgress(); return }
const conflictContext = bridge.getConflictContext()
wx.showActionSheet({
itemList: ['采用云端存档', '用本机存档覆盖云端'],
success: result => {
if (!stillActive() || bridge.getConflictContext() !== conflictContext) return
const choice = result.tapIndex === 0 ? 'cloud' : 'local'
wx.showModal({
title: '确认阅读存档',
content: choice === 'cloud' ? '将用云端进度替换当前本机进度。原记录会保留一份本机备份。'
: '将用本机进度覆盖云端,其他设备的未同步阅读可能不在其中。原记录会保留一份本机备份。',
confirmText: '确认使用',
success: async answer => {
if (answer.confirm && stillActive()) {
await bridge.resolveConflict(choice, conflictContext)
if (stillActive()) this.refreshHomeProgress()
}
},
})
},
})
},
returnToXuetang() {
bridge.flush()
wx.reLaunch({ url: '/tongji/pages/weekly' })
},
onResize(resizeInfo) {
this.applyHomeLayout(resizeInfo)
},
applyHomeLayout(resizeInfo = null) {
let windowInfo = {}
try {
windowInfo = wx.getWindowInfo
? wx.getWindowInfo()
: wx.getSystemInfoSync()
} catch (error) {
windowInfo = {}
}
const resizeSize = resizeInfo && resizeInfo.size
? resizeInfo.size
: resizeInfo
if (resizeSize) {
windowInfo = {
...windowInfo,
windowWidth: resizeSize.windowWidth || windowInfo.windowWidth,
windowHeight: resizeSize.windowHeight || windowInfo.windowHeight,
}
}
let menuRect = {}
try {
menuRect = wx.getMenuButtonBoundingClientRect
? wx.getMenuButtonBoundingClientRect()
: {}
} catch (error) {
menuRect = {}
}
const metrics = getHomeLayoutMetrics(windowInfo, menuRect)
this.setData({
homeLayoutStyle: getHomeLayoutStyle(metrics),
})
},
startGame() {
const chapter = this.data.lastChapter || 1
wx.navigateTo({
url: chapterRoute(chapter),
})
},
revealCover() {
if (this.data.coverOpened) return
this.setData({ coverOpened: true })
},
keepCoverOpen() {},
openCatalog() {
wx.navigateTo({ url: '/tang-detective/pages/catalog/catalog' })
},
openCast() {
wx.navigateTo({ url: '/tang-detective/pages/cast/cast' })
},
openMemories() {
wx.navigateTo({ url: '/tang-detective/pages/memories/memories' })
},
onShareAppMessage() {
return {
title: '唐侦探:在一桌饭里,看见被忽略的人',
path: '/tang-detective/pages/home/home',
}
},
onShareTimeline() {
return {
title: '唐侦探:在一桌饭里,看见被忽略的人',
query: '',
}
},
})
@@ -0,0 +1,69 @@
<view class="safe-shell home-shell" style="{{homeLayoutStyle}}">
<view class="tang-host-bar">
<button class="tang-host-button" catchtap="returnToXuetang" aria-label="退出唐侦探,返回学堂"> 返回学堂</button>
<button class="tang-sync-button" catchtap="syncTang">{{tangStatusText}}</button>
</view>
<view
class="home-comic-book {{coverOpened ? 'is-open' : 'is-closed'}}"
bindtap="revealCover"
aria-label="{{coverOpened ? '唐侦探漫画书已经翻开' : '唐侦探漫画封面,轻触翻开'}}"
>
<view class="home-cover-art" aria-label="桂香饭店人物群像漫画封面">
<image
class="home-cover-image"
src="/tang-detective/assets/comic/s01-c01/S01-C01-P02-missing-table-ensemble-v1.jpg"
mode="aspectFill"
></image>
<view class="home-cover-shade"></view>
<view class="home-cover-spine"><text>甄养堂 · 中国健康连环画</text></view>
<view class="home-cover-title-block">
<text class="home-cover-series">第一季</text>
<text class="home-cover-title">唐侦探</text>
<text class="home-cover-subtitle">桂香里的第十五桌</text>
</view>
<view wx:if="{{!coverOpened}}" class="home-cover-touch">
<text class="home-cover-touch-mark"></text>
<text>翻开瞧瞧</text>
</view>
</view>
<view
wx:if="{{coverOpened}}"
class="home-flyleaf paper-panel"
catchtap="keepCoverOpen"
aria-label="漫画书扉页"
>
<view class="home-brand">
<view class="seal">甄</view>
<view class="home-brand-copy">
<text class="home-brand-name">甄养堂</text>
<text class="home-brand-kind">一本能看、能点、能带回家聊的中国健康连环画</text>
</view>
</view>
<text class="home-flyleaf-title">桂香里的第十五桌</text>
<text class="home-season">第一季 · 一桌饭里的三代人</text>
<text class="home-quote">“一桌饭,不应该只有坐下的人,还应该有被看见的人。”</text>
<button
class="home-primary"
catchtap="startGame"
aria-label="{{hasReadingProgress ? '继续阅读上次看到的地方' : '翻开第一回'}}"
>
{{hasReadingProgress ? '接着上回往下看' : '翻开第一回'}}
</button>
<view class="home-tabs">
<button class="home-tab" catchtap="openCatalog" aria-label="打开十五回目录">目录</button>
<button class="home-tab" catchtap="openCast" aria-label="打开人物画谱">人物</button>
<button class="home-tab home-memory-tab" catchtap="openMemories" aria-label="打开我的桂香岁月">
岁月册<text wx:if="{{memoryCount > 0}}"> · {{memoryCount}}页</text>
</button>
</view>
<text class="home-tagline">先看画、听故事;翻到背面,再聊聊这回事。</text>
</view>
</view>
<text wx:if="{{coverOpened}}" class="home-disclaimer">这里聊的是日常生活,不替代诊断、处方或个体化治疗建议。</text>
</view>
@@ -0,0 +1,683 @@
@import "/tang-detective/shared.wxss";
.tang-host-bar { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex: 0 0 44px; padding-right: 92px; }
.tang-host-button, .tang-sync-button { margin: 0; min-height: 44px; padding: 0 12px; line-height: 44px; font-size: 16px; border-radius: 8px; color: #f3e5bd; background: #30251c; }
.tang-sync-button { font-size: 14px; }
.home-shell {
--home-top-inset: calc(8px + constant(safe-area-inset-top));
--home-right-inset: calc(10px + constant(safe-area-inset-right));
--home-bottom-inset: calc(7px + constant(safe-area-inset-bottom));
--home-left-inset: calc(10px + constant(safe-area-inset-left));
--home-top-inset: calc(8px + env(safe-area-inset-top));
--home-right-inset: calc(10px + env(safe-area-inset-right));
--home-bottom-inset: calc(7px + env(safe-area-inset-bottom));
--home-left-inset: calc(10px + env(safe-area-inset-left));
display: flex;
height: 100vh;
min-height: 0;
padding: var(--home-top-inset) var(--home-right-inset)
var(--home-bottom-inset) var(--home-left-inset);
gap: 6px;
overflow: hidden;
flex-direction: column;
}
.home-comic-book {
position: relative;
display: grid;
width: 100%;
min-height: 0;
flex: 1 1 auto;
overflow: hidden;
color: #33251b;
background: #1d130f;
border: 3px solid #9e845e;
border-radius: 10px;
box-shadow: 0 15px 34px rgba(0, 0, 0, 0.36);
grid-template-columns: minmax(0, 1fr);
}
.home-comic-book.is-open {
grid-template-columns: minmax(0, 1.45fr) minmax(280px, 1fr);
}
.home-cover-art {
position: relative;
min-width: 0;
min-height: 0;
overflow: hidden;
background: #241811;
}
.home-comic-book.is-open .home-cover-art {
border-right: 7px solid #6f2b23;
box-shadow: 10px 0 24px rgba(32, 19, 12, 0.34);
}
.home-cover-image,
.home-cover-shade {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.home-cover-shade {
pointer-events: none;
background:
linear-gradient(90deg, rgba(29, 18, 12, 0.68), transparent 24%, transparent 72%, rgba(29, 18, 12, 0.2)),
linear-gradient(0deg, rgba(31, 18, 12, 0.82), transparent 53%);
box-shadow: inset 0 0 70px rgba(30, 16, 9, 0.3);
}
.home-cover-spine {
position: absolute;
z-index: 3;
top: 0;
bottom: 0;
left: 0;
display: flex;
width: 46px;
align-items: center;
justify-content: center;
color: #f0d8a7;
background: rgba(74, 35, 26, 0.94);
border-right: 2px solid #c59a58;
font-family: "Songti SC", "STSong", serif;
font-size: 15px;
font-weight: 850;
letter-spacing: 3px;
writing-mode: vertical-rl;
}
.home-cover-title-block {
position: absolute;
z-index: 3;
left: 72px;
bottom: 54px;
display: flex;
max-width: 64%;
padding: 13px 19px 15px;
color: #f7e7c4;
background: rgba(43, 27, 18, 0.86);
border-left: 7px solid #a53a30;
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.28);
flex-direction: column;
}
.home-cover-series {
color: #e4c277;
font-size: 16px;
font-weight: 800;
letter-spacing: 4px;
}
.home-cover-title {
margin-top: 3px;
font-family: "Songti SC", "STSong", serif;
font-size: 43px;
font-weight: 900;
letter-spacing: 8px;
line-height: 1.05;
}
.home-cover-subtitle {
margin-top: 6px;
color: #f1d18c;
font-family: "Songti SC", "STSong", serif;
font-size: 25px;
font-weight: 900;
line-height: 1.2;
}
.home-cover-touch {
position: absolute;
z-index: 4;
right: 24px;
bottom: 22px;
display: flex;
min-height: 50px;
align-items: center;
gap: 9px;
padding: 8px 15px;
color: #fff0ca;
background: rgba(54, 34, 23, 0.9);
border: 2px solid #d7b36d;
border-radius: 999px;
font-size: 18px;
font-weight: 850;
box-shadow: 0 7px 18px rgba(0, 0, 0, 0.28);
}
.home-cover-touch-mark {
display: flex;
width: 31px;
height: 31px;
align-items: center;
justify-content: center;
color: #6c271f;
background: #f0d28c;
border-radius: 50%;
font-size: 28px;
line-height: 1;
}
.home-flyleaf {
display: flex;
box-sizing: border-box;
min-width: 0;
min-height: 0;
justify-content: center;
overflow: hidden;
padding: 16px 20px;
background:
repeating-linear-gradient(0deg, rgba(92, 61, 35, 0.03) 0, rgba(92, 61, 35, 0.03) 1px, transparent 1px, transparent 5px),
#f2e4bf;
flex-direction: column;
}
.home-flyleaf-title {
display: block;
margin-top: 9px;
color: #8d2d26;
font-family: "Songti SC", "STSong", serif;
font-size: 29px;
font-weight: 900;
line-height: 1.18;
}
.home-flyleaf .home-quote {
position: static;
display: block;
margin-top: 10px;
color: #4e3a2b;
font-size: 18px;
font-weight: 750;
line-height: 1.45;
}
.home-flyleaf .home-primary {
margin-top: 12px;
}
.home-flyleaf .home-tabs {
width: 100%;
min-width: 0;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.home-flyleaf .home-tab {
box-sizing: border-box;
width: 100%;
min-width: 0;
overflow: hidden;
padding: 5px 7px;
font-size: 16px;
white-space: nowrap;
}
.home-comic-book button::after {
border: 0;
}
.home-book {
display: grid;
width: 100%;
min-height: 0;
flex: 1;
overflow: hidden;
grid-template-columns: minmax(0, 3fr) minmax(285px, 2fr);
border: 2px solid #9e845e;
border-radius: 9px;
}
.home-art {
position: relative;
min-width: 0;
min-height: 0;
overflow: hidden;
background:
repeating-linear-gradient(0deg, rgba(65, 42, 25, 0.04) 0, rgba(65, 42, 25, 0.04) 1px, transparent 1px, transparent 5px),
#241811;
border-right: 5px solid var(--cinnabar);
}
.home-art-image,
.home-art-shade {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.home-art-shade {
pointer-events: none;
background: linear-gradient(0deg, rgba(35, 23, 16, 0.82), transparent 48%);
}
.home-memory-ribbon {
position: absolute;
z-index: 3;
top: 12px;
left: 12px;
display: flex;
min-height: 48px;
align-items: center;
gap: 7px;
padding: 6px 10px 6px 7px;
color: #4a3225;
background: rgba(241, 221, 178, 0.96);
border: 2px solid #9e754a;
border-radius: 5px;
box-shadow: 0 5px 14px rgba(25, 15, 8, 0.28);
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif;
font-size: 17px;
font-weight: 850;
white-space: nowrap;
}
.home-memory-ribbon-mark {
display: flex;
width: 28px;
height: 35px;
align-items: center;
justify-content: center;
color: #ffe9bb;
background: #963128;
clip-path: polygon(0 0, 100% 0, 100% 100%, 50% 78%, 0 100%);
font-size: 14px;
font-weight: 900;
}
.home-quote {
position: absolute;
z-index: 2;
right: 22px;
bottom: 18px;
left: 22px;
color: #f5e5c0;
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif;
font-size: 20px;
font-weight: 800;
line-height: 1.5;
}
.home-copy {
display: flex;
box-sizing: border-box;
min-width: 0;
min-height: 0;
justify-content: center;
overflow-x: hidden;
overflow-y: auto;
padding: 18px 24px;
flex-direction: column;
}
.home-brand {
display: flex;
align-items: center;
gap: 10px;
}
.home-brand .seal {
width: 48px;
height: 48px;
flex: none;
font-size: 27px;
}
.home-brand-copy {
display: flex;
color: #7f2a23;
flex-direction: column;
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif;
font-size: 18px;
font-weight: 850;
line-height: 1.35;
}
.home-brand-kind {
color: #765f45;
font-size: 16px;
font-weight: 700;
}
.home-title {
display: block;
margin-top: 10px;
font-size: 45px;
font-weight: 900;
letter-spacing: 7px;
line-height: 1.05;
}
.home-subtitle {
display: block;
margin-top: 4px;
color: #8d2d26;
font-size: 30px;
font-weight: 900;
line-height: 1.2;
}
.home-season {
display: block;
margin-top: 5px;
color: #6f5942;
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif;
font-size: 18px;
font-weight: 750;
}
.home-primary {
display: flex;
width: 100%;
min-height: 64px;
align-items: center;
justify-content: center;
margin-top: 16px;
padding: 8px 14px;
color: #fff0cb;
background: var(--cinnabar);
border: 3px solid #74231d;
border-radius: 8px;
box-shadow: 0 8px 18px rgba(111, 32, 27, 0.24);
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif;
font-size: 21px;
font-weight: 900;
}
.home-tabs {
display: grid;
gap: 9px;
margin-top: 10px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.home-tab {
position: relative;
display: flex;
min-width: 0;
min-height: 48px;
align-items: center;
justify-content: center;
padding: 6px 10px;
color: #4b3829;
background: #ead9b2;
border: 2px solid #997b54;
border-radius: 7px;
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif;
font-size: 18px;
font-weight: 850;
}
.home-memory-count {
display: flex;
min-width: 30px;
height: 26px;
align-items: center;
justify-content: center;
padding: 0 6px;
color: #f9edce;
background: #704b35;
border-radius: 999px;
font-size: 13px;
}
.home-tagline {
display: block;
margin-top: 10px;
color: #594431;
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif;
font-size: 18px;
font-weight: 700;
line-height: 1.48;
}
.home-disclaimer {
display: block;
flex: none;
color: #d7c49b;
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif;
font-size: 17px;
line-height: 1.35;
text-align: center;
}
@media (max-height: 620px) {
.home-copy {
justify-content: flex-start;
overflow-y: hidden;
padding: 12px 18px;
}
.home-brand .seal {
width: 42px;
height: 42px;
font-size: 24px;
}
.home-brand-copy {
font-size: 17px;
}
.home-brand-kind {
font-size: 15px;
}
.home-title {
margin-top: 7px;
font-size: 39px;
}
.home-subtitle {
font-size: 27px;
}
.home-season {
font-size: 17px;
}
.home-primary {
min-height: 60px;
margin-top: 11px;
font-size: 20px;
}
.home-tabs {
margin-top: 8px;
}
.home-tab {
min-height: 48px;
font-size: 17px;
}
.home-tagline {
display: block;
margin-top: 6px;
font-size: 16px;
line-height: 1.35;
}
.home-disclaimer {
font-size: 16px;
}
}
@media (max-width: 730px) {
.home-brand-kind {
display: none;
}
.home-brand {
max-width: calc(100% - 104px);
}
.home-title {
font-size: 39px;
letter-spacing: 5px;
}
.home-subtitle {
font-size: 26px;
}
.home-tagline {
display: block;
margin-top: 6px;
font-size: 16px;
line-height: 1.35;
}
.home-memory-ribbon {
top: 9px;
left: 9px;
min-height: 48px;
padding: 5px 8px 5px 6px;
font-size: 16px;
}
}
@media (max-height: 430px) {
.home-comic-book.is-open {
grid-template-columns: minmax(0, 1.3fr) minmax(292px, 1fr);
}
.home-cover-title-block {
left: 62px;
bottom: 34px;
padding: 9px 13px 10px;
}
.home-cover-series {
font-size: 13px;
}
.home-cover-title {
font-size: 34px;
letter-spacing: 6px;
}
.home-cover-subtitle {
font-size: 20px;
}
.home-cover-touch {
right: 16px;
bottom: 13px;
min-height: 48px;
font-size: 16px;
}
.home-flyleaf {
padding: 8px 12px;
}
.home-flyleaf .home-brand .seal {
width: 34px;
height: 34px;
font-size: 20px;
}
.home-flyleaf .home-brand-copy {
font-size: 14px;
}
.home-flyleaf-title {
margin-top: 5px;
font-size: 23px;
}
.home-flyleaf .home-season {
font-size: 14px;
}
.home-flyleaf .home-quote {
margin-top: 5px;
font-size: 15px;
line-height: 1.3;
}
.home-flyleaf .home-primary {
min-height: 50px;
margin-top: 6px;
font-size: 18px;
}
.home-flyleaf .home-tabs {
gap: 5px;
margin-top: 5px;
}
.home-flyleaf .home-tab {
min-height: 48px;
padding: 4px;
font-size: 14px;
}
.home-flyleaf .home-tagline {
margin-top: 4px;
font-size: 14px;
}
.home-copy {
justify-content: flex-start;
overflow-y: hidden;
padding: 8px 14px;
}
.home-brand .seal {
width: 36px;
height: 36px;
font-size: 22px;
}
.home-brand-copy {
font-size: 15px;
}
.home-brand-kind {
font-size: 13px;
}
.home-title {
margin-top: 4px;
font-size: 34px;
letter-spacing: 4px;
}
.home-subtitle {
margin-top: 2px;
font-size: 23px;
}
.home-season {
margin-top: 3px;
font-size: 15px;
}
.home-primary {
min-height: 60px;
margin-top: 7px;
padding: 6px 10px;
font-size: 19px;
}
.home-tabs {
margin-top: 5px;
}
.home-tagline {
display: block;
margin-top: 4px;
font-size: 15px;
line-height: 1.2;
}
.home-disclaimer {
font-size: 14px;
line-height: 1.2;
}
}
@@ -0,0 +1,125 @@
// Transport-only lookup. Review eligibility continues to belong to the story
// and release manifests; an HTTPS URL never confers audio approval.
const manifest = require('./cosMediaManifest')
const paths = Object.create(null)
const urls = Object.create(null)
const SHARE_ASSET_ID = 'image.tang.share-preview'
for (const entry of manifest.entries) {
paths[entry.sourcePath] = entry
urls[entry.url] = entry
}
function entryFor(value) {
if (typeof value !== 'string') return null
if (urls[value]) return urls[value]
let relative = value.replace(/^\//, '')
if (relative.startsWith(`${manifest.namespace}/`)) {
relative = relative.slice(manifest.namespace.length + 1)
}
return paths[relative] || null
}
function resolve(value) {
const entry = entryFor(value)
return entry ? entry.url : ''
}
function verifiedUrl(value, sha256, kind) {
const entry = urls[value]
return entry && entry.sha256 === sha256 && entry.kind === kind
? entry.url : ''
}
function mapMedia(value) {
if (typeof value === 'string') {
if (/^\/(?:[a-z0-9-]+\/)?(?:assets|package-[a-z0-9-]+)\/.*\.(?:jpe?g|png|webp|gif|svg|avif|mp3|wav|aac|m4a|ogg|mp4|webm|mov)$/i.test(value)) {
// Unregistered declarations remain text-only; never guess an object URL.
return resolve(value)
}
return value
}
if (Array.isArray(value)) return value.map(mapMedia)
if (!value || typeof value !== 'object') return value
const mapped = {}
Object.keys(value).forEach(key => { mapped[key] = mapMedia(value[key]) })
return mapped
}
function beginComicImage(page, pageData) {
const previous = page._cosComicImage
if (!previous || previous.pageId !== pageData.currentPageId) {
page._cosComicImage = {
pageId: pageData.currentPageId,
generation: (previous ? previous.generation : 0) + 1,
failed: Object.create(null),
lastPatch: null,
}
}
const state = page._cosComicImage
pageData.comicImageGeneration = state.generation
// Ordinary reader interactions re-render this page. They must not revive a
// URL that already failed or replace terminal text fallback with an image.
if (state.lastPatch) Object.assign(pageData, state.lastPatch)
}
function isCurrentComicImageEvent(page, event) {
const state = page._cosComicImage
const dataset = event && event.currentTarget && event.currentTarget.dataset
return Boolean(state && dataset && !page.__tangDead && !page.__tangOriginalUnloaded
&& page.__tangVisible !== false && page.data.comicImageSrc
&& state.pageId === page.data.currentPageId
&& dataset.cosPageId === state.pageId
&& Number(dataset.cosImageGeneration) === state.generation
&& dataset.cosImageSrc === page.data.comicImageSrc)
}
function recordComicImageError(page, event) {
if (!isCurrentComicImageEvent(page, event)) return false
const state = page._cosComicImage
const src = page.data.comicImageSrc
if (state.failed[src]) return false
state.failed[src] = true
return true
}
function canUseComicFallback(page, src) {
const state = page._cosComicImage
const entry = entryFor(src)
return Boolean(state && entry && entry.kind === 'image' && !state.failed[src])
}
function applyComicImageFallback(page, patch) {
if (page._cosComicImage) page._cosComicImage.lastPatch = { ...patch }
page.setData(patch)
}
function prepareSharePreview(page, assetManager) {
if (page._sharePreviewPromise) return page._sharePreviewPromise
const generation = page.__tangShowGeneration
const alive = () => !page.__tangDead && !page.__tangOriginalUnloaded
&& page.__tangVisible !== false && page.__tangShowGeneration === generation
// Resolve again for each share; the manager rehashes cached bytes before use.
const promise = Promise.resolve().then(() => assetManager.resolve(SHARE_ASSET_ID))
.then(result => {
if (!alive()) return ''
const localPath = result && result.available && result.uri || ''
page.setData({ sharePreviewLocalPath: localPath })
return localPath
})
.catch(() => {
if (alive()) page.setData({ sharePreviewLocalPath: '' })
return ''
})
.finally(() => {
if (page._sharePreviewPromise === promise) page._sharePreviewPromise = null
})
page._sharePreviewPromise = promise
return promise
}
module.exports = {
SHARE_ASSET_ID, entryFor, resolve, verifiedUrl, mapMedia, prepareSharePreview,
beginComicImage, isCurrentComicImageEvent, recordComicImageError,
canUseComicFallback, applyComicImageFallback,
}
@@ -0,0 +1,674 @@
const CACHE_INDEX_VERSION = 2
const CACHE_FOLDER = 'tang-detective-assets-v2'
const CACHE_INDEX_FILE = 'index.json'
const DEFAULT_IMAGE_BUDGET = 28 * 1024 * 1024
const DEFAULT_AUDIO_BUDGET = 32 * 1024 * 1024
const DEFAULT_AUDIO_MAX_ENTRIES = 32
const SHA256_ROUND_CONSTANTS = [
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5,
0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3,
0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc,
0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3,
0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5,
0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
]
function utf8Bytes(value) {
const encoded = encodeURIComponent(String(value || ''))
const bytes = []
for (let index = 0; index < encoded.length; index += 1) {
if (encoded[index] === '%') {
bytes.push(parseInt(encoded.slice(index + 1, index + 3), 16))
index += 2
} else {
bytes.push(encoded.charCodeAt(index))
}
}
return new Uint8Array(bytes)
}
function toBytes(value) {
if (typeof value === 'string') return utf8Bytes(value)
if (value instanceof ArrayBuffer) return new Uint8Array(value)
if (ArrayBuffer.isView(value)) {
return new Uint8Array(value.buffer, value.byteOffset, value.byteLength)
}
throw new Error('unsupported SHA-256 input')
}
function rotateRight(value, bits) {
return (value >>> bits) | (value << (32 - bits))
}
function sha256Hex(value) {
const bytes = toBytes(value)
const paddedLength = Math.ceil((bytes.length + 9) / 64) * 64
const message = new Uint8Array(paddedLength)
message.set(bytes)
message[bytes.length] = 0x80
const bitLength = bytes.length * 8
const highBits = Math.floor(bitLength / 0x100000000)
const lowBits = bitLength >>> 0
const lengthOffset = paddedLength - 8
message[lengthOffset] = (highBits >>> 24) & 0xff
message[lengthOffset + 1] = (highBits >>> 16) & 0xff
message[lengthOffset + 2] = (highBits >>> 8) & 0xff
message[lengthOffset + 3] = highBits & 0xff
message[lengthOffset + 4] = (lowBits >>> 24) & 0xff
message[lengthOffset + 5] = (lowBits >>> 16) & 0xff
message[lengthOffset + 6] = (lowBits >>> 8) & 0xff
message[lengthOffset + 7] = lowBits & 0xff
const hash = [
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a,
0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
]
const words = new Uint32Array(64)
for (let offset = 0; offset < paddedLength; offset += 64) {
for (let index = 0; index < 16; index += 1) {
const start = offset + index * 4
words[index] = (
(message[start] << 24)
| (message[start + 1] << 16)
| (message[start + 2] << 8)
| message[start + 3]
) >>> 0
}
for (let index = 16; index < 64; index += 1) {
const word15 = words[index - 15]
const word2 = words[index - 2]
const sigma0 = (
rotateRight(word15, 7)
^ rotateRight(word15, 18)
^ (word15 >>> 3)
)
const sigma1 = (
rotateRight(word2, 17)
^ rotateRight(word2, 19)
^ (word2 >>> 10)
)
words[index] = (
words[index - 16]
+ sigma0
+ words[index - 7]
+ sigma1
) >>> 0
}
let a = hash[0]
let b = hash[1]
let c = hash[2]
let d = hash[3]
let e = hash[4]
let f = hash[5]
let g = hash[6]
let h = hash[7]
for (let index = 0; index < 64; index += 1) {
const sum1 = rotateRight(e, 6) ^ rotateRight(e, 11) ^ rotateRight(e, 25)
const choice = (e & f) ^ (~e & g)
const temp1 = (
h + sum1 + choice + SHA256_ROUND_CONSTANTS[index] + words[index]
) >>> 0
const sum0 = rotateRight(a, 2) ^ rotateRight(a, 13) ^ rotateRight(a, 22)
const majority = (a & b) ^ (a & c) ^ (b & c)
const temp2 = (sum0 + majority) >>> 0
h = g
g = f
f = e
e = (d + temp1) >>> 0
d = c
c = b
b = a
a = (temp1 + temp2) >>> 0
}
hash[0] = (hash[0] + a) >>> 0
hash[1] = (hash[1] + b) >>> 0
hash[2] = (hash[2] + c) >>> 0
hash[3] = (hash[3] + d) >>> 0
hash[4] = (hash[4] + e) >>> 0
hash[5] = (hash[5] + f) >>> 0
hash[6] = (hash[6] + g) >>> 0
hash[7] = (hash[7] + h) >>> 0
}
return hash.map((word) => word.toString(16).padStart(8, '0')).join('')
}
function constantTimeEqualHex(left, right) {
const leftValue = String(left || '').toLowerCase()
const rightValue = String(right || '').toLowerCase()
const length = Math.max(leftValue.length, rightValue.length)
let difference = leftValue.length ^ rightValue.length
for (let index = 0; index < length; index += 1) {
const leftCode = index < leftValue.length ? leftValue.charCodeAt(index) : 0
const rightCode = index < rightValue.length ? rightValue.charCodeAt(index) : 0
difference |= leftCode ^ rightCode
}
return difference === 0
}
function trimSlash(value) {
return String(value || '').replace(/\/+$/, '')
}
function joinPath(left, right) {
return `${trimSlash(left)}/${String(right || '').replace(/^\/+/, '')}`
}
function normalizeIndex(value) {
if (
!value
|| value.version !== CACHE_INDEX_VERSION
|| !value.entries
|| typeof value.entries !== 'object'
) {
return {
version: CACHE_INDEX_VERSION,
entries: {},
}
}
return value
}
function callFs(fs, method, options) {
return new Promise((resolve, reject) => {
if (!fs || typeof fs[method] !== 'function') {
reject(new Error(`fs.${method} unavailable`))
return
}
fs[method]({
...options,
success: resolve,
fail: reject,
})
})
}
function createAssetPlatformFacade() {
if (typeof wx === 'undefined') return null
const env = Object.freeze({
USER_DATA_PATH: String(
wx.env && wx.env.USER_DATA_PATH || '',
),
})
return Object.freeze({
downloadFile(options) {
if (typeof wx.downloadFile !== 'function') {
if (options && typeof options.fail === 'function') {
options.fail(new Error('wx.downloadFile unavailable'))
}
return undefined
}
return wx.downloadFile(options)
},
getFileSystemManager() {
if (typeof wx.getFileSystemManager !== 'function') return null
return wx.getFileSystemManager()
},
env,
})
}
function download(assetPlatform, url) {
return new Promise((resolve, reject) => {
if (!assetPlatform || typeof assetPlatform.downloadFile !== 'function') {
reject(new Error('wx.downloadFile unavailable'))
return
}
assetPlatform.downloadFile({
url,
success(result) {
if (
result
&& result.statusCode >= 200
&& result.statusCode < 300
&& result.tempFilePath
) {
resolve(result)
return
}
reject(new Error(`download status ${result && result.statusCode}`))
},
fail: reject,
})
})
}
function safeCacheName(assetId, asset) {
const extensionMatch = String(asset.remotePath || '').match(/(\.[a-z0-9]+)$/i)
const extension = extensionMatch ? extensionMatch[1].toLowerCase() : '.bin'
const safeId = assetId.replace(/[^a-z0-9._-]/gi, '_')
return `${safeId}.${asset.sha256.slice(0, 12)}${extension}`
}
function createAssetManager(options = {}) {
const assetPlatform = options.assetPlatform || createAssetPlatformFacade()
const manifest = options.manifest || {}
const cdnBaseUrl = trimSlash(options.cdnBaseUrl)
const imageBudgetBytes = Number.isFinite(options.imageCacheBudgetBytes)
? Math.max(0, options.imageCacheBudgetBytes)
: DEFAULT_IMAGE_BUDGET
const audioBudgetBytes = Number.isFinite(options.audioCacheBudgetBytes)
? Math.max(0, options.audioCacheBudgetBytes)
: DEFAULT_AUDIO_BUDGET
const audioMaxEntries = Number.isFinite(options.audioCacheMaxEntries)
? Math.max(0, Math.floor(options.audioCacheMaxEntries))
: DEFAULT_AUDIO_MAX_ENTRIES
const downloadConcurrency = Number.isFinite(options.downloadConcurrency)
? Math.max(1, Math.floor(options.downloadConcurrency))
: 2
const now = typeof options.now === 'function' ? options.now : Date.now
const fs = assetPlatform
&& typeof assetPlatform.getFileSystemManager === 'function'
? assetPlatform.getFileSystemManager()
: null
const userDataPath = (
assetPlatform
&& assetPlatform.env
&& assetPlatform.env.USER_DATA_PATH
) || ''
const cacheRoot = userDataPath ? joinPath(userDataPath, CACHE_FOLDER) : ''
const indexPath = cacheRoot ? joinPath(cacheRoot, CACHE_INDEX_FILE) : ''
let initialized = false
let index = normalizeIndex(null)
let activeDownloads = 0
const inflight = new Map()
const downloadWaiters = []
function fallback(assetId, reason) {
return {
assetId,
available: false,
uri: '',
source: 'text-fallback',
reason,
}
}
async function ensureCacheFolder() {
if (!cacheRoot) return false
try {
await callFs(fs, 'mkdir', {
dirPath: cacheRoot,
recursive: true,
})
} catch (error) {
// EEXIST and older base-library mkdir failures are both safe to ignore.
}
return true
}
async function persistIndex() {
if (!indexPath) return
try {
await ensureCacheFolder()
await callFs(fs, 'writeFile', {
filePath: indexPath,
data: JSON.stringify(index),
encoding: 'utf8',
})
} catch (error) {
// Cache metadata must never block the text-first game.
}
}
async function fileExists(filePath) {
if (!filePath) return false
try {
await callFs(fs, 'access', { path: filePath })
return true
} catch (error) {
return false
}
}
async function fileMatchesSha256(filePath, expectedSha256) {
try {
const result = await callFs(fs, 'readFile', { filePath })
return constantTimeEqualHex(
sha256Hex(result.data),
expectedSha256,
)
} catch (error) {
return false
}
}
async function init() {
if (initialized) return
initialized = true
if (!indexPath) return
try {
const result = await callFs(fs, 'readFile', {
filePath: indexPath,
encoding: 'utf8',
})
index = normalizeIndex(JSON.parse(result.data))
} catch (error) {
index = normalizeIndex(null)
}
let changed = false
for (const [assetId, entry] of Object.entries(index.entries)) {
if (!manifest[assetId] || !(await fileExists(entry.filePath))) {
delete index.entries[assetId]
changed = true
}
}
if (changed) await persistIndex()
await enforceImageBudget()
await enforceAudioBudget()
}
function cacheBytes(kind) {
return Object.values(index.entries).reduce(
(total, entry) => (
entry.kind === kind
? total + Math.max(0, Number(entry.size) || 0)
: total
),
0,
)
}
function cacheEntries(kind) {
return Object.values(index.entries)
.filter((entry) => entry.kind === kind)
.length
}
function imageCacheBytes() {
return cacheBytes('image')
}
function audioCacheBytes() {
return cacheBytes('audio')
}
async function unlinkQuietly(filePath) {
try {
await callFs(fs, 'unlink', { filePath })
} catch (error) {
// A missing cache file is already evicted.
}
}
async function enforceImageBudget(protectedAssetId = '') {
let total = imageCacheBytes()
if (total <= imageBudgetBytes) return
const candidates = Object.entries(index.entries)
.filter(([assetId, entry]) => (
entry.kind === 'image' && assetId !== protectedAssetId
))
.sort((left, right) => (
(Number(left[1].lastAccessedAt) || 0)
- (Number(right[1].lastAccessedAt) || 0)
))
for (const [assetId, entry] of candidates) {
if (total <= imageBudgetBytes) break
await unlinkQuietly(entry.filePath)
total -= Math.max(0, Number(entry.size) || 0)
delete index.entries[assetId]
}
await persistIndex()
}
async function enforceAudioBudget(protectedAssetId = '') {
let total = audioCacheBytes()
let entries = cacheEntries('audio')
if (total <= audioBudgetBytes && entries <= audioMaxEntries) return
const candidates = Object.entries(index.entries)
.filter(([assetId, entry]) => (
entry.kind === 'audio' && assetId !== protectedAssetId
))
.sort((left, right) => (
(Number(left[1].lastAccessedAt) || 0)
- (Number(right[1].lastAccessedAt) || 0)
))
for (const [assetId, entry] of candidates) {
if (total <= audioBudgetBytes && entries <= audioMaxEntries) break
await unlinkQuietly(entry.filePath)
total -= Math.max(0, Number(entry.size) || 0)
entries -= 1
delete index.entries[assetId]
}
await persistIndex()
}
async function resolveCached(assetId, asset) {
const entry = index.entries[assetId]
if (!entry || entry.sha256 !== asset.sha256) return null
if (!(await fileExists(entry.filePath))) {
delete index.entries[assetId]
await persistIndex()
return null
}
if (!(await fileMatchesSha256(entry.filePath, asset.sha256))) {
await unlinkQuietly(entry.filePath)
delete index.entries[assetId]
await persistIndex()
return fallback(assetId, 'remote-integrity-failed')
}
entry.lastAccessedAt = now()
await persistIndex()
return {
assetId,
available: true,
uri: entry.filePath,
source: 'cache',
kind: asset.kind,
}
}
async function getTempFileSize(tempFilePath, response) {
if (Number.isFinite(response.fileSize)) return response.fileSize
try {
const statResult = await callFs(fs, 'stat', {
path: tempFilePath,
})
const stat = statResult && statResult.stats
return Math.max(0, Number(stat && stat.size) || 0)
} catch (error) {
return 0
}
}
async function fetchRemote(assetId, asset) {
if (!cdnBaseUrl || !asset.remotePath) {
return fallback(assetId, 'remote-disabled')
}
const response = await new Promise((resolve, reject) => {
const run = () => {
activeDownloads += 1
download(assetPlatform, joinPath(cdnBaseUrl, asset.remotePath))
.then(resolve, reject)
.finally(() => {
activeDownloads -= 1
const next = downloadWaiters.shift()
if (next) next()
})
}
if (activeDownloads < downloadConcurrency) run()
else downloadWaiters.push(run)
})
if (!(await fileMatchesSha256(response.tempFilePath, asset.sha256))) {
await unlinkQuietly(response.tempFilePath)
return fallback(assetId, 'remote-integrity-failed')
}
const size = await getTempFileSize(response.tempFilePath, response)
const exceedsCachePolicy = (
(asset.kind === 'image' && size > imageBudgetBytes)
|| (
asset.kind === 'audio'
&& (size > audioBudgetBytes || audioMaxEntries < 1)
)
)
if (!cacheRoot || exceedsCachePolicy) {
return {
assetId,
available: true,
uri: response.tempFilePath,
source: 'remote',
kind: asset.kind,
persistent: false,
}
}
try {
await ensureCacheFolder()
const filePath = joinPath(cacheRoot, safeCacheName(assetId, asset))
await callFs(fs, 'saveFile', {
tempFilePath: response.tempFilePath,
filePath,
})
index.entries[assetId] = {
assetId,
filePath,
kind: asset.kind,
sha256: asset.sha256,
size,
lastAccessedAt: now(),
}
await enforceImageBudget(assetId)
await enforceAudioBudget(assetId)
await persistIndex()
return {
assetId,
available: true,
uri: filePath,
source: 'remote',
kind: asset.kind,
persistent: true,
}
} catch (error) {
return {
assetId,
available: true,
uri: response.tempFilePath,
source: 'remote',
kind: asset.kind,
persistent: false,
}
}
}
async function resolve(assetId, resolveOptions = {}) {
await init()
const asset = manifest[assetId]
if (!asset) return fallback(assetId, 'unknown-asset')
if (
asset.kind === 'audio'
&& String(asset.reviewStatus || '').toLowerCase() !== 'approved'
) {
return fallback(assetId, 'audio-unapproved')
}
// 包内种子永远优先:首屏无需等网络,离线也能继续读和玩。
if (asset.localSeed && resolveOptions.preferRemote !== true) {
return {
assetId,
available: true,
uri: asset.localSeed,
source: 'local',
kind: asset.kind,
}
}
const cached = await resolveCached(assetId, asset)
if (cached) return cached
if (resolveOptions.allowRemote === false) {
if (asset.localSeed) {
return {
assetId,
available: true,
uri: asset.localSeed,
source: 'local',
kind: asset.kind,
}
}
return fallback(assetId, 'remote-disallowed')
}
if (inflight.has(assetId)) return inflight.get(assetId)
const request = fetchRemote(assetId, asset)
.catch(() => (
asset.localSeed
? {
assetId,
available: true,
uri: asset.localSeed,
source: 'local',
kind: asset.kind,
}
: fallback(assetId, 'remote-failed')
))
.finally(() => inflight.delete(assetId))
inflight.set(assetId, request)
return request
}
async function prefetch(assetIds) {
const ids = Array.isArray(assetIds) ? assetIds : []
return Promise.all(ids.map((assetId) => resolve(assetId)))
}
async function clearCache() {
await init()
const entries = Object.values(index.entries)
for (const entry of entries) await unlinkQuietly(entry.filePath)
index = normalizeIndex(null)
await persistIndex()
}
function getCacheStats() {
return {
entries: Object.keys(index.entries).length,
imageBytes: imageCacheBytes(),
imageBudgetBytes,
audioEntries: cacheEntries('audio'),
audioBytes: audioCacheBytes(),
audioBudgetBytes,
audioMaxEntries,
}
}
return {
init,
resolve,
prefetch,
clearCache,
getCacheStats,
getAsset(assetId) {
return manifest[assetId] || null
},
}
}
module.exports = {
CACHE_INDEX_VERSION,
DEFAULT_AUDIO_BUDGET,
DEFAULT_AUDIO_MAX_ENTRIES,
constantTimeEqualHex,
createAssetPlatformFacade,
createAssetManager,
sha256Hex,
}
@@ -0,0 +1,3 @@
const { createPlatformBridge } = require('./platformCore')
const config = require('./platformConfig')
module.exports = createPlatformBridge(wx, config)
@@ -0,0 +1,218 @@
const { sha256Hex } = require('./identityHash')
const { CONTENT_VERSION, emptyProgress, projectProgress } = require('./progressContract')
const copy = value => JSON.parse(JSON.stringify(value))
const KEY = 'tang-xuetang-v1:'
// Dependency-injected for offline tests. Tokens remain in the existing host key
// and request header only; local slots use a SHA-256 fingerprint, not the token.
function createPlatformBridge(platform, config) {
let active = null
const listeners = new Set()
function token() { try { return String(platform.getStorageSync('token') || '') } catch (_) { return '' } }
function read(key, fallback) { try { return platform.getStorageSync(key) || fallback } catch (_) { return fallback } }
function write(key, value) { try { platform.setStorageSync(key, copy(value)); return true } catch (_) { return false } }
function context() {
const current = token()
if (active && active.token === current) return active
if (active && active.timer) clearTimeout(active.timer)
const scope = KEY + (current ? sha256Hex(current) : 'guest')
const mapped = current ? read(scope + ':user', null) : null
const key = Number.isSafeInteger(mapped) && mapped > 0 ? KEY + 'user:' + mapped : scope
const cached = read(key, {})
active = { token: current, scope, key, verified: false, opening: null, flushing: null, timer: null,
status: current ? 'offline' : 'guest', remote: null,
state: { progress: emptyProgress(), revision: 0, story_generation: 0, user_id: null,
dirty: false, resetPending: false, serial: 0, pending: null, ...cached } }
return active
}
const current = c => active === c && token() === c.token
function notify(c, status) {
if (!current(c)) return
c.status = status
listeners.forEach(listener => { try { listener(status) } catch (_) {} })
}
function persist(c) {
const ok = write(c.key, c.state)
if (!ok) notify(c, 'storage-error')
return ok
}
async function request(c, path, method = 'GET', data) {
if (!current(c) || !c.token) throw new Error('SESSION_CHANGED')
return new Promise((resolve, reject) => platform.request({
url: String(config.apiBaseUrl).replace(/\/+$/, '') + '/api/tang/' + path,
method, data, timeout: 8000,
header: { token: c.token, 'content-type': 'application/json' },
success(result) {
if (!current(c)) return reject(new Error('SESSION_CHANGED'))
const body = result.data
if (result.statusCode !== 200 || !body || typeof body !== 'object') return reject(new Error('API_UNAVAILABLE'))
if (body.code === -1) { c.verified = false; notify(c, 'auth-expired'); return reject(new Error('AUTH_EXPIRED')) }
if (body.code !== 1) {
const error = new Error(body.data && body.data.error_code || 'API_UNAVAILABLE')
return reject(error)
}
resolve(body.data)
}, fail() { reject(new Error('NETWORK_UNAVAILABLE')) },
}))
}
function validateRemote(value) {
if (!value || value.schema_version !== 1 || value.content_version !== CONTENT_VERSION
|| !Number.isSafeInteger(value.user_id) || value.user_id <= 0
|| !Number.isSafeInteger(value.revision) || value.revision < 0
|| !Number.isSafeInteger(value.story_generation) || value.story_generation < 0
|| !value.progress || typeof value.progress !== 'object') throw new Error('CONTENT_MISMATCH')
return { ...value, progress: projectProgress(value.progress) }
}
function hydrate(c, remote) {
c.state.progress = { ...c.state.progress, ...remote.progress }
c.state.revision = remote.revision
c.state.story_generation = remote.story_generation
c.state.user_id = remote.user_id
c.state.pending = null
c.state.resetPending = false
c.state.dirty = false
const saved = persist(c)
if (saved) notify(c, 'synced')
return saved
}
function failure(c, error) {
if (!current(c) || error.message === 'SESSION_CHANGED') return
if (['AUTH_EXPIRED', 'AUTH_REQUIRED'].includes(error.message)) c.verified = false
const permanent = ['INVALID_REQUEST', 'PAYLOAD_TOO_LARGE', 'IDEMPOTENCY_CONFLICT', 'UNSUPPORTED_MEDIA_TYPE', 'METHOD_NOT_ALLOWED']
if (c.status !== 'storage-error') notify(c, ['AUTH_EXPIRED', 'AUTH_REQUIRED'].includes(error.message) ? 'auth-expired'
: ['CONTENT_MISMATCH', 'UNSUPPORTED_CONTENT_VERSION'].includes(error.message) ? 'version-error'
: permanent.includes(error.message) ? 'sync-error' : 'offline')
}
async function open(force = false) {
const c = context()
if (!c.token) return c.status
if (c.opening) return c.opening
if (c.verified && !force) return c.status
c.opening = (async () => {
c.verified = false
notify(c, 'connecting')
try {
const [catalog, raw] = await Promise.all([request(c, 'catalog'), request(c, 'progress')])
if (!current(c)) return 'session-changed'
if (!catalog || catalog.schema_version !== 1 || catalog.content_version !== CONTENT_VERSION) throw new Error('CONTENT_MISMATCH')
const remote = validateRemote(raw)
if (c.state.user_id && c.state.user_id !== remote.user_id) throw new Error('CONTENT_MISMATCH')
// Only the authenticated server identity may select a shared user slot.
// A renewed token can recover the same user's unsynced local queue.
const userKey = KEY + 'user:' + remote.user_id
if (c.key !== userKey) {
const candidate = read(userKey, null)
const saved = candidate && typeof candidate === 'object' && !Array.isArray(candidate) ? candidate : null
if (saved && !c.state.dirty) c.state = saved
else if (saved && c.state.dirty && !write(c.scope + ':previous-user-backup', saved)) throw new Error('STORAGE_UNAVAILABLE')
c.key = userKey
if (!write(c.scope + ':user', remote.user_id)) { notify(c, 'storage-error'); return c.status }
}
c.verified = true
c.state.user_id = remote.user_id
if (!c.state.dirty) hydrate(c, remote)
else if (c.state.pending) await flushContext(c) // retry the exact idempotent request first
else if (c.state.revision !== remote.revision || c.state.story_generation !== remote.story_generation) {
c.remote = remote; notify(c, 'conflict')
} else await flushContext(c)
} catch (error) { failure(c, error) }
finally { c.opening = null }
return c.status
})()
return c.opening
}
function schedule(c) {
if (c.timer) clearTimeout(c.timer)
c.timer = setTimeout(() => { c.timer = null; flushContext(c) }, 600)
}
async function flushContext(c) {
if (!current(c) || !c.verified || !c.state.dirty || ['conflict', 'storage-error', 'sync-error', 'version-error'].includes(c.status)) return
if (c.flushing) return c.flushing
// Defer preparation so the promise is assigned before any early exit.
// The outer finally also covers a failed durable queue write.
c.flushing = Promise.resolve().then(async () => {
try {
if (!current(c)) return
if (!c.state.pending) {
const operation = c.state.resetPending ? 'reset_story' : 'replace'
const progress = projectProgress(c.state.progress)
c.state.pending = { serial: operation === 'reset_story' ? c.state.resetAt : c.state.serial, body: {
schema_version: 1, content_version: CONTENT_VERSION,
base_revision: c.state.revision, story_generation: c.state.story_generation,
request_id: 'tang-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 14),
operation, progress: operation === 'reset_story'
? { ...emptyProgress(), collectedMemoryCards: progress.collectedMemoryCards } : progress,
} }
}
if (!persist(c)) return
const pending = copy(c.state.pending)
notify(c, 'syncing')
const remote = validateRemote(await request(c, 'saveProgress', 'POST', pending.body))
if (!current(c)) return
if (remote.user_id !== c.state.user_id) throw new Error('CONTENT_MISMATCH')
c.state.revision = remote.revision
c.state.story_generation = remote.story_generation
c.state.pending = null
// A reset created while another request was in flight must not be lost.
if (pending.body.operation === 'reset_story' && (c.state.resetAt || 0) <= pending.serial) c.state.resetPending = false
if (pending.serial === c.state.serial) hydrate(c, remote)
else if (persist(c)) { notify(c, 'pending'); schedule(c) }
} catch (error) {
if (error.message === 'PROGRESS_CONFLICT' && current(c)) {
try { c.remote = validateRemote(await request(c, 'progress')); notify(c, 'conflict') }
catch (readError) { failure(c, readError) }
} else failure(c, error)
}
}).finally(() => { c.flushing = null })
return c.flushing
}
function saveProgress(value, reset = false) {
const c = context()
// A delayed callback from an old page/account must not enter the new slot.
if (!value || value.__tangLocalScope !== c.scope) return false
c.state.progress = copy(value)
delete c.state.progress.__tangLocalScope
c.state.dirty = true
c.state.serial += 1
if (reset) { c.state.resetPending = true; c.state.resetAt = c.state.serial }
if (!persist(c)) return false
if (!['conflict', 'sync-error', 'version-error', 'auth-expired'].includes(c.status)) notify(c, c.token ? 'pending' : 'guest')
if (c.verified) schedule(c)
return true
}
function getConflictContext() {
const c = context()
return c.status === 'conflict' && c.remote ? JSON.stringify([
c.scope, c.remote.revision, c.remote.story_generation, c.state.serial,
]) : ''
}
async function resolveConflict(choice, expectedContext) {
const c = context()
if (!expectedContext || expectedContext !== getConflictContext() || !c.verified) return false
// Recoverable, account-scoped backup before either explicit resolution.
if (!write(c.key + ':conflict-backup', { local: c.state, remote: c.remote })) { notify(c, 'storage-error'); return false }
if (choice === 'cloud') { const saved = hydrate(c, c.remote); if (saved) c.remote = null; return saved }
if (choice !== 'local') return false
c.state.revision = c.remote.revision
c.state.story_generation = c.remote.story_generation
c.state.pending = null
c.remote = null
if (!persist(c)) return false
notify(c, 'pending')
await flushContext(c)
return c.status === 'synced'
}
return {
open, saveProgress, resolveConflict, getConflictContext,
getProgress: () => { const c = context(); return { ...copy(c.state.progress || {}), ...projectProgress(c.state.progress), __tangLocalScope: c.scope } },
getScope: () => context().scope,
getStatus: () => context().status,
flush: () => { const c = context(); if (c.timer) { clearTimeout(c.timer); c.timer = null } return flushContext(c) },
subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener) },
// Local preferences/audio are account-scoped and are never passed to request().
readLocal: (suffix, fallback) => copy(read(context().key + ':' + suffix, fallback)),
writeLocal: (suffix, value) => write(context().key + ':' + suffix, value),
dispose() { if (active && active.timer) clearTimeout(active.timer); listeners.clear(); active = null },
}
}
module.exports = { createPlatformBridge }
@@ -0,0 +1,41 @@
// Wire projection only: never send snapshots, answers, settings or audio state.
const CONTENT_VERSION = 'season-01'
const pad = value => String(value).padStart(2, '0')
const record = value => value && typeof value === 'object' && !Array.isArray(value) ? value : {}
const list = value => Array.isArray(value) ? value : []
function emptyProgress() {
return { completedHotspots: {}, completedChapters: [], lastChapter: 1,
collectedMemoryCards: [], comicReaderByChapter: {}, lastPageId: '' }
}
function projectProgress(input) {
const value = record(input)
const result = emptyProgress()
result.lastChapter = Number.isInteger(value.lastChapter) && value.lastChapter >= 1 && value.lastChapter <= 15 ? value.lastChapter : 1
for (let n = 1; n <= 15; n++) {
const id = `S01-C${pad(n)}`
const saved = record(record(value.comicReaderByChapter)[id])
const supplied = new Set(list(saved.completedEventIds || record(value.completedHotspots)[id]))
const events = []
for (let i = 1; i <= 4; i++) {
const event = `S01-H${pad((n - 1) * 4 + i)}`
if (!supplied.has(event)) break
events.push(event)
}
const finished = events.length === 4 && (typeof saved.chapterFinished === 'boolean'
? saved.chapterFinished : list(value.completedChapters).includes(id))
if (Object.keys(saved).length || events.length || Object.prototype.hasOwnProperty.call(record(value.completedHotspots), id)) {
const requested = String(saved.currentPageId || (n === result.lastChapter ? value.lastPageId : '') || '')
const match = requested.match(new RegExp(`^${id}-P(0[1-8])$`))
const page = Math.max(1, Math.min(finished ? 8 : 3 + events.length, match ? Number(match[1]) : 1))
const currentPageId = `${id}-P${pad(page)}`
result.completedHotspots[id] = events
result.comicReaderByChapter[id] = { currentPageId, completedEventIds: events.slice(), chapterFinished: finished }
if (finished) result.completedChapters.push(id)
if (n === result.lastChapter) result.lastPageId = currentPageId
}
const card = `${id}-MC01`
if (list(value.collectedMemoryCards).includes(card)) result.collectedMemoryCards.push(card)
}
return result
}
module.exports = { CONTENT_VERSION, emptyProgress, projectProgress }
@@ -0,0 +1,22 @@
const bridge = require('./platformBridge')
const { emptyProgress } = require('./progressContract')
function normalizeSettings(value = {}) {
return { ...value, fontScale: value.fontScale === 'xlarge' ? 'xlarge' : 'large', sound: value.sound !== false }
}
function resetStoryProgress(expectedScope) {
if (!expectedScope || bridge.getScope() !== expectedScope) return false
const current = bridge.getProgress()
const next = { ...current, ...emptyProgress(), collectedMemoryCards: current.collectedMemoryCards || [] }
if (!bridge.saveProgress(next, true)) return false
bridge.writeLocal('audio', {})
return true
}
module.exports = {
getProgress: bridge.getProgress,
saveProgress: value => bridge.saveProgress(value),
resetStoryProgress,
getSettings: () => normalizeSettings(bridge.readLocal('settings', {})),
saveSettings: value => bridge.writeLocal('settings', normalizeSettings(value)),
getAudioProgress: () => bridge.readLocal('audio', {}),
saveAudioProgress: value => bridge.writeLocal('audio', value),
}
@@ -0,0 +1,145 @@
// Keep the native Page lifecycle, but hydrate the correct account before any
// story page reads/writes storage. This also covers cold starts from shares.
const bridge = require('./platformBridge')
const LIFECYCLE_METHODS = new Set(['onLoad', 'onShow', 'onReady', 'onHide', 'onUnload'])
const CLEANUP_METHOD = /^(?:destroy|clear|unbind|pause|invalidate|beginPauseLock)/
module.exports = function registerTangPage(options) {
function isCurrent(page) {
return !page.__tangDead && !page.__tangOriginalUnloaded
&& page.__tangVisible && page.__tangScope === bridge.getScope()
}
function returnHome(page) {
if (page.__tangRedirected || page.__tangDead) return
page.__tangRedirected = true
wx.reLaunch({ url: '/tang-detective/pages/home/home' })
}
function cleanUp(page, name) {
if (!page.__tangLoaded || typeof options[name] !== 'function') return
if (name !== 'onUnload' && page.__tangOriginalUnloaded) return
if (name === 'onUnload') {
if (page.__tangOriginalUnloaded) return
page.__tangOriginalUnloaded = true
}
page.__tangCleaning = true
try {
options[name].call(page)
} finally {
// This exception is synchronous only; pending timers and audio callbacks
// regain the normal visible/account/dead guards as soon as cleanup ends.
page.__tangCleaning = false
}
}
function deliverReady(page) {
if (!isCurrent(page) || !page.__tangLoaded || !page.__tangHasShown
|| !page.__tangReadyRequested || page.__tangReadyDelivered) return
page.__tangReadyDelivered = true
if (typeof options.onReady === 'function') options.onReady.call(page)
}
const guarded = { ...options }
Object.keys(options).forEach(key => {
// Event handlers such as onAudioTimeUpdate are ordinary guarded methods,
// even though their names begin with "on".
if (typeof options[key] !== 'function' || LIFECYCLE_METHODS.has(key)) return
guarded[key] = function (...args) {
if (this.__tangCleaning) {
// Release resources across an account change, but do not run helpers
// such as saveCurrentAudioTime against the newly selected account.
if (this.__tangScope === bridge.getScope() || CLEANUP_METHOD.test(key)) {
return options[key].apply(this, args)
}
return
}
if (this.__tangDead || this.__tangOriginalUnloaded || !this.__tangLoaded || !this.__tangVisible) return
if (this.__tangScope && this.__tangScope !== bridge.getScope()) {
returnHome(this)
return
}
return options[key].apply(this, args)
}
})
Page({
...guarded,
data: { ...(options.data || {}), tangBootPending: true },
tangIgnoreBootTap() {},
onLoad(query) {
this.__tangQuery = query
this.__tangScope = bridge.getScope()
this.__tangDead = false
this.__tangVisible = false
this.__tangLoaded = false
this.__tangCleaning = false
this.__tangOriginalUnloaded = false
this.__tangRedirected = false
this.__tangShowGeneration = 0
this.__tangReadyRequested = false
this.__tangReadyDelivered = false
this.__tangHasShown = false
const nativeSetData = this.setData
this.setData = function (...args) {
if (this.__tangScope !== bridge.getScope()) return
if (!this.__tangCleaning && !isCurrent(this)) return
return nativeSetData.apply(this, args)
}
this.__tangBoot = bridge.open()
},
onShow() {
if (this.__tangDead) return
this.__tangVisible = true
const generation = ++this.__tangShowGeneration
const scope = bridge.getScope()
if (this.__tangOriginalUnloaded || scope !== this.__tangScope) {
returnHome(this)
return
}
Promise.resolve(this.__tangBoot).then(() => {
if (this.__tangDead || !this.__tangVisible || generation !== this.__tangShowGeneration) return
if (bridge.getScope() !== scope) {
returnHome(this)
return
}
if (!this.__tangLoaded) {
this.__tangLoaded = true
if (options.onLoad) options.onLoad.call(this, this.__tangQuery)
if (!isCurrent(this)) return
this.setData({ tangBootPending: false })
}
if (options.onShow) options.onShow.call(this)
this.__tangHasShown = true
deliverReady(this)
}).catch(() => {
if (isCurrent(this) && generation === this.__tangShowGeneration) {
wx.showToast({ title: '故事暂时未能打开,请返回重试', icon: 'none' })
}
})
},
onReady() {
this.__tangReadyRequested = true
deliverReady(this)
},
onHide() {
this.__tangVisible = false
++this.__tangShowGeneration
try {
cleanUp(this, 'onHide')
} finally {
// A hidden page from an obsolete account must not retain contexts with
// callbacks that directly reference storage instead of guarded methods.
try {
if (this.__tangScope !== bridge.getScope()) cleanUp(this, 'onUnload')
} finally {
bridge.flush()
}
}
},
onUnload() {
this.__tangDead = true
this.__tangVisible = false
++this.__tangShowGeneration
try {
cleanUp(this, 'onUnload')
} finally {
bridge.flush()
}
},
})
}
@@ -0,0 +1,161 @@
{
"pages": [
"pages/home/home",
"pages/catalog/catalog",
"pages/cast/cast",
"pages/memories/memories",
"pages/share/share",
"pages/report/report"
],
"subPackages": [
{
"root": "package-game",
"name": "game",
"pages": [
"pages/chapter/chapter"
]
},
{
"root": "package-audio-c01-a",
"name": "audio-c01-a",
"pages": [
"pages/player/player"
]
},
{
"root": "package-audio-c01-b",
"name": "audio-c01-b",
"pages": [
"pages/player/player"
]
},
{
"root": "package-audio-player",
"name": "audio-player",
"pages": [
"pages/player/player"
]
},
{
"root": "package-chapter-04",
"name": "chapter-04",
"pages": [
"pages/chapter/chapter"
]
},
{
"root": "package-chapter-02",
"name": "chapter-02",
"pages": [
"pages/chapter/chapter"
]
},
{
"root": "package-chapter-03",
"name": "chapter-03",
"pages": [
"pages/chapter/chapter"
]
},
{
"root": "package-chapter-05",
"name": "chapter-05",
"pages": [
"pages/chapter/chapter"
]
},
{
"root": "package-chapter-06",
"name": "chapter-06",
"pages": [
"pages/chapter/chapter"
]
},
{
"root": "package-chapter-07",
"name": "chapter-07",
"pages": [
"pages/chapter/chapter"
]
},
{
"root": "package-chapter-08",
"name": "chapter-08",
"pages": [
"pages/chapter/chapter"
]
},
{
"root": "package-chapter-09",
"name": "chapter-09",
"pages": [
"pages/chapter/chapter"
]
},
{
"root": "package-chapter-10",
"name": "chapter-10",
"pages": [
"pages/chapter/chapter"
]
},
{
"root": "package-chapter-11",
"name": "chapter-11",
"pages": [
"pages/chapter/chapter"
]
},
{
"root": "package-chapter-12",
"name": "chapter-12",
"pages": [
"pages/chapter/chapter"
]
},
{
"root": "package-chapter-13",
"name": "chapter-13",
"pages": [
"pages/chapter/chapter"
]
},
{
"root": "package-chapter-14",
"name": "chapter-14",
"pages": [
"pages/chapter/chapter"
]
},
{
"root": "package-chapter-15",
"name": "chapter-15",
"pages": [
"pages/chapter/chapter"
]
}
],
"preloadRule": {
"package-game/pages/chapter/chapter": {
"network": "all",
"packages": [
"audio-c01-a"
]
},
"package-audio-c01-a/pages/player/player": {
"network": "all",
"packages": [
"audio-c01-b"
]
}
},
"window": {
"navigationStyle": "custom",
"pageOrientation": "landscape",
"backgroundColor": "#201711",
"backgroundTextStyle": "light"
},
"lazyCodeLoading": "requiredComponents",
"style": "v2",
"sitemapLocation": "sitemap.json"
}
@@ -0,0 +1,167 @@
page {
--ink: #30251c;
--muted: #745f48;
--paper: #f3e5bd;
--paper-deep: #dfc995;
--cinnabar: #9f3028;
--cinnabar-dark: #6f201b;
--jade: #275f4e;
--jade-soft: #d7e4d5;
--danger-soft: #ead2c7;
--night: #211812;
min-height: 100%;
color: var(--ink);
background: var(--night);
font-family: "Songti SC", "STSong", "Noto Serif CJK SC", serif;
}
view,
text,
button,
image,
scroll-view {
box-sizing: border-box;
}
button {
margin: 0;
padding: 0;
color: inherit;
background: none;
border: 0;
border-radius: 0;
font: inherit;
line-height: inherit;
}
button::after {
border: 0;
}
.safe-shell {
width: 100vw;
min-height: 100vh;
padding:
calc(24rpx + constant(safe-area-inset-top))
calc(34rpx + constant(safe-area-inset-right))
calc(24rpx + constant(safe-area-inset-bottom))
calc(34rpx + constant(safe-area-inset-left));
padding:
calc(24rpx + env(safe-area-inset-top))
calc(34rpx + env(safe-area-inset-right))
calc(24rpx + env(safe-area-inset-bottom))
calc(34rpx + env(safe-area-inset-left));
background:
linear-gradient(90deg, rgba(255, 255, 255, 0.035), transparent 30%),
#211812;
}
.paper-panel {
color: var(--ink);
background:
repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.025) 0, rgba(85, 54, 31, 0.025) 1px, transparent 1px, transparent 6px),
var(--paper);
border: 2rpx solid #9e845e;
box-shadow: 0 18rpx 42rpx rgba(0, 0, 0, 0.24);
}
.seal {
display: flex;
width: 64rpx;
height: 64rpx;
align-items: center;
justify-content: center;
color: #fff0cd;
background: var(--cinnabar);
border: 3rpx solid #d99b72;
border-radius: 10rpx;
font-size: 38rpx;
font-weight: 800;
}
.eyebrow {
color: var(--cinnabar);
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif;
font-size: 20rpx;
font-weight: 700;
letter-spacing: 4rpx;
}
.primary-button,
.secondary-button,
.quiet-button {
display: flex;
min-height: 76rpx;
align-items: center;
justify-content: center;
padding: 0 30rpx;
border-radius: 12rpx;
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif;
font-size: 28rpx;
font-weight: 750;
}
.primary-button {
color: #fff1ce;
background: var(--cinnabar);
box-shadow: 0 10rpx 22rpx rgba(111, 32, 27, 0.25);
}
.secondary-button {
color: #fff0cf;
background: #3b2a20;
border: 2rpx solid #856646;
}
.quiet-button {
min-height: 60rpx;
padding: 0 22rpx;
color: var(--muted);
background: rgba(255, 248, 229, 0.62);
border: 2rpx solid #a78f68;
font-size: 24rpx;
}
.status-pill {
display: inline-flex;
align-items: center;
padding: 8rpx 16rpx;
border-radius: 999rpx;
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif;
font-size: 20rpx;
}
.status-pill.internal {
color: #7b231d;
background: #ead2b4;
border: 2rpx solid #b8744e;
}
@media (max-height: 620px) {
.safe-shell {
padding:
calc(10px + constant(safe-area-inset-top))
calc(14px + constant(safe-area-inset-right))
calc(10px + constant(safe-area-inset-bottom))
calc(14px + constant(safe-area-inset-left));
padding:
calc(10px + env(safe-area-inset-top))
calc(14px + env(safe-area-inset-right))
calc(10px + env(safe-area-inset-bottom))
calc(14px + env(safe-area-inset-left));
}
.primary-button,
.secondary-button,
.quiet-button {
min-height: 44px;
padding: 0 16px;
border-radius: 8px;
font-size: 18px;
}
.quiet-button {
min-height: 48px;
font-size: 16px;
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

@@ -0,0 +1,74 @@
module.exports = [
{
id: 'tang-shouan',
name: '唐守安',
title: '唐侦探/唐大夫',
eras: '1978—2026',
asset: '/assets/characters/tang-shouan.jpg',
role: '从厂卫生员到受邻里敬重的中医从业者;始终先观察、后开口,最后才真正入席。',
visual: '素色深靛蓝日常外套,旧棕布卫生包只暗示家学,不画成古装神医。',
},
{
id: 'qin-zhicheng',
name: '秦志成',
title: '秦师傅',
eras: '1978—2026',
asset: '/assets/characters/qin-zhicheng.jpg',
role: '从集体食堂小炊事员到桂香饭店老师傅,第十五桌的留下、挪走和回来都经过他的手。',
visual: '宽头、粗颈、宽肩、厚实前臂;中式粗棉厨工褂、白围裙和低矮布帽。',
},
{
id: 'lin-xiulan',
name: '林秀兰',
title: '票夹保管人',
eras: '1978—2026',
asset: '/assets/characters/lin-xiulan.jpg',
role: '守住饭票、账本和钥匙,也守住每个人把旧事慢慢讲清楚的权利。',
visual: '枣红或深棕工作外套;用动作摆事实,不竖指训人。',
},
{
id: 'zhao-jianguo',
name: '赵建国',
title: '赵伯/劳动骨干',
eras: '1978—2026',
asset: '/assets/characters/zhao-jianguo.jpg',
role: '年轻时把饭量当本事,后来学会不拿“为你好”替别人作决定。',
visual: '青年工装结实,老年深蓝便帽、盖碗茶;豪爽但不被画成莽汉或病号。',
},
{
id: 'tang-mingyuan',
name: '唐明远',
title: '中年家人',
eras: '1995—2026',
asset: '/assets/characters/tang-mingyuan.jpg',
role: '唐守安之子、乐乐之父,用久坐、应酬、晚吃饭和家庭责任接住现代中年人的困境。',
visual: '随年代逐渐出现腰腹与颈围变化;发福是生活轨迹,不作笑料或疾病诊断。',
},
{
id: 'qin-xiaoman',
name: '秦小满',
title: '饭店经营者',
eras: '2026',
asset: '/assets/characters/qin-xiaoman.jpg',
role: '把祖辈想留人的心意,转成纸单点餐、可选择份量和真实可用的座位。',
visual: '简洁现代中式工作装,软尺、纸质座位单和调台记录随身。',
},
{
id: 'lele',
name: '乐乐',
title: '会直问的小孙子',
eras: '2026',
asset: '/assets/characters/lele.jpg',
role: '从儿童视角照见大人的生活习惯;教育对象是家庭环境,不把责任推给孩子。',
visual: '圆润、活泼、讨人喜欢;不贴疾病、懒惰或减重标签。',
},
{
id: 'xiaozhen',
name: '小甄',
title: '甄养堂健康客服',
eras: '2026',
asset: '/assets/characters/xiaozhen.jpg',
role: '负责画外引导和知识回顾;需要时提醒联系专业医护,不替医生调整治疗。',
visual: '灰绿色开衫、白衬衫、低马尾与记录夹;不作广告式主角。',
},
]
@@ -0,0 +1,25 @@
const chapterTitles = [
['2026', '开席前,少了一张桌'],
['2026 → 1978', '铜牌背后的两张旧票'],
['1978', '厂铃一响,饭盆就响'],
['1978', '劳动骨干的第三碗饭'],
['1978', '第十五桌给谁留'],
['1993—1995', '木牌翻面的那一天'],
['1995', '桂香饭馆开张'],
['1998', '四凉八热才叫客气'],
['2001', '一杯酒绕了三圈'],
['2003', '后厨里的员工桌'],
['2008', '旧房子要拆了'],
['2026', '扫码点出一整桌'],
['2026', '三代人都说“我是为你好”'],
['2026', '秦师傅最后一道老菜'],
['2026', '第十五桌重新开席'],
]
module.exports = chapterTitles.map(([year, title], index) => ({
number: index + 1,
chapterId: `S01-C${String(index + 1).padStart(2, '0')}`,
year,
title,
audioStatus: index === 0 ? 'audition' : 'reserved',
}))
@@ -0,0 +1,246 @@
// Compact, main-package registry for the personal storybook.
// The wording mirrors each chapter's reviewed memory page. Keep this file free
// of gameplay logic so old collections can be restored without loading a game
// subpackage.
module.exports = [
{
cardId: 'S01-C01-MC01',
reportId: 'S01-C01-RP01',
chapterId: 'S01-C01',
chapterNumber: 1,
chapterTitle: '开席前,少了一张桌',
characterName: '赵建国',
eraLine: '2026 · 桂香大饭店宴会前厅',
tableEcho: '空着的位置,不是少摆一张桌,是在等一个人被叫到名字。',
lifeAction: '替家里人张罗之前,先问一句:“您想怎么来?”',
observation: '替家人安排得再周到,也别忘了问他的意思。',
familySupport: '先问赵伯想坐哪里,再一起安排座位。',
todayAction: '替家里人张罗之前,先问一句:“您想怎么来?”',
familyLine: '下回别急着替我安排,先叫我一起商量。',
eraObject: '手写请柬与十五号旧铜牌',
},
{
cardId: 'S01-C02-MC01',
reportId: 'S01-C02-RP01',
chapterId: 'S01-C02',
chapterNumber: 2,
chapterTitle: '铜牌背后的两张旧票',
characterName: '林秀兰',
eraLine: '2026 → 1978 · 桂香旧物展柜',
tableEcho: '旧东西会认错,慢一点核对,人就不会被轻易抹掉。',
lifeAction: '保留近照和尺寸,等待旧桌板出现。',
observation: '旧物和旧话都可能记错,慢一点核对更稳妥。',
familySupport: '听林秀兰把旧事说完,再一起找照片和尺寸。',
todayAction: '保留近照和尺寸,等待旧桌板出现。',
familyLine: '我说起旧事时,先让我慢慢说完;咱们再一起核对。',
eraObject: '十五号铜牌与两枚破损饭菜票',
},
{
cardId: 'S01-C03-MC01',
reportId: 'S01-C03-RP01',
chapterId: 'S01-C03',
chapterNumber: 3,
chapterTitle: '厂铃一响,饭盆就响',
characterName: '唐守安',
eraLine: '1978 · 桂香机械厂集体食堂',
tableEcho: '看见一个人,不只看他能干多少,也问他累不累。',
lifeAction: '进食前按条件把手清洁并擦干',
observation: '忙着干活的人,也需要被问一句累不累。',
familySupport: '开饭前互相提醒洗手,也问问今天累不累。',
todayAction: '进食前按条件把手清洁并擦干',
familyLine: '回家聊一聊:你想怎样给这个不起眼的小唐留一点位置?',
eraObject: '饭票与搪瓷饭盒',
},
{
cardId: 'S01-C04-MC01',
reportId: 'S01-C04-RP01',
chapterId: 'S01-C04',
chapterNumber: 4,
chapterTitle: '劳动骨干的第三碗饭',
characterName: '赵建国',
eraLine: '1978 · 桂香机械厂集体食堂',
tableEcho: '饭能添,面子也要留;劝人停一停,先给他一张凳。',
lifeAction: '停止继续添饭,先坐下休息并说出不适;必要时停止工作并求助。',
observation: '劝人少添一碗,先留住他的体面和座位。',
familySupport: '发现他不舒服,先让他坐下,再一起想办法。',
todayAction: '停止继续添饭,先坐下休息并说出不适;必要时停止工作并求助。',
familyLine: '下回我嘴硬时,先给我拉把凳子。',
eraObject: '铝饭勺与长条凳',
},
{
cardId: 'S01-C05-MC01',
reportId: 'S01-C05-RP01',
chapterId: 'S01-C05',
chapterNumber: 5,
chapterTitle: '第十五桌给谁留',
characterName: '秦志成',
eraLine: '1978 · 熄灯后的桂香食堂',
tableEcho: '有人为晚归的人留了一盏灯。',
lifeAction: '尽快说明情况,按单位安排和个人需要解决进食',
observation: '晚归的人需要的不只是一口热饭,还有一个位置。',
familySupport: '家里有人晚归,留句消息,也留一份合适的饭。',
todayAction: '尽快说明情况,按单位安排和个人需要解决进食',
familyLine: '回家聊一聊:你想和秦师傅一起,为这张桌留下些什么?',
eraObject: '第十五桌木牌与留饭灯',
},
{
cardId: 'S01-C06-MC01',
reportId: 'S01-C06-RP01',
chapterId: 'S01-C06',
chapterNumber: 6,
chapterTitle: '木牌翻面的那一天',
characterName: '秦志成',
eraLine: '1995 · 从厂食堂改成的桂香饭馆',
tableEcho: '门牌可以翻面,留给人的座位不能翻没。',
lifeAction: '同时查看合同期限和资产清单',
observation: '生意往前走,人情和规矩也要一起留下。',
familySupport: '谈变化时,把期限、物件和家人的意见都摆明白。',
todayAction: '同时查看合同期限和资产清单',
familyLine: '回家聊一聊:饭馆要往前走,第十五桌怎样继续留下来?',
eraObject: '翻面木牌与承包合同',
},
{
cardId: 'S01-C07-MC01',
reportId: 'S01-C07-RP01',
chapterId: 'S01-C07',
chapterNumber: 7,
chapterTitle: '桂香饭馆开张',
characterName: '唐明远',
eraLine: '1995 · 开张后的桂香饭馆',
tableEcho: '问出口的关心,要等到对方把话说完。',
lifeAction: '一开始少盛,不够再添',
observation: '问了“吃不吃”,也要等对方把话说完。',
familySupport: '盛饭前先少一点,问清楚不够再添。',
todayAction: '一开始少盛,不够再添',
familyLine: '回家聊一聊:你愿意怎样把这句话接下去?',
eraObject: '手写菜单与现金盒',
},
{
cardId: 'S01-C08-MC01',
reportId: 'S01-C08-RP01',
chapterId: 'S01-C08',
chapterNumber: 8,
chapterTitle: '四凉八热才叫客气',
characterName: '女炊事员',
eraLine: '1998 · 桂香饭馆第一场大婚宴',
tableEcho: '热闹照得到客人,也该照得到端菜的人。',
lifeAction: '按人数、份量和菜品结构确认是否足够',
observation: '热闹席面里,忙着端菜的人也值得被看见。',
familySupport: '聚餐时问问照料大家的人有没有坐下吃饭。',
todayAction: '按人数、份量和菜品结构确认是否足够',
familyLine: '回家聊一聊:在最忙的时候,你想怎样让她知道自己没有被忘记?',
eraObject: '红纸菜单与婚宴席单',
},
{
cardId: 'S01-C09-MC01',
reportId: 'S01-C09-RP01',
chapterId: 'S01-C09',
chapterNumber: 9,
chapterTitle: '一杯酒绕了三圈',
characterName: '赵建国',
eraLine: '2001 · 桂香饭馆宴席',
tableEcho: '照顾不是把人摘出去,是陪他把自己的选择说出来。',
lifeAction: '明确告诉同桌自己要开车,直接换成茶或白水',
observation: '照顾不是替他决定,而是让他把选择说出来。',
familySupport: '不劝酒、不起哄,给赵伯茶或白水,也留住座位。',
todayAction: '明确告诉同桌自己要开车,直接换成茶或白水',
familyLine: '回家聊一聊:你愿意怎样让赵伯先说出自己的担心?',
eraObject: '茶杯与小酒盅',
},
{
cardId: 'S01-C10-MC01',
reportId: 'S01-C10-RP01',
chapterId: 'S01-C10',
chapterNumber: 10,
chapterTitle: '后厨里的员工桌',
characterName: '唐明远',
eraLine: '2003 · 桂香饭馆后厨',
tableEcho: '桌上腾不出一只碗,忙碌就已经挤到人身上了。',
lifeAction: '清出座位并排出轮班,让员工坐下完成一餐',
observation: '忙到没地方坐下吃饭,已经不是小事。',
familySupport: '一家人再忙,也给吃饭留出座位和时间。',
todayAction: '清出座位并排出轮班,让员工坐下完成一餐',
familyLine: '回家聊一聊:你想怎样帮他把这一顿饭重新放回生活里?',
eraObject: '员工饭盒与后厨长桌',
},
{
cardId: 'S01-C11-MC01',
reportId: 'S01-C11-RP01',
chapterId: 'S01-C11',
chapterNumber: 11,
chapterTitle: '旧房子要拆了',
characterName: '秦志成',
eraLine: '2008 · 翻建前的桂香旧房',
tableEcho: '东西收进柜里叫保存,规矩留在人间才叫传下去。',
lifeAction: '把理念落实到座位、菜单和服务动作',
observation: '旧物保存下来,照顾人的规矩更要继续做。',
familySupport: '把好意写成全家都能做到的小规矩。',
todayAction: '把理念落实到座位、菜单和服务动作',
familyLine: '回家聊一聊:除了保存旧物,你想请秦师傅再留下些什么?',
eraObject: '旧桌板与暗红铁包边',
},
{
cardId: 'S01-C12-MC01',
reportId: 'S01-C12-RP01',
chapterId: 'S01-C12',
chapterNumber: 12,
chapterTitle: '扫码点出一整桌',
characterName: '赵建国',
eraLine: '2026 · 重聚宴服务台与餐桌',
tableEcho: '让人看懂、让人开口,才算把选择递到手里。',
lifeAction: '为每项证据拍照并记录来源',
observation: '信息看得懂、话说得出,选择才真正回到自己手里。',
familySupport: '点餐时放慢一点,陪着看,不替老人直接下单。',
todayAction: '为每项证据拍照并记录来源',
familyLine: '回家聊一聊:信息都在了,最后一步怎样交还给赵伯?',
eraObject: '手机点餐页与纸菜单',
},
{
cardId: 'S01-C13-MC01',
reportId: 'S01-C13-RP01',
chapterId: 'S01-C13',
chapterNumber: 13,
chapterTitle: '三代人都说“我是为你好”',
characterName: '乐乐',
eraLine: '2026 · 重聚宴共同桌与侧桌',
tableEcho: '这一次,大人终于听孩子把话说完。',
lifeAction: '先介绍菜,让乐乐自己夹',
observation: '关心孩子,也要先让孩子说出自己的感受。',
familySupport: '介绍菜以后,先问乐乐想吃什么、想吃多少。',
todayAction: '先介绍菜,让乐乐自己夹',
familyLine: '回家聊一聊:这一回,家里人怎样让乐乐自己选?',
eraObject: '公筷与儿童小碗',
},
{
cardId: 'S01-C14-MC01',
reportId: 'S01-C14-RP01',
chapterId: 'S01-C14',
chapterNumber: 14,
chapterTitle: '秦师傅最后一道老菜',
characterName: '唐守安',
eraLine: '2026 · 重聚宴主桌',
tableEcho: '会解决问题的人,也可以学着先听一会儿。',
lifeAction: '只介绍食材、做法和份量信息',
observation: '会给办法之前,先听完一句话。',
familySupport: '家人开口时先不打断,听完再一起商量。',
todayAction: '只介绍食材、做法和份量信息',
familyLine: '回家聊一聊:唐守安怎样让儿子把那句旧话真正说完?',
eraObject: '秦师傅的手写老菜谱',
},
{
cardId: 'S01-C15-MC01',
reportId: 'S01-C15-RP01',
chapterId: 'S01-C15',
chapterNumber: 15,
chapterTitle: '第十五桌重新开席',
characterName: '老吕',
eraLine: '2026 · 桂香大饭店原址',
tableEcho: '桌子回来了,人也都坐回来了。',
lifeAction: '点餐前先看份量和人数',
observation: '桌子回来只是开始,重要的是每个人都坐得自在。',
familySupport: '点菜前先问人数、份量和每个人的想法。',
todayAction: '点餐前先看份量和人数',
familyLine: '回家聊一聊:这只旧碗怎样留在今天的第十五桌边?',
eraObject: '缺口搪瓷碗与第十五桌铜牌',
},
]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,11 @@
/**
* This is the in-app build identity, not the version entered in WeChat
* Developer Tools during upload. Keep semanticVersion aligned with the root
* package.json so support screenshots can be traced back to source.
*/
module.exports = Object.freeze({
productName: '唐侦探:桂香里的第十五桌',
semanticVersion: '0.1.0',
releaseChannel: 'release-candidate',
contentVersion: 'season-01',
})
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

@@ -0,0 +1,45 @@
// First-chapter page tracks are packaged for an internal listening candidate.
// They passed automated technical checks, but have not passed human listening,
// elder-device listening or medical-content sign-off. Do not relabel approved.
module.exports = Object.freeze({
'S01-C01-P01': Object.freeze({
reviewStatus: 'technical-qa-pass-human-listening-pending',
pageNumber: 1,
title: '开席前,少了一张桌',
caption: '请柬明明写着十五桌。可赵伯走到十四桌和十六桌中间,偏偏没找着自己的座。',
imageSrc: '/package-audio-c01-a/assets/player-art/S01-C01-P01-title-v1.jpg',
audioSrc: '/package-audio-c01-a/assets/audio/S01-C01-P01.daa8f04a2627.mp3',
durationSeconds: 33.679388,
sha256: 'daa8f04a262767109f3a0621f178e3c58f169507e8ea09fa8d583258cb713282',
}),
'S01-C01-P02': Object.freeze({
reviewStatus: 'technical-qa-pass-human-listening-pending',
pageNumber: 2,
title: '十五桌,到底去哪儿了?',
caption: '请柬写着十五桌,座位图却从十四跳到十六。赵伯站在空地边,手里的请柬捏了又捏。',
imageSrc: '/package-audio-c01-a/assets/player-art/S01-C01-P02-missing-table-ensemble-v1.jpg',
audioSrc: '/package-audio-c01-a/assets/audio/S01-C01-P02.7774981b1878.mp3',
durationSeconds: 49.49424,
sha256: '7774981b1878f6ad882cb7a4e3f923d67f43c12c2ea068982df1cab63820d8c2',
}),
'S01-C01-P03': Object.freeze({
reviewStatus: 'technical-qa-pass-human-listening-pending',
pageNumber: 3,
title: '老人站在扫码牌前',
caption: '小满把平板往老人面前一递:“扫一扫就能找座。”纸名单还压在桌角,没人铺开。',
imageSrc: '/package-audio-c01-a/assets/player-art/S01-C01-P03-H01-scan-only-v1.jpg',
audioSrc: '/package-audio-c01-a/assets/audio/S01-C01-P03.0f21daa7eccb.mp3',
durationSeconds: 30.534762,
sha256: '0f21daa7eccbaac9139317e8209570fe025fb2403ea5cc794e12586764365885',
}),
'S01-C01-P04': Object.freeze({
reviewStatus: 'technical-qa-pass-human-listening-pending',
pageNumber: 4,
title: '桌上已经够吃了',
caption: '十个人的菜已经摆得满满当当,赵伯的拇指还停在“再来一道”上:“桌上可不能显得空。”',
imageSrc: '/package-audio-c01-a/assets/player-art/S01-C01-P04-H02-extra-dish-v1.jpg',
audioSrc: '/package-audio-c01-a/assets/audio/S01-C01-P04.ed65c367d761.mp3',
durationSeconds: 46.79424,
sha256: 'ed65c367d76125e1404d76bb86faa0629b599f6853a39e58821617f854bfa167',
}),
})
@@ -0,0 +1,713 @@
const audioPages = require('../../data/audioPages')
const SEEK_TIMEOUT_MS = 1600
const PAUSE_LOCK_TIMEOUT_MS = 1200
const RATE_VALUES = Object.freeze([0.8, 1, 1.2])
const RATE_OPTIONS = Object.freeze([
Object.freeze({ value: 0.8, label: '0.8倍' }),
Object.freeze({ value: 1, label: '1.0倍' }),
Object.freeze({ value: 1.2, label: '1.2倍' }),
])
const FALLBACK_RATE_OPTIONS = Object.freeze([
Object.freeze({ value: 1, label: '1.0倍' }),
])
function formatTime(value) {
const seconds = Math.max(0, Math.floor(Number(value) || 0))
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`
}
function clamp(value, minimum, maximum) {
return Math.min(maximum, Math.max(minimum, Number(value) || 0))
}
function eventValue(event) {
return Number(event && event.detail && event.detail.value) || 0
}
Page({
data: {
pageId: '',
pageNumber: 1,
title: '',
caption: '',
imageSrc: '',
playing: false,
loading: false,
audioReady: false,
hasStarted: false,
seekLocked: false,
error: '',
currentLabel: '0:00',
durationLabel: '0:00',
durationSeconds: 1,
sliderValue: 0,
progressStyle: 'width:0%',
playbackRate: 1,
rateOptions: FALLBACK_RATE_OPTIONS,
rateControlsVisible: false,
reviewStatus: 'technical-qa-pass-human-listening-pending',
},
onLoad(options = {}) {
this._unloaded = false
this._lifecycleGeneration = (Number(this._lifecycleGeneration) || 0) + 1
this._contextGeneration = 0
this._intentGeneration = 0
this._seekOperationGeneration = 0
this._controlOperationGeneration = 0
this._desiredPlayback = false
this._activePlayIntentGeneration = 0
this._controlLocked = false
this._scrubbing = false
this._pendingSeek = null
this._seekTimer = null
this._pendingPauseLock = null
this._pauseLockTimer = null
this._page = null
this.bindAudioInterruptionHandlers()
const pageId = String(options.pageId || '')
const page = audioPages[pageId]
if (!page) {
this.setData({ error: '这一页的声音没有找到,请返回连环画。' })
return
}
this._page = page
const durationSeconds = Math.max(1, Number(page.durationSeconds) || 1)
this.setData({
pageId,
pageNumber: page.pageNumber,
title: page.title,
caption: page.caption,
imageSrc: page.imageSrc,
durationLabel: formatTime(durationSeconds),
durationSeconds,
})
},
onReady() {
if (!this._page || this._unloaded) return
this.createAudioContext(this._page.audioSrc)
},
isCurrentContext(context, generation) {
return !this._unloaded
&& this.audioContext === context
&& this._contextGeneration === generation
},
setProgress(value, durationValue) {
const duration = Math.max(
1,
Number(durationValue) || Number(this.data.durationSeconds) || 1,
)
const current = clamp(value, 0, duration)
const percent = Math.min(100, current / duration * 100)
this.setData({
currentLabel: formatTime(current),
durationLabel: formatTime(duration),
durationSeconds: duration,
sliderValue: current,
progressStyle: `width:${percent}%`,
})
},
disableRateControls(context) {
try {
if (context && typeof context.playbackRate === 'number') {
context.playbackRate = 1
}
} catch (_) {
// A runtime that rejects playbackRate remains safely at normal speed.
}
this.setData({
playbackRate: 1,
rateOptions: FALLBACK_RATE_OPTIONS,
rateControlsVisible: false,
})
},
configureRateControls(context, generation) {
try {
if (typeof context.playbackRate !== 'number') {
this.disableRateControls(context)
return
}
for (const rate of RATE_VALUES) {
context.playbackRate = rate
if (Math.abs(Number(context.playbackRate) - rate) > 0.001) {
this.disableRateControls(context)
return
}
}
context.playbackRate = 1
if (
!this.isCurrentContext(context, generation)
|| Math.abs(Number(context.playbackRate) - 1) > 0.001
) {
this.disableRateControls(context)
return
}
this.setData({
playbackRate: 1,
rateOptions: RATE_OPTIONS,
rateControlsVisible: true,
})
} catch (_) {
this.disableRateControls(context)
}
},
createAudioContext(src) {
if (this._unloaded) return null
if (this.audioContext) this.destroyAudioContext()
const context = wx.createInnerAudioContext()
const generation = this._contextGeneration + 1
this._contextGeneration = generation
this.audioContext = context
context.autoplay = false
context.src = src
this.setData({
audioReady: true,
loading: false,
playing: false,
error: '',
})
this.configureRateControls(context, generation)
context.onCanplay(() => {
if (!this.isCurrentContext(context, generation)) return
this.setData({ audioReady: true, error: '' })
})
context.onPlay(() => {
if (!this.isCurrentContext(context, generation)) return
if (
!this._desiredPlayback
|| this._activePlayIntentGeneration !== this._intentGeneration
) {
context.pause()
this._controlLocked = false
this.setData({ playing: false, loading: false })
return
}
this._controlLocked = false
this.setData({
playing: true,
loading: false,
hasStarted: true,
error: '',
})
})
context.onPause(() => {
if (!this.isCurrentContext(context, generation)) return
if (this._desiredPlayback) return
this.releasePauseLockFromEvent(context, generation)
this.setData({ playing: false, loading: false })
})
context.onStop(() => {
if (!this.isCurrentContext(context, generation)) return
this.invalidatePlaybackIntent(false)
this.setData({ playing: false, loading: false })
})
context.onWaiting(() => {
if (
!this.isCurrentContext(context, generation)
|| !this._desiredPlayback
) return
this.setData({ playing: false, loading: true })
})
context.onEnded(() => {
if (!this.isCurrentContext(context, generation)) return
this.invalidatePlaybackIntent(false)
this.setProgress(this.data.durationSeconds, this.data.durationSeconds)
this.setData({ playing: false, loading: false })
})
context.onTimeUpdate(() => {
if (
!this.isCurrentContext(context, generation)
|| this._scrubbing
|| this._pendingSeek
) return
const duration = Number(context.duration) || this.data.durationSeconds
const current = Number(context.currentTime) || 0
this.setProgress(current, duration)
})
if (typeof context.onSeeked === 'function') {
context.onSeeked(() => {
if (!this.isCurrentContext(context, generation)) return
this.finishSeekFromEvent(context, generation)
})
}
context.onError(() => {
if (!this.isCurrentContext(context, generation)) return
this.invalidatePlaybackIntent(false)
this.setData({
playing: false,
loading: false,
error: '声音暂时没打开,您可以返回继续看连环画。',
})
})
this.bindAudioInterruptionHandlers()
return context
},
beginPlaybackIntent() {
this._intentGeneration += 1
this._desiredPlayback = true
this._activePlayIntentGeneration = this._intentGeneration
return this._intentGeneration
},
clearSeekState(updateData = true) {
if (this._seekTimer !== null) {
clearTimeout(this._seekTimer)
this._seekTimer = null
}
this._seekOperationGeneration += 1
this._pendingSeek = null
this._scrubbing = false
if (updateData && !this._unloaded) this.setData({ seekLocked: false })
},
clearPauseLock(unlock = true) {
if (this._pauseLockTimer !== null) {
clearTimeout(this._pauseLockTimer)
this._pauseLockTimer = null
}
this._controlOperationGeneration += 1
this._pendingPauseLock = null
if (unlock) this._controlLocked = false
},
beginPauseLock(context) {
this.clearPauseLock(true)
const operationGeneration = this._controlOperationGeneration + 1
this._controlOperationGeneration = operationGeneration
const pending = {
operationGeneration,
contextGeneration: this._contextGeneration,
intentGeneration: this._intentGeneration,
lifecycleGeneration: this._lifecycleGeneration,
context,
}
this._pendingPauseLock = pending
this._controlLocked = true
this._pauseLockTimer = setTimeout(() => {
this.releasePauseLock(
operationGeneration,
pending.contextGeneration,
pending.intentGeneration,
pending.lifecycleGeneration,
context,
)
}, PAUSE_LOCK_TIMEOUT_MS)
},
releasePauseLock(
operationGeneration,
contextGeneration,
intentGeneration,
lifecycleGeneration,
context,
) {
const pending = this._pendingPauseLock
if (
!pending
|| pending.operationGeneration !== operationGeneration
|| pending.contextGeneration !== contextGeneration
|| pending.intentGeneration !== intentGeneration
|| pending.lifecycleGeneration !== lifecycleGeneration
|| pending.context !== context
|| !this.isCurrentContext(context, contextGeneration)
|| this._intentGeneration !== intentGeneration
|| this._lifecycleGeneration !== lifecycleGeneration
|| this._desiredPlayback
) return false
if (this._pauseLockTimer !== null) {
clearTimeout(this._pauseLockTimer)
this._pauseLockTimer = null
}
this._pendingPauseLock = null
this._controlLocked = false
return true
},
releasePauseLockFromEvent(context, contextGeneration) {
const pending = this._pendingPauseLock
if (!pending) {
if (this.isCurrentContext(context, contextGeneration)) {
this._controlLocked = false
}
return
}
this.releasePauseLock(
pending.operationGeneration,
contextGeneration,
pending.intentGeneration,
pending.lifecycleGeneration,
context,
)
},
invalidatePlaybackIntent(pauseContext = true) {
this._intentGeneration += 1
this._activePlayIntentGeneration = 0
this._desiredPlayback = false
this.clearPauseLock(true)
this.clearSeekState(!this._unloaded)
if (
pauseContext
&& this.audioContext
&& typeof this.audioContext.pause === 'function'
) {
this.audioContext.pause()
}
},
startPlaybackWithIntent(intentGeneration) {
const context = this.audioContext
if (
!context
|| this._unloaded
|| intentGeneration !== this._intentGeneration
|| !this._desiredPlayback
) return false
this._controlLocked = true
this.setData({
loading: true,
playing: false,
hasStarted: true,
error: '',
})
context.play()
return true
},
bindAudioInterruptionHandlers() {
if (this._audioInterruptionBeginHandler) return
const generation = this._lifecycleGeneration
this._audioInterruptionBeginHandler = () => {
if (this._unloaded || this._lifecycleGeneration !== generation) return
this.pauseWithoutAutoResume()
}
this._audioInterruptionEndHandler = () => {
if (this._unloaded || this._lifecycleGeneration !== generation) return
this.invalidatePlaybackIntent(false)
this.setData({ playing: false, loading: false })
}
if (typeof wx.onAudioInterruptionBegin === 'function') {
wx.onAudioInterruptionBegin(this._audioInterruptionBeginHandler)
}
if (typeof wx.onAudioInterruptionEnd === 'function') {
wx.onAudioInterruptionEnd(this._audioInterruptionEndHandler)
}
},
unbindAudioInterruptionHandlers() {
if (
this._audioInterruptionBeginHandler
&& typeof wx.offAudioInterruptionBegin === 'function'
) {
wx.offAudioInterruptionBegin(this._audioInterruptionBeginHandler)
}
if (
this._audioInterruptionEndHandler
&& typeof wx.offAudioInterruptionEnd === 'function'
) {
wx.offAudioInterruptionEnd(this._audioInterruptionEndHandler)
}
this._audioInterruptionBeginHandler = null
this._audioInterruptionEndHandler = null
},
pauseWithoutAutoResume() {
const context = this.audioContext
this.invalidatePlaybackIntent(false)
if (context && typeof context.pause === 'function') {
this.beginPauseLock(context)
try {
context.pause()
} catch (_) {
this.clearPauseLock(true)
}
}
if (!this._unloaded) {
this.setData({ playing: false, loading: false })
}
},
onHide() {
this.pauseWithoutAutoResume()
},
onShow() {
if (this._unloaded) return
this.clearPauseLock(true)
this.setData({ playing: false, loading: false, seekLocked: false })
},
togglePlayback() {
if (this.data.loading || this._controlLocked || this._unloaded) return
if (!this.audioContext && this._page) {
this.createAudioContext(this._page.audioSrc)
}
if (!this.audioContext) return
if (this.data.playing || this._desiredPlayback) {
this.pauseWithoutAutoResume()
return
}
const intentGeneration = this.beginPlaybackIntent()
this.startPlaybackWithIntent(intentGeneration)
},
requestSeek(targetValue, options = {}) {
const context = this.audioContext
if (
!context
|| this._unloaded
|| this.data.loading
|| this._pendingSeek
) return false
const duration = Math.max(
1,
Number(context.duration) || Number(this.data.durationSeconds) || 1,
)
const target = clamp(targetValue, 0, duration)
const actualBeforeSeek = Number(context.currentTime)
const previousValue = clamp(
Number.isFinite(actualBeforeSeek)
? actualBeforeSeek
: Number(this.data.sliderValue) || 0,
0,
duration,
)
const operationGeneration = this._seekOperationGeneration + 1
this._seekOperationGeneration = operationGeneration
const pending = {
operationGeneration,
contextGeneration: this._contextGeneration,
intentGeneration: this._intentGeneration,
context,
target,
previousValue,
playAfterSeek: Boolean(options.playAfterSeek),
}
this._pendingSeek = pending
this._scrubbing = false
this.setProgress(target, duration)
this.setData({ seekLocked: true, error: '' })
this._seekTimer = setTimeout(() => {
this.failSeek(
operationGeneration,
pending.contextGeneration,
pending.intentGeneration,
context,
)
}, SEEK_TIMEOUT_MS)
try {
context.seek(target)
} catch (_) {
this.failSeek(
operationGeneration,
pending.contextGeneration,
pending.intentGeneration,
context,
)
return false
}
return true
},
finishSeekFromEvent(context, contextGeneration) {
const pending = this._pendingSeek
if (!pending) return
const actual = Number(context.currentTime)
if (Number.isFinite(actual) && Math.abs(actual - pending.target) > 1.25) return
this.finishSeek(
pending.operationGeneration,
contextGeneration,
pending.intentGeneration,
context,
)
},
finishSeek(
operationGeneration,
contextGeneration,
intentGeneration,
context,
) {
const pending = this._pendingSeek
if (
!pending
|| pending.operationGeneration !== operationGeneration
|| pending.contextGeneration !== contextGeneration
|| pending.intentGeneration !== intentGeneration
|| pending.context !== context
|| !this.isCurrentContext(context, contextGeneration)
|| this._intentGeneration !== intentGeneration
) return
if (this._seekTimer !== null) {
clearTimeout(this._seekTimer)
this._seekTimer = null
}
this._pendingSeek = null
this.setData({ seekLocked: false })
if (
pending.playAfterSeek
&& this._desiredPlayback
&& this._activePlayIntentGeneration === intentGeneration
) {
this.startPlaybackWithIntent(intentGeneration)
}
},
failSeek(
operationGeneration,
contextGeneration,
intentGeneration,
context,
) {
const pending = this._pendingSeek
if (
!pending
|| pending.operationGeneration !== operationGeneration
|| pending.contextGeneration !== contextGeneration
|| pending.intentGeneration !== intentGeneration
|| pending.context !== context
|| !this.isCurrentContext(context, contextGeneration)
|| this._intentGeneration !== intentGeneration
) return false
if (this._seekTimer !== null) {
clearTimeout(this._seekTimer)
this._seekTimer = null
}
this._pendingSeek = null
this._scrubbing = false
const actual = Number(context.currentTime)
const restoredValue = Number.isFinite(actual)
? actual
: pending.previousValue
if (pending.playAfterSeek) this.invalidatePlaybackIntent(false)
this.setProgress(restoredValue, this.data.durationSeconds)
this.setData({
playing: pending.playAfterSeek ? false : this.data.playing,
loading: false,
seekLocked: false,
error: '进度没有调好,请再试一次。',
})
return true
},
previewSeek(event) {
if (!this.audioContext || this.data.loading || this._pendingSeek) return
this._scrubbing = true
this.setProgress(eventValue(event), this.data.durationSeconds)
},
commitSeek(event) {
if (!this.audioContext || this.data.loading || this._pendingSeek) return
this._scrubbing = false
this.requestSeek(eventValue(event))
},
rewindTenSeconds() {
if (!this.audioContext || this.data.loading || this._pendingSeek) return
const current = Number(this.audioContext.currentTime)
const fallback = Number(this.data.sliderValue) || 0
this.requestSeek(Math.max(0, (Number.isFinite(current) ? current : fallback) - 10))
},
replay() {
if (
!this.audioContext
|| this.data.loading
|| this._controlLocked
|| this._pendingSeek
|| this._unloaded
) return
const alreadyPlaying = this.data.playing && this._desiredPlayback
const intentGeneration = alreadyPlaying
? this._intentGeneration
: this.beginPlaybackIntent()
this.setData({ hasStarted: true, error: '' })
this.requestSeek(0, {
playAfterSeek: !alreadyPlaying,
intentGeneration,
})
},
changePlaybackRate(event) {
const requested = Number(
event && event.currentTarget && event.currentTarget.dataset.rate,
)
if (
!RATE_VALUES.includes(requested)
|| !this.audioContext
|| !this.data.rateControlsVisible
) return
const allowed = this.data.rateOptions.some((item) => item.value === requested)
if (!allowed) return
try {
this.audioContext.playbackRate = requested
if (Math.abs(Number(this.audioContext.playbackRate) - requested) > 0.001) {
this.disableRateControls(this.audioContext)
return
}
this.setData({ playbackRate: requested })
} catch (_) {
this.disableRateControls(this.audioContext)
}
},
goBack() {
const pages = typeof getCurrentPages === 'function' ? getCurrentPages() : []
const returnToChapter = () => {
if (typeof wx.reLaunch === 'function') {
wx.reLaunch({
url: '/package-game/pages/chapter/chapter?chapter=1',
})
}
}
if (pages.length > 1 && typeof wx.navigateBack === 'function') {
wx.navigateBack({ delta: 1, fail: returnToChapter })
return
}
returnToChapter()
},
destroyAudioContext() {
const context = this.audioContext
if (!context) return
this.invalidatePlaybackIntent(false)
this._contextGeneration += 1
this.audioContext = null
context.destroy()
if (!this._unloaded) {
this.setData({
audioReady: false,
playing: false,
loading: false,
rateOptions: FALLBACK_RATE_OPTIONS,
rateControlsVisible: false,
playbackRate: 1,
})
}
},
onUnload() {
this._unloaded = true
this._lifecycleGeneration += 1
this._intentGeneration += 1
this._activePlayIntentGeneration = 0
this._desiredPlayback = false
this._controlLocked = true
this.clearPauseLock(false)
this.clearSeekState(false)
this.unbindAudioInterruptionHandlers()
const context = this.audioContext
this._contextGeneration += 1
this.audioContext = null
if (context) context.destroy()
},
})
@@ -0,0 +1,3 @@
{
"disableScroll": true
}
@@ -0,0 +1,63 @@
<view class="audio-leaf">
<view class="audio-leaf-topbar">
<button class="back-top" bindtap="goBack" aria-label="返回刚才的连环画"> 返回连环画</button>
<view class="leaf-heading">
<text class="leaf-kicker">第01回 · 有声夹页</text>
<text class="leaf-count">{{pageNumber}}8</text>
</view>
</view>
<view class="audio-leaf-spread">
<view class="leaf-art-frame">
<image class="leaf-art" src="{{imageSrc}}" mode="aspectFit"></image>
<text class="leaf-stamp">桂香故事</text>
</view>
<view class="leaf-copy">
<text class="leaf-title">{{title}}</text>
<text class="leaf-caption">{{caption}}</text>
<view class="leaf-seek-row">
<slider
class="leaf-slider"
aria-label="播放进度,可左右拖动"
min="0"
max="{{durationSeconds}}"
step="1"
value="{{sliderValue}}"
activeColor="#9b3328"
backgroundColor="#d6c6a1"
block-color="#8f271f"
block-size="24"
disabled="{{loading || !audioReady || seekLocked}}"
bindchanging="previewSeek"
bindchange="commitSeek"
></slider>
<text class="leaf-time">{{currentLabel}} / {{durationLabel}}</text>
</view>
<view class="leaf-controls">
<button class="leaf-button" disabled="{{loading || !audioReady || seekLocked}}" bindtap="rewindTenSeconds">后退10秒</button>
<button class="leaf-button primary" disabled="{{loading || !audioReady || seekLocked}}" bindtap="togglePlayback">
{{loading ? '正在准备' : playing ? '暂停' : hasStarted ? '继续听' : '开始听'}}
</button>
<button class="leaf-button" disabled="{{loading || !audioReady || seekLocked}}" bindtap="replay">从头重听</button>
</view>
<view wx:if="{{rateControlsVisible}}" class="leaf-rate-row" aria-label="播放速度">
<text class="leaf-rate-label">语速</text>
<button
wx:for="{{rateOptions}}"
wx:key="value"
class="leaf-rate-button {{playbackRate == item.value ? 'active' : ''}}"
data-rate="{{item.value}}"
disabled="{{loading || seekLocked}}"
bindtap="changePlaybackRate"
>{{item.label}}</button>
</view>
<text wx:if="{{error}}" class="leaf-error">{{error}}</text>
<button class="back-bottom" bindtap="goBack">看完,回到连环画</button>
</view>
</view>
</view>
@@ -0,0 +1,171 @@
page {
width: 100%;
min-height: 100%;
background: #1c130f;
color: #2f241d;
}
button::after { border: 0; }
.audio-leaf {
box-sizing: border-box;
width: 100vw;
height: 100vh;
min-height: 320px;
padding: max(8px, env(safe-area-inset-top)) max(12px, env(safe-area-inset-right)) max(8px, env(safe-area-inset-bottom)) max(12px, env(safe-area-inset-left));
display: flex;
flex-direction: column;
gap: 8px;
background: radial-gradient(circle at 48% 45%, #423024 0, #211611 70%);
}
.audio-leaf-topbar {
height: 52px;
flex: 0 0 52px;
display: grid;
grid-template-columns: 160px minmax(0, 1fr);
gap: 10px;
align-items: center;
padding-right: max(92px, env(safe-area-inset-right));
color: #f5e6b7;
}
.back-top {
box-sizing: border-box;
width: 100%;
max-width: 100%;
min-height: 48px;
border: 1px solid #9b7a51;
border-radius: 10px;
background: #38251b;
color: #f7e8bd;
font-size: 20px;
line-height: 48px;
text-align: center;
padding: 0 12px;
}
.leaf-heading { display: flex; align-items: baseline; gap: 12px; }
.leaf-kicker { font-size: 20px; font-weight: 700; }
.leaf-count { color: #d9bb78; font-size: 18px; }
.audio-leaf-spread {
min-height: 0;
flex: 1;
display: grid;
grid-template-columns: minmax(0, 3fr) minmax(300px, 2fr);
border: 2px solid #b99762;
background: #efe2bd;
box-shadow: 0 10px 34px rgba(0, 0, 0, .4);
}
.leaf-art-frame {
position: relative;
min-width: 0;
min-height: 0;
overflow: hidden;
background: #17100c;
border-right: 4px solid #8d271f;
}
.leaf-art { width: 100%; height: 100%; display: block; }
.leaf-stamp {
position: absolute;
left: 18px;
bottom: 16px;
padding: 6px 12px;
background: rgba(86, 24, 20, .9);
color: #f5e6bd;
font-size: 17px;
letter-spacing: 2px;
}
.leaf-copy {
min-width: 0;
min-height: 0;
padding: 18px 20px 14px;
display: flex;
flex-direction: column;
background: repeating-linear-gradient(0deg, rgba(119, 87, 46, .04) 0, rgba(119, 87, 46, .04) 1px, transparent 1px, transparent 4px), #f6edcf;
}
.leaf-title { color: #8f271f; font-size: 28px; font-weight: 800; line-height: 1.25; }
.leaf-caption { margin-top: 10px; font-size: 22px; font-weight: 600; line-height: 1.48; }
.leaf-seek-row {
min-width: 0;
min-height: 48px;
margin-top: auto;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 6px;
align-items: center;
}
.leaf-slider { min-width: 0; width: 100%; margin: 0; }
.leaf-time { min-width: 82px; color: #6d5b48; font-size: 17px; text-align: right; }
.leaf-controls {
min-width: 0;
margin-top: 6px;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.leaf-button, .back-bottom {
box-sizing: border-box;
min-height: 48px;
border: 2px solid #7e6648;
border-radius: 10px;
background: #eadbb5;
color: #37291f;
font-size: 18px;
font-weight: 700;
line-height: 46px;
min-width: 0;
margin: 0;
padding: 0 6px;
}
.leaf-button { width: 100%; max-width: 100%; }
.leaf-button.primary { border-color: #8f271f; background: #9e3027; color: #fff2cf; }
.leaf-button[disabled] { opacity: .72; }
.leaf-rate-row {
min-width: 0;
min-height: 48px;
margin-top: 6px;
display: grid;
grid-template-columns: auto repeat(3, minmax(48px, 1fr));
gap: 6px;
align-items: center;
}
.leaf-rate-label { color: #6d5b48; font-size: 18px; font-weight: 700; }
.leaf-rate-button {
box-sizing: border-box;
min-width: 48px;
min-height: 48px;
margin: 0;
padding: 0 4px;
border: 2px solid #9d896a;
border-radius: 9px;
background: #f3e7c8;
color: #4d3b2d;
font-size: 17px;
font-weight: 700;
line-height: 46px;
}
.leaf-rate-button { width: 100%; max-width: 100%; }
.leaf-rate-button.active { border-color: #8f271f; background: #8f271f; color: #fff2cf; }
.leaf-rate-button[disabled] { opacity: .72; }
.leaf-error { margin-top: 8px; color: #8f271f; font-size: 17px; line-height: 1.35; }
.back-bottom { margin-top: 8px; width: 100%; }
@media (max-height: 430px) {
.audio-leaf { gap: 4px; padding-top: 4px; padding-bottom: 4px; }
.audio-leaf-topbar { height: 48px; flex-basis: 48px; }
.leaf-copy { padding: 8px 12px; overflow-y: auto; }
.leaf-title { font-size: 23px; }
.leaf-caption { margin-top: 4px; font-size: 17px; line-height: 1.3; }
.leaf-seek-row { min-height: 48px; }
.leaf-controls, .leaf-rate-row { margin-top: 3px; gap: 5px; }
.leaf-button, .back-bottom { min-height: 48px; line-height: 46px; font-size: 17px; }
.leaf-rate-button { min-height: 48px; line-height: 46px; font-size: 16px; }
.leaf-rate-label { font-size: 16px; }
.back-bottom { display: none; }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

@@ -0,0 +1,45 @@
// First-chapter page tracks are packaged for an internal listening candidate.
// They passed automated technical checks, but have not passed human listening,
// elder-device listening or medical-content sign-off. Do not relabel approved.
module.exports = Object.freeze({
'S01-C01-P05': Object.freeze({
reviewStatus: 'technical-qa-pass-human-listening-pending',
pageNumber: 5,
title: '地上留下四个新压痕',
caption: '乐乐蹲下来一量:四个桌脚印还是新的,拖痕一路朝侧门去了。',
imageSrc: '/package-audio-c01-b/assets/player-art/S01-C01-P05-H03-four-imprints-v1.jpg',
audioSrc: '/package-audio-c01-b/assets/audio/S01-C01-P05.210005d24e32.mp3',
durationSeconds: 32.880567,
sha256: '210005d24e3276e2689e38b0bb3a9d6b6e900ca3cc5ca874278a0b89a50def6a',
}),
'S01-C01-P06': Object.freeze({
reviewStatus: 'technical-qa-pass-human-listening-pending',
pageNumber: 6,
title: '秦师傅把话咽了回去',
caption: '秦师傅隔着门问:“十五桌的人齐了吗?”稿纸越攥越紧,脚却往后退。',
imageSrc: '/package-audio-c01-b/assets/player-art/S01-C01-P06-H04-unsaid-speech-v1.jpg',
audioSrc: '/package-audio-c01-b/assets/audio/S01-C01-P06.5e69ed4c17f3.mp3',
durationSeconds: 13.328889,
sha256: '5e69ed4c17f3d5c4168546c0b76a6d5bfb13ecbecdd982c5be1c27259b9ce1a4',
}),
'S01-C01-P07': Object.freeze({
reviewStatus: 'technical-qa-pass-human-listening-pending',
pageNumber: 7,
title: '把赵伯叫进来',
caption: '赵伯把请柬捏平,嘴上说“别管我”,两只脚却一直守着那块空地。',
imageSrc: '/package-audio-c01-b/assets/player-art/S01-C01-P07-EM01-call-zhao-in-v1.jpg',
audioSrc: '/package-audio-c01-b/assets/audio/S01-C01-P07.a68bc0cb8ecc.mp3',
durationSeconds: 10.466644,
sha256: 'a68bc0cb8ecc6d53b7c877567d6c21fdd894c914e84c072357116429d05d0cc5',
}),
'S01-C01-P08': Object.freeze({
reviewStatus: 'technical-qa-pass-human-listening-pending',
pageNumber: 8,
title: '锅盖响了一声',
caption: '乐乐从立牌箱底下摸出一个纸筒。铜牌刚展开,后厨“哐当”一声,锅盖掉了。秦师傅没有出来。',
imageSrc: '/package-audio-c01-b/assets/player-art/S01-C01-P08-cliffhanger-v1.jpg',
audioSrc: '/package-audio-c01-b/assets/audio/S01-C01-P08.1c44eff9d71f.mp3',
durationSeconds: 39.534467,
sha256: '1c44eff9d71fcd004e53050e0727ee37fa3560509d3579c0e2cd004e80e246ec',
}),
})
@@ -0,0 +1,713 @@
const audioPages = require('../../data/audioPages')
const SEEK_TIMEOUT_MS = 1600
const PAUSE_LOCK_TIMEOUT_MS = 1200
const RATE_VALUES = Object.freeze([0.8, 1, 1.2])
const RATE_OPTIONS = Object.freeze([
Object.freeze({ value: 0.8, label: '0.8倍' }),
Object.freeze({ value: 1, label: '1.0倍' }),
Object.freeze({ value: 1.2, label: '1.2倍' }),
])
const FALLBACK_RATE_OPTIONS = Object.freeze([
Object.freeze({ value: 1, label: '1.0倍' }),
])
function formatTime(value) {
const seconds = Math.max(0, Math.floor(Number(value) || 0))
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`
}
function clamp(value, minimum, maximum) {
return Math.min(maximum, Math.max(minimum, Number(value) || 0))
}
function eventValue(event) {
return Number(event && event.detail && event.detail.value) || 0
}
Page({
data: {
pageId: '',
pageNumber: 1,
title: '',
caption: '',
imageSrc: '',
playing: false,
loading: false,
audioReady: false,
hasStarted: false,
seekLocked: false,
error: '',
currentLabel: '0:00',
durationLabel: '0:00',
durationSeconds: 1,
sliderValue: 0,
progressStyle: 'width:0%',
playbackRate: 1,
rateOptions: FALLBACK_RATE_OPTIONS,
rateControlsVisible: false,
reviewStatus: 'technical-qa-pass-human-listening-pending',
},
onLoad(options = {}) {
this._unloaded = false
this._lifecycleGeneration = (Number(this._lifecycleGeneration) || 0) + 1
this._contextGeneration = 0
this._intentGeneration = 0
this._seekOperationGeneration = 0
this._controlOperationGeneration = 0
this._desiredPlayback = false
this._activePlayIntentGeneration = 0
this._controlLocked = false
this._scrubbing = false
this._pendingSeek = null
this._seekTimer = null
this._pendingPauseLock = null
this._pauseLockTimer = null
this._page = null
this.bindAudioInterruptionHandlers()
const pageId = String(options.pageId || '')
const page = audioPages[pageId]
if (!page) {
this.setData({ error: '这一页的声音没有找到,请返回连环画。' })
return
}
this._page = page
const durationSeconds = Math.max(1, Number(page.durationSeconds) || 1)
this.setData({
pageId,
pageNumber: page.pageNumber,
title: page.title,
caption: page.caption,
imageSrc: page.imageSrc,
durationLabel: formatTime(durationSeconds),
durationSeconds,
})
},
onReady() {
if (!this._page || this._unloaded) return
this.createAudioContext(this._page.audioSrc)
},
isCurrentContext(context, generation) {
return !this._unloaded
&& this.audioContext === context
&& this._contextGeneration === generation
},
setProgress(value, durationValue) {
const duration = Math.max(
1,
Number(durationValue) || Number(this.data.durationSeconds) || 1,
)
const current = clamp(value, 0, duration)
const percent = Math.min(100, current / duration * 100)
this.setData({
currentLabel: formatTime(current),
durationLabel: formatTime(duration),
durationSeconds: duration,
sliderValue: current,
progressStyle: `width:${percent}%`,
})
},
disableRateControls(context) {
try {
if (context && typeof context.playbackRate === 'number') {
context.playbackRate = 1
}
} catch (_) {
// A runtime that rejects playbackRate remains safely at normal speed.
}
this.setData({
playbackRate: 1,
rateOptions: FALLBACK_RATE_OPTIONS,
rateControlsVisible: false,
})
},
configureRateControls(context, generation) {
try {
if (typeof context.playbackRate !== 'number') {
this.disableRateControls(context)
return
}
for (const rate of RATE_VALUES) {
context.playbackRate = rate
if (Math.abs(Number(context.playbackRate) - rate) > 0.001) {
this.disableRateControls(context)
return
}
}
context.playbackRate = 1
if (
!this.isCurrentContext(context, generation)
|| Math.abs(Number(context.playbackRate) - 1) > 0.001
) {
this.disableRateControls(context)
return
}
this.setData({
playbackRate: 1,
rateOptions: RATE_OPTIONS,
rateControlsVisible: true,
})
} catch (_) {
this.disableRateControls(context)
}
},
createAudioContext(src) {
if (this._unloaded) return null
if (this.audioContext) this.destroyAudioContext()
const context = wx.createInnerAudioContext()
const generation = this._contextGeneration + 1
this._contextGeneration = generation
this.audioContext = context
context.autoplay = false
context.src = src
this.setData({
audioReady: true,
loading: false,
playing: false,
error: '',
})
this.configureRateControls(context, generation)
context.onCanplay(() => {
if (!this.isCurrentContext(context, generation)) return
this.setData({ audioReady: true, error: '' })
})
context.onPlay(() => {
if (!this.isCurrentContext(context, generation)) return
if (
!this._desiredPlayback
|| this._activePlayIntentGeneration !== this._intentGeneration
) {
context.pause()
this._controlLocked = false
this.setData({ playing: false, loading: false })
return
}
this._controlLocked = false
this.setData({
playing: true,
loading: false,
hasStarted: true,
error: '',
})
})
context.onPause(() => {
if (!this.isCurrentContext(context, generation)) return
if (this._desiredPlayback) return
this.releasePauseLockFromEvent(context, generation)
this.setData({ playing: false, loading: false })
})
context.onStop(() => {
if (!this.isCurrentContext(context, generation)) return
this.invalidatePlaybackIntent(false)
this.setData({ playing: false, loading: false })
})
context.onWaiting(() => {
if (
!this.isCurrentContext(context, generation)
|| !this._desiredPlayback
) return
this.setData({ playing: false, loading: true })
})
context.onEnded(() => {
if (!this.isCurrentContext(context, generation)) return
this.invalidatePlaybackIntent(false)
this.setProgress(this.data.durationSeconds, this.data.durationSeconds)
this.setData({ playing: false, loading: false })
})
context.onTimeUpdate(() => {
if (
!this.isCurrentContext(context, generation)
|| this._scrubbing
|| this._pendingSeek
) return
const duration = Number(context.duration) || this.data.durationSeconds
const current = Number(context.currentTime) || 0
this.setProgress(current, duration)
})
if (typeof context.onSeeked === 'function') {
context.onSeeked(() => {
if (!this.isCurrentContext(context, generation)) return
this.finishSeekFromEvent(context, generation)
})
}
context.onError(() => {
if (!this.isCurrentContext(context, generation)) return
this.invalidatePlaybackIntent(false)
this.setData({
playing: false,
loading: false,
error: '声音暂时没打开,您可以返回继续看连环画。',
})
})
this.bindAudioInterruptionHandlers()
return context
},
beginPlaybackIntent() {
this._intentGeneration += 1
this._desiredPlayback = true
this._activePlayIntentGeneration = this._intentGeneration
return this._intentGeneration
},
clearSeekState(updateData = true) {
if (this._seekTimer !== null) {
clearTimeout(this._seekTimer)
this._seekTimer = null
}
this._seekOperationGeneration += 1
this._pendingSeek = null
this._scrubbing = false
if (updateData && !this._unloaded) this.setData({ seekLocked: false })
},
clearPauseLock(unlock = true) {
if (this._pauseLockTimer !== null) {
clearTimeout(this._pauseLockTimer)
this._pauseLockTimer = null
}
this._controlOperationGeneration += 1
this._pendingPauseLock = null
if (unlock) this._controlLocked = false
},
beginPauseLock(context) {
this.clearPauseLock(true)
const operationGeneration = this._controlOperationGeneration + 1
this._controlOperationGeneration = operationGeneration
const pending = {
operationGeneration,
contextGeneration: this._contextGeneration,
intentGeneration: this._intentGeneration,
lifecycleGeneration: this._lifecycleGeneration,
context,
}
this._pendingPauseLock = pending
this._controlLocked = true
this._pauseLockTimer = setTimeout(() => {
this.releasePauseLock(
operationGeneration,
pending.contextGeneration,
pending.intentGeneration,
pending.lifecycleGeneration,
context,
)
}, PAUSE_LOCK_TIMEOUT_MS)
},
releasePauseLock(
operationGeneration,
contextGeneration,
intentGeneration,
lifecycleGeneration,
context,
) {
const pending = this._pendingPauseLock
if (
!pending
|| pending.operationGeneration !== operationGeneration
|| pending.contextGeneration !== contextGeneration
|| pending.intentGeneration !== intentGeneration
|| pending.lifecycleGeneration !== lifecycleGeneration
|| pending.context !== context
|| !this.isCurrentContext(context, contextGeneration)
|| this._intentGeneration !== intentGeneration
|| this._lifecycleGeneration !== lifecycleGeneration
|| this._desiredPlayback
) return false
if (this._pauseLockTimer !== null) {
clearTimeout(this._pauseLockTimer)
this._pauseLockTimer = null
}
this._pendingPauseLock = null
this._controlLocked = false
return true
},
releasePauseLockFromEvent(context, contextGeneration) {
const pending = this._pendingPauseLock
if (!pending) {
if (this.isCurrentContext(context, contextGeneration)) {
this._controlLocked = false
}
return
}
this.releasePauseLock(
pending.operationGeneration,
contextGeneration,
pending.intentGeneration,
pending.lifecycleGeneration,
context,
)
},
invalidatePlaybackIntent(pauseContext = true) {
this._intentGeneration += 1
this._activePlayIntentGeneration = 0
this._desiredPlayback = false
this.clearPauseLock(true)
this.clearSeekState(!this._unloaded)
if (
pauseContext
&& this.audioContext
&& typeof this.audioContext.pause === 'function'
) {
this.audioContext.pause()
}
},
startPlaybackWithIntent(intentGeneration) {
const context = this.audioContext
if (
!context
|| this._unloaded
|| intentGeneration !== this._intentGeneration
|| !this._desiredPlayback
) return false
this._controlLocked = true
this.setData({
loading: true,
playing: false,
hasStarted: true,
error: '',
})
context.play()
return true
},
bindAudioInterruptionHandlers() {
if (this._audioInterruptionBeginHandler) return
const generation = this._lifecycleGeneration
this._audioInterruptionBeginHandler = () => {
if (this._unloaded || this._lifecycleGeneration !== generation) return
this.pauseWithoutAutoResume()
}
this._audioInterruptionEndHandler = () => {
if (this._unloaded || this._lifecycleGeneration !== generation) return
this.invalidatePlaybackIntent(false)
this.setData({ playing: false, loading: false })
}
if (typeof wx.onAudioInterruptionBegin === 'function') {
wx.onAudioInterruptionBegin(this._audioInterruptionBeginHandler)
}
if (typeof wx.onAudioInterruptionEnd === 'function') {
wx.onAudioInterruptionEnd(this._audioInterruptionEndHandler)
}
},
unbindAudioInterruptionHandlers() {
if (
this._audioInterruptionBeginHandler
&& typeof wx.offAudioInterruptionBegin === 'function'
) {
wx.offAudioInterruptionBegin(this._audioInterruptionBeginHandler)
}
if (
this._audioInterruptionEndHandler
&& typeof wx.offAudioInterruptionEnd === 'function'
) {
wx.offAudioInterruptionEnd(this._audioInterruptionEndHandler)
}
this._audioInterruptionBeginHandler = null
this._audioInterruptionEndHandler = null
},
pauseWithoutAutoResume() {
const context = this.audioContext
this.invalidatePlaybackIntent(false)
if (context && typeof context.pause === 'function') {
this.beginPauseLock(context)
try {
context.pause()
} catch (_) {
this.clearPauseLock(true)
}
}
if (!this._unloaded) {
this.setData({ playing: false, loading: false })
}
},
onHide() {
this.pauseWithoutAutoResume()
},
onShow() {
if (this._unloaded) return
this.clearPauseLock(true)
this.setData({ playing: false, loading: false, seekLocked: false })
},
togglePlayback() {
if (this.data.loading || this._controlLocked || this._unloaded) return
if (!this.audioContext && this._page) {
this.createAudioContext(this._page.audioSrc)
}
if (!this.audioContext) return
if (this.data.playing || this._desiredPlayback) {
this.pauseWithoutAutoResume()
return
}
const intentGeneration = this.beginPlaybackIntent()
this.startPlaybackWithIntent(intentGeneration)
},
requestSeek(targetValue, options = {}) {
const context = this.audioContext
if (
!context
|| this._unloaded
|| this.data.loading
|| this._pendingSeek
) return false
const duration = Math.max(
1,
Number(context.duration) || Number(this.data.durationSeconds) || 1,
)
const target = clamp(targetValue, 0, duration)
const actualBeforeSeek = Number(context.currentTime)
const previousValue = clamp(
Number.isFinite(actualBeforeSeek)
? actualBeforeSeek
: Number(this.data.sliderValue) || 0,
0,
duration,
)
const operationGeneration = this._seekOperationGeneration + 1
this._seekOperationGeneration = operationGeneration
const pending = {
operationGeneration,
contextGeneration: this._contextGeneration,
intentGeneration: this._intentGeneration,
context,
target,
previousValue,
playAfterSeek: Boolean(options.playAfterSeek),
}
this._pendingSeek = pending
this._scrubbing = false
this.setProgress(target, duration)
this.setData({ seekLocked: true, error: '' })
this._seekTimer = setTimeout(() => {
this.failSeek(
operationGeneration,
pending.contextGeneration,
pending.intentGeneration,
context,
)
}, SEEK_TIMEOUT_MS)
try {
context.seek(target)
} catch (_) {
this.failSeek(
operationGeneration,
pending.contextGeneration,
pending.intentGeneration,
context,
)
return false
}
return true
},
finishSeekFromEvent(context, contextGeneration) {
const pending = this._pendingSeek
if (!pending) return
const actual = Number(context.currentTime)
if (Number.isFinite(actual) && Math.abs(actual - pending.target) > 1.25) return
this.finishSeek(
pending.operationGeneration,
contextGeneration,
pending.intentGeneration,
context,
)
},
finishSeek(
operationGeneration,
contextGeneration,
intentGeneration,
context,
) {
const pending = this._pendingSeek
if (
!pending
|| pending.operationGeneration !== operationGeneration
|| pending.contextGeneration !== contextGeneration
|| pending.intentGeneration !== intentGeneration
|| pending.context !== context
|| !this.isCurrentContext(context, contextGeneration)
|| this._intentGeneration !== intentGeneration
) return
if (this._seekTimer !== null) {
clearTimeout(this._seekTimer)
this._seekTimer = null
}
this._pendingSeek = null
this.setData({ seekLocked: false })
if (
pending.playAfterSeek
&& this._desiredPlayback
&& this._activePlayIntentGeneration === intentGeneration
) {
this.startPlaybackWithIntent(intentGeneration)
}
},
failSeek(
operationGeneration,
contextGeneration,
intentGeneration,
context,
) {
const pending = this._pendingSeek
if (
!pending
|| pending.operationGeneration !== operationGeneration
|| pending.contextGeneration !== contextGeneration
|| pending.intentGeneration !== intentGeneration
|| pending.context !== context
|| !this.isCurrentContext(context, contextGeneration)
|| this._intentGeneration !== intentGeneration
) return false
if (this._seekTimer !== null) {
clearTimeout(this._seekTimer)
this._seekTimer = null
}
this._pendingSeek = null
this._scrubbing = false
const actual = Number(context.currentTime)
const restoredValue = Number.isFinite(actual)
? actual
: pending.previousValue
if (pending.playAfterSeek) this.invalidatePlaybackIntent(false)
this.setProgress(restoredValue, this.data.durationSeconds)
this.setData({
playing: pending.playAfterSeek ? false : this.data.playing,
loading: false,
seekLocked: false,
error: '进度没有调好,请再试一次。',
})
return true
},
previewSeek(event) {
if (!this.audioContext || this.data.loading || this._pendingSeek) return
this._scrubbing = true
this.setProgress(eventValue(event), this.data.durationSeconds)
},
commitSeek(event) {
if (!this.audioContext || this.data.loading || this._pendingSeek) return
this._scrubbing = false
this.requestSeek(eventValue(event))
},
rewindTenSeconds() {
if (!this.audioContext || this.data.loading || this._pendingSeek) return
const current = Number(this.audioContext.currentTime)
const fallback = Number(this.data.sliderValue) || 0
this.requestSeek(Math.max(0, (Number.isFinite(current) ? current : fallback) - 10))
},
replay() {
if (
!this.audioContext
|| this.data.loading
|| this._controlLocked
|| this._pendingSeek
|| this._unloaded
) return
const alreadyPlaying = this.data.playing && this._desiredPlayback
const intentGeneration = alreadyPlaying
? this._intentGeneration
: this.beginPlaybackIntent()
this.setData({ hasStarted: true, error: '' })
this.requestSeek(0, {
playAfterSeek: !alreadyPlaying,
intentGeneration,
})
},
changePlaybackRate(event) {
const requested = Number(
event && event.currentTarget && event.currentTarget.dataset.rate,
)
if (
!RATE_VALUES.includes(requested)
|| !this.audioContext
|| !this.data.rateControlsVisible
) return
const allowed = this.data.rateOptions.some((item) => item.value === requested)
if (!allowed) return
try {
this.audioContext.playbackRate = requested
if (Math.abs(Number(this.audioContext.playbackRate) - requested) > 0.001) {
this.disableRateControls(this.audioContext)
return
}
this.setData({ playbackRate: requested })
} catch (_) {
this.disableRateControls(this.audioContext)
}
},
goBack() {
const pages = typeof getCurrentPages === 'function' ? getCurrentPages() : []
const returnToChapter = () => {
if (typeof wx.reLaunch === 'function') {
wx.reLaunch({
url: '/package-game/pages/chapter/chapter?chapter=1',
})
}
}
if (pages.length > 1 && typeof wx.navigateBack === 'function') {
wx.navigateBack({ delta: 1, fail: returnToChapter })
return
}
returnToChapter()
},
destroyAudioContext() {
const context = this.audioContext
if (!context) return
this.invalidatePlaybackIntent(false)
this._contextGeneration += 1
this.audioContext = null
context.destroy()
if (!this._unloaded) {
this.setData({
audioReady: false,
playing: false,
loading: false,
rateOptions: FALLBACK_RATE_OPTIONS,
rateControlsVisible: false,
playbackRate: 1,
})
}
},
onUnload() {
this._unloaded = true
this._lifecycleGeneration += 1
this._intentGeneration += 1
this._activePlayIntentGeneration = 0
this._desiredPlayback = false
this._controlLocked = true
this.clearPauseLock(false)
this.clearSeekState(false)
this.unbindAudioInterruptionHandlers()
const context = this.audioContext
this._contextGeneration += 1
this.audioContext = null
if (context) context.destroy()
},
})
@@ -0,0 +1,3 @@
{
"disableScroll": true
}
@@ -0,0 +1,63 @@
<view class="audio-leaf">
<view class="audio-leaf-topbar">
<button class="back-top" bindtap="goBack" aria-label="返回刚才的连环画"> 返回连环画</button>
<view class="leaf-heading">
<text class="leaf-kicker">第01回 · 有声夹页</text>
<text class="leaf-count">{{pageNumber}}8</text>
</view>
</view>
<view class="audio-leaf-spread">
<view class="leaf-art-frame">
<image class="leaf-art" src="{{imageSrc}}" mode="aspectFit"></image>
<text class="leaf-stamp">桂香故事</text>
</view>
<view class="leaf-copy">
<text class="leaf-title">{{title}}</text>
<text class="leaf-caption">{{caption}}</text>
<view class="leaf-seek-row">
<slider
class="leaf-slider"
aria-label="播放进度,可左右拖动"
min="0"
max="{{durationSeconds}}"
step="1"
value="{{sliderValue}}"
activeColor="#9b3328"
backgroundColor="#d6c6a1"
block-color="#8f271f"
block-size="24"
disabled="{{loading || !audioReady || seekLocked}}"
bindchanging="previewSeek"
bindchange="commitSeek"
></slider>
<text class="leaf-time">{{currentLabel}} / {{durationLabel}}</text>
</view>
<view class="leaf-controls">
<button class="leaf-button" disabled="{{loading || !audioReady || seekLocked}}" bindtap="rewindTenSeconds">后退10秒</button>
<button class="leaf-button primary" disabled="{{loading || !audioReady || seekLocked}}" bindtap="togglePlayback">
{{loading ? '正在准备' : playing ? '暂停' : hasStarted ? '继续听' : '开始听'}}
</button>
<button class="leaf-button" disabled="{{loading || !audioReady || seekLocked}}" bindtap="replay">从头重听</button>
</view>
<view wx:if="{{rateControlsVisible}}" class="leaf-rate-row" aria-label="播放速度">
<text class="leaf-rate-label">语速</text>
<button
wx:for="{{rateOptions}}"
wx:key="value"
class="leaf-rate-button {{playbackRate == item.value ? 'active' : ''}}"
data-rate="{{item.value}}"
disabled="{{loading || seekLocked}}"
bindtap="changePlaybackRate"
>{{item.label}}</button>
</view>
<text wx:if="{{error}}" class="leaf-error">{{error}}</text>
<button class="back-bottom" bindtap="goBack">看完,回到连环画</button>
</view>
</view>
</view>
@@ -0,0 +1,171 @@
page {
width: 100%;
min-height: 100%;
background: #1c130f;
color: #2f241d;
}
button::after { border: 0; }
.audio-leaf {
box-sizing: border-box;
width: 100vw;
height: 100vh;
min-height: 320px;
padding: max(8px, env(safe-area-inset-top)) max(12px, env(safe-area-inset-right)) max(8px, env(safe-area-inset-bottom)) max(12px, env(safe-area-inset-left));
display: flex;
flex-direction: column;
gap: 8px;
background: radial-gradient(circle at 48% 45%, #423024 0, #211611 70%);
}
.audio-leaf-topbar {
height: 52px;
flex: 0 0 52px;
display: grid;
grid-template-columns: 160px minmax(0, 1fr);
gap: 10px;
align-items: center;
padding-right: max(92px, env(safe-area-inset-right));
color: #f5e6b7;
}
.back-top {
box-sizing: border-box;
width: 100%;
max-width: 100%;
min-height: 48px;
border: 1px solid #9b7a51;
border-radius: 10px;
background: #38251b;
color: #f7e8bd;
font-size: 20px;
line-height: 48px;
text-align: center;
padding: 0 12px;
}
.leaf-heading { display: flex; align-items: baseline; gap: 12px; }
.leaf-kicker { font-size: 20px; font-weight: 700; }
.leaf-count { color: #d9bb78; font-size: 18px; }
.audio-leaf-spread {
min-height: 0;
flex: 1;
display: grid;
grid-template-columns: minmax(0, 3fr) minmax(300px, 2fr);
border: 2px solid #b99762;
background: #efe2bd;
box-shadow: 0 10px 34px rgba(0, 0, 0, .4);
}
.leaf-art-frame {
position: relative;
min-width: 0;
min-height: 0;
overflow: hidden;
background: #17100c;
border-right: 4px solid #8d271f;
}
.leaf-art { width: 100%; height: 100%; display: block; }
.leaf-stamp {
position: absolute;
left: 18px;
bottom: 16px;
padding: 6px 12px;
background: rgba(86, 24, 20, .9);
color: #f5e6bd;
font-size: 17px;
letter-spacing: 2px;
}
.leaf-copy {
min-width: 0;
min-height: 0;
padding: 18px 20px 14px;
display: flex;
flex-direction: column;
background: repeating-linear-gradient(0deg, rgba(119, 87, 46, .04) 0, rgba(119, 87, 46, .04) 1px, transparent 1px, transparent 4px), #f6edcf;
}
.leaf-title { color: #8f271f; font-size: 28px; font-weight: 800; line-height: 1.25; }
.leaf-caption { margin-top: 10px; font-size: 22px; font-weight: 600; line-height: 1.48; }
.leaf-seek-row {
min-width: 0;
min-height: 48px;
margin-top: auto;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 6px;
align-items: center;
}
.leaf-slider { min-width: 0; width: 100%; margin: 0; }
.leaf-time { min-width: 82px; color: #6d5b48; font-size: 17px; text-align: right; }
.leaf-controls {
min-width: 0;
margin-top: 6px;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.leaf-button, .back-bottom {
box-sizing: border-box;
min-height: 48px;
border: 2px solid #7e6648;
border-radius: 10px;
background: #eadbb5;
color: #37291f;
font-size: 18px;
font-weight: 700;
line-height: 46px;
min-width: 0;
margin: 0;
padding: 0 6px;
}
.leaf-button { width: 100%; max-width: 100%; }
.leaf-button.primary { border-color: #8f271f; background: #9e3027; color: #fff2cf; }
.leaf-button[disabled] { opacity: .72; }
.leaf-rate-row {
min-width: 0;
min-height: 48px;
margin-top: 6px;
display: grid;
grid-template-columns: auto repeat(3, minmax(48px, 1fr));
gap: 6px;
align-items: center;
}
.leaf-rate-label { color: #6d5b48; font-size: 18px; font-weight: 700; }
.leaf-rate-button {
box-sizing: border-box;
min-width: 48px;
min-height: 48px;
margin: 0;
padding: 0 4px;
border: 2px solid #9d896a;
border-radius: 9px;
background: #f3e7c8;
color: #4d3b2d;
font-size: 17px;
font-weight: 700;
line-height: 46px;
}
.leaf-rate-button { width: 100%; max-width: 100%; }
.leaf-rate-button.active { border-color: #8f271f; background: #8f271f; color: #fff2cf; }
.leaf-rate-button[disabled] { opacity: .72; }
.leaf-error { margin-top: 8px; color: #8f271f; font-size: 17px; line-height: 1.35; }
.back-bottom { margin-top: 8px; width: 100%; }
@media (max-height: 430px) {
.audio-leaf { gap: 4px; padding-top: 4px; padding-bottom: 4px; }
.audio-leaf-topbar { height: 48px; flex-basis: 48px; }
.leaf-copy { padding: 8px 12px; overflow-y: auto; }
.leaf-title { font-size: 23px; }
.leaf-caption { margin-top: 4px; font-size: 17px; line-height: 1.3; }
.leaf-seek-row { min-height: 48px; }
.leaf-controls, .leaf-rate-row { margin-top: 3px; gap: 5px; }
.leaf-button, .back-bottom { min-height: 48px; line-height: 46px; font-size: 17px; }
.leaf-rate-button { min-height: 48px; line-height: 46px; font-size: 16px; }
.leaf-rate-label { font-size: 16px; }
.back-bottom { display: none; }
}
@@ -0,0 +1,14 @@
/**
* 发布环境可覆盖这些值。
*
* cdnBaseUrl 默认留空:未配置合法 HTTPS 下载域名时,AssetManager 会直接
* 使用包内种子图或返回文字回退,不会发起网络请求。
*/
module.exports = {
cdnBaseUrl: '',
imageCacheBudgetBytes: 28 * 1024 * 1024,
audioCacheBudgetBytes: 32 * 1024 * 1024,
audioCacheMaxEntries: 32,
prefetchAhead: 2,
downloadConcurrency: 2,
}
@@ -0,0 +1,911 @@
/**
* 发布固定资产清单 v1
*
* 约束:
* 1. 页面只保存稳定的 assetId,不直接拼 CDN 地址。
* 2. remotePath 必须包含对应文件内容哈希的前 12 位,发布后不可覆盖。
* 3. 医学结论、判断答案和行动建议必须随代码包发布,不得放进远端资产。
* 4. localSeed 是当前小程序包内的安全回退;以后迁移 CDN 时可按资产逐项移除。
*/
const RELEASE_ASSET_MANIFEST_VERSION = 1
const releaseAssets = {
'comic.s01.c01.p01': {
kind: 'image',
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P01-title-v1.jpg',
remotePath: 'comic/s01-c01/S01-C01-P01-title-v1.12163b7f4667.jpg',
sha256: '12163b7f466766af13a75024189aa28441627f67a6623b1c4b99b29110ecb35a',
},
'comic.s01.c01.p02': {
kind: 'image',
localSeed: '/assets/comic/s01-c01/S01-C01-P02-missing-table-ensemble-v1.jpg',
remotePath: 'comic/s01-c01/S01-C01-P02-missing-table-ensemble-v1.f34fe1faf167.jpg',
sha256: 'f34fe1faf167da590dd36595c80803f1ba8740576e600ee3c1d004bbbd4a26f0',
},
'comic.s01.c01.p03': {
kind: 'image',
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P03-H01-scan-only-v1.jpg',
remotePath: 'comic/s01-c01/S01-C01-P03-H01-scan-only-v1.6e0d20c4055f.jpg',
sha256: '6e0d20c4055ff20b8b2d47a12edb5902c89d7440334a1783acd11133d854ad80',
},
'comic.s01.c01.p04': {
kind: 'image',
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P04-H02-extra-dish-v1.jpg',
remotePath: 'comic/s01-c01/S01-C01-P04-H02-extra-dish-v1.bcef17dda041.jpg',
sha256: 'bcef17dda041c5e21e43281160ee09ded3fbb89b052e92da27fd50c7b78640ce',
},
'comic.s01.c01.p05': {
kind: 'image',
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P05-H03-four-imprints-v1.jpg',
remotePath: 'comic/s01-c01/S01-C01-P05-H03-four-imprints-v1.d2e1becd9c12.jpg',
sha256: 'd2e1becd9c12c80f7c90e63755454d618fa3741ca1c2685e04fe11f408130f5f',
},
'comic.s01.c01.p06': {
kind: 'image',
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P06-H04-unsaid-speech-v1.jpg',
remotePath: 'comic/s01-c01/S01-C01-P06-H04-unsaid-speech-v1.7bc229ef1f1c.jpg',
sha256: '7bc229ef1f1c5dc127f7e2be75dfac62a3a485984584e0caf6cf096792e03750',
},
'comic.s01.c01.p07': {
kind: 'image',
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P07-EM01-call-zhao-in-v1.jpg',
remotePath: 'comic/s01-c01/S01-C01-P07-EM01-call-zhao-in-v1.4a19c49a53ea.jpg',
sha256: '4a19c49a53eaee3d775d0b0a8965a6478d4976d820584b0abb364790294c6f31',
},
'comic.s01.c01.p08': {
kind: 'image',
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P08-cliffhanger-v1.jpg',
remotePath: 'comic/s01-c01/S01-C01-P08-cliffhanger-v1.7db63c3725ee.jpg',
sha256: '7db63c3725ee5a1ca8ad71c67746e7cd0234ed21fee07030fc1158ccd0161d69',
},
'comic.s01.c02.p01': {
kind: 'image',
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P01-title-v1.jpg',
remotePath: 'comic/s01-c02/S01-C02-P01-title-v1.c32093483c58.jpg',
sha256: 'c32093483c588ab38465e16f1c4a6bf230a0c6433925df91cf588e57fca05552',
},
'comic.s01.c02.p02': {
kind: 'image',
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P02-exhibit-ensemble-v1.jpg',
remotePath: 'comic/s01-c02/S01-C02-P02-exhibit-ensemble-v1.892de611f097.jpg',
sha256: '892de611f097d6562eb7d632eb29e91da28b5fc1c141ab4911da52c0f8f869ea',
},
'comic.s01.c02.p03': {
kind: 'image',
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P03-H05-meal-ticket-label-v1.jpg',
remotePath: 'comic/s01-c02/S01-C02-P03-H05-meal-ticket-label-v1.e725c333849e.jpg',
sha256: 'e725c333849e13dc342c01448eb34bf02b269946d4a8a40843044c7ab5f4803c',
},
'comic.s01.c02.p04': {
kind: 'image',
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P04-H06-bowl-direction-v1.jpg',
remotePath: 'comic/s01-c02/S01-C02-P04-H06-bowl-direction-v1.ac89e35c3692.jpg',
sha256: 'ac89e35c369254b81cfa0c9b350c6d485d637eb8642c13952ef6b26c889b9593',
},
'comic.s01.c02.p05': {
kind: 'image',
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P05-H07-photo-edge-clues-v1.jpg',
remotePath: 'comic/s01-c02/S01-C02-P05-H07-photo-edge-clues-v1.cda630c13f31.jpg',
sha256: 'cda630c13f31745e3ad6308cca179337c6c9c11dd33c6473f3f769d614662a4d',
},
'comic.s01.c02.p06': {
kind: 'image',
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P06-H08-plaque-reverse-v1.jpg',
remotePath: 'comic/s01-c02/S01-C02-P06-H08-plaque-reverse-v1.61d413562200.jpg',
sha256: '61d413562200e3a40ded87c77c1b6c887331810ae03ef2a5b6b533cc4a7ff541',
},
'comic.s01.c02.p07': {
kind: 'image',
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P07-EM02-slow-recognition-v1.jpg',
remotePath: 'comic/s01-c02/S01-C02-P07-EM02-slow-recognition-v1.c7a28411b1d7.jpg',
sha256: 'c7a28411b1d72d708d0c22a38b1ac3e2ccf3faac85056d603790d3c4ac3ee539',
},
'comic.s01.c02.p08': {
kind: 'image',
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P08-clock-to-1978-v1.jpg',
remotePath: 'comic/s01-c02/S01-C02-P08-clock-to-1978-v1.6c0eed6b964e.jpg',
sha256: '6c0eed6b964edb72f13810a32bcfa3509f28f002dedd4bb25c9fc89f4a594703',
},
'comic.s01.c03.p01': {
kind: 'image',
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P01-title-v1.jpg',
remotePath: 'comic/s01-c03/S01-C03-P01-title-v1.7aaefa1ff157.jpg',
sha256: '7aaefa1ff15756b6808abd6476b3e8d666fc011cc83760acaa89b8aeb60e26a4',
},
'comic.s01.c03.p02': {
kind: 'image',
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P02-canteen-ensemble-v1.jpg',
remotePath: 'comic/s01-c03/S01-C03-P02-canteen-ensemble-v1.feb9f3243262.jpg',
sha256: 'feb9f324326284127c8579153e7fba5ac32cf0e72687ea35b92986dcb7f94dd5',
},
'comic.s01.c03.p03': {
kind: 'image',
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P03-H09-oily-hand-bun-v1.jpg',
remotePath: 'comic/s01-c03/S01-C03-P03-H09-oily-hand-bun-v1.841146ea446b.jpg',
sha256: '841146ea446b6d42e703f02c1f88446f1e8fde1f7f64fb8d3f3dc4ba01d1b95e',
},
'comic.s01.c03.p04': {
kind: 'image',
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P04-H10-bench-balance-v1.jpg',
remotePath: 'comic/s01-c03/S01-C03-P04-H10-bench-balance-v1.8a838a7d0bac.jpg',
sha256: '8a838a7d0bac89fe6d728cc2002f5cf4578659d7b5f4619efdaaa386e5a7d727',
},
'comic.s01.c03.p05': {
kind: 'image',
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P05-H11-staple-era-context-v1.jpg',
remotePath: 'comic/s01-c03/S01-C03-P05-H11-staple-era-context-v1.cbd7d426ee42.jpg',
sha256: 'cbd7d426ee427c16429066feff5afd0300e65ac73dc25beb134ed4947ddc6b79',
},
'comic.s01.c03.p06': {
kind: 'image',
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P06-H12-two-clips-v1.jpg',
remotePath: 'comic/s01-c03/S01-C03-P06-H12-two-clips-v1.00e5034f636a.jpg',
sha256: '00e5034f636aed3b8be3bfecf8be84a12b2dd80763876a5df31eaa81540d77f1',
},
'comic.s01.c03.p07': {
kind: 'image',
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P07-EM03-ask-tired-v1.jpg',
remotePath: 'comic/s01-c03/S01-C03-P07-EM03-ask-tired-v1.787bf794fd53.jpg',
sha256: '787bf794fd536bbab6bbb0e2ba9fe68193b74dbb1810ddf3929f8f332c4df602',
},
'comic.s01.c03.p08': {
kind: 'image',
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P08-third-bowl-cliffhanger-v1.jpg',
remotePath: 'comic/s01-c03/S01-C03-P08-third-bowl-cliffhanger-v1.056ef8b3017c.jpg',
sha256: '056ef8b3017c857574093fd86478fab7d349261202ca65919129219a2f2dd4e5',
},
'comic.s01.c04.p01': {
kind: 'image',
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P01-title-v1.jpg',
remotePath: 'comic/s01-c04/S01-C04-P01-title-v1.e20d8bd83bfb.jpg',
sha256: 'e20d8bd83bfb6084bf4aac30078fda6a5024bbf1be46a7b40e001fc510e05a30',
},
'comic.s01.c04.p02': {
kind: 'image',
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P02-ensemble-v1.jpg',
remotePath: 'comic/s01-c04/S01-C04-P02-ensemble-v1.d83c35c61a7c.jpg',
sha256: 'd83c35c61a7cff187fbc8e0a8bb5355f856413c6ebddfb32ef69d92f0dea9505',
},
'comic.s01.c04.p03': {
kind: 'image',
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P03-H13-ladle-contest-v1.jpg',
remotePath: 'comic/s01-c04/S01-C04-P03-H13-ladle-contest-v1.c90b76c43463.jpg',
sha256: 'c90b76c43463f456c5323a06734f8c5e48f81938990d1715a1f3807bea1ce3d4',
},
'comic.s01.c04.p04': {
kind: 'image',
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P04-H14-belt-table-v1.jpg',
remotePath: 'comic/s01-c04/S01-C04-P04-H14-belt-table-v1.70da80c5dd1d.jpg',
sha256: '70da80c5dd1d40279540cd1fcc657aaf6a99ece7a36a81c979f734a78fecf5d7',
},
'comic.s01.c04.p05': {
kind: 'image',
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P05-H15-water-reminder-v1.jpg',
remotePath: 'comic/s01-c04/S01-C04-P05-H15-water-reminder-v1.4f4212b949b9.jpg',
sha256: '4f4212b949b9e25bdc5041d0dc0c1c4a8e2d1247efc387b424573e58b01eff3a',
},
'comic.s01.c04.p06': {
kind: 'image',
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P06-H16-stool-bowl-v1.jpg',
remotePath: 'comic/s01-c04/S01-C04-P06-H16-stool-bowl-v1.25834d3e9082.jpg',
sha256: '25834d3e908299dff9f74442eebf615d5eaf1dce22d06b2e5739a3e2a1ba8cc2',
},
'comic.s01.c04.p07': {
kind: 'image',
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P07-EM04-face-saving-pause-v1.jpg',
remotePath: 'comic/s01-c04/S01-C04-P07-EM04-face-saving-pause-v1.3f741e4ac31c.jpg',
sha256: '3f741e4ac31cb55ad4a2956d07b183ecb665e297f4612f7ba8c6c3f6becc971d',
},
'comic.s01.c04.p08': {
kind: 'image',
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P08-cliffhanger-v1.jpg',
remotePath: 'comic/s01-c04/S01-C04-P08-cliffhanger-v1.9558ee49f2e2.jpg',
sha256: '9558ee49f2e2b6b53eff173425a2adc31969f8532ff584b94980bd51b757fe61',
},
'comic.s01.c05.p01': {
kind: 'image',
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P01-title-v1.jpg',
remotePath: 'comic/s01-c05/S01-C05-P01-title-v1.52a79ac178e6.jpg',
sha256: '52a79ac178e6b0a154c9918010f16be797cb62d63624f820a69c6a0cd3fcc2ef',
},
'comic.s01.c05.p02': {
kind: 'image',
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P02-late-shift-ensemble-v1.jpg',
remotePath: 'comic/s01-c05/S01-C05-P02-late-shift-ensemble-v1.897aa558fe7d.jpg',
sha256: '897aa558fe7d0f4b607686d82c48c435522664c125627a023819f7ca2ad4ccfb',
},
'comic.s01.c05.p03': {
kind: 'image',
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P03-H17-empty-window-v1.jpg',
remotePath: 'comic/s01-c05/S01-C05-P03-H17-empty-window-v1.76ac18af26da.jpg',
sha256: '76ac18af26da9f5b6a9a2007fd6cf4cc81f584659c293c774222a7359bec0e22',
},
'comic.s01.c05.p04': {
kind: 'image',
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P04-H18-half-bun-v1.jpg',
remotePath: 'comic/s01-c05/S01-C05-P04-H18-half-bun-v1.c9506ed1fe98.jpg',
sha256: 'c9506ed1fe983ec7d467ade84a6b0807401292d18e8ba85a5507173e8c11d23c',
},
'comic.s01.c05.p05': {
kind: 'image',
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P05-H19-relit-stove-v1.jpg',
remotePath: 'comic/s01-c05/S01-C05-P05-H19-relit-stove-v1.a88bbf597302.jpg',
sha256: 'a88bbf597302146baf361469aa904cd9c126d411ce76feb7d1d1dd520ee783e8',
},
'comic.s01.c05.p06': {
kind: 'image',
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P06-H20-seat-at-table-v1.jpg',
remotePath: 'comic/s01-c05/S01-C05-P06-H20-seat-at-table-v1.63adf03d0d30.jpg',
sha256: '63adf03d0d305435bf17865cb4f17e28c8c3fad9274c730010a4f4580f75f8ca',
},
'comic.s01.c05.p07': {
kind: 'image',
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P07-EM05-light-for-latecomers-v1.jpg',
remotePath: 'comic/s01-c05/S01-C05-P07-EM05-light-for-latecomers-v1.172fc27a2252.jpg',
sha256: '172fc27a22529f5d6a3c12b60ec5b36a34620b50af2ab7b475287c3b70dd1567',
},
'comic.s01.c05.p08': {
kind: 'image',
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P08-first-photo-v1.jpg',
remotePath: 'comic/s01-c05/S01-C05-P08-first-photo-v1.b85b2852a7dc.jpg',
sha256: 'b85b2852a7dc274f866923f429451e488c1cd6436c62892db3e62a8952c06f3f',
},
'comic.s01.c06.p01': {
kind: 'image',
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P01-title-v1.jpg',
remotePath: 'comic/s01-c06/S01-C06-P01-title-v1.f7039560e49d.jpg',
sha256: 'f7039560e49d68615812a9d154c095e69ec04aa6a60f06f4f14822ea36d91d40',
},
'comic.s01.c06.p02': {
kind: 'image',
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P02-sign-flip-ensemble-v1.jpg',
remotePath: 'comic/s01-c06/S01-C06-P02-sign-flip-ensemble-v1.0b4519763ba5.jpg',
sha256: '0b4519763ba53e001fa8bd6903fea84c25c97cf9e6049a05bba684da1e3ec6ae',
},
'comic.s01.c06.p03': {
kind: 'image',
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P03-H21-contract-boundary-v1.jpg',
remotePath: 'comic/s01-c06/S01-C06-P03-H21-contract-boundary-v1.caf57f377be3.jpg',
sha256: 'caf57f377be31f6c018859d7e0b2149f1ce39b8ad95473a86f4e4c1f52b11f75',
},
'comic.s01.c06.p04': {
kind: 'image',
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P04-H22-ticket-cash-hands-v1.jpg',
remotePath: 'comic/s01-c06/S01-C06-P04-H22-ticket-cash-hands-v1.f05b92fd22c2.jpg',
sha256: 'f05b92fd22c222f61ea75dab51e2a0b27682362ea6f1e2ccc1e990cf11c2b469',
},
'comic.s01.c06.p05': {
kind: 'image',
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P05-H23-covered-staff-meal-v1.jpg',
remotePath: 'comic/s01-c06/S01-C06-P05-H23-covered-staff-meal-v1.408f43b54b2b.jpg',
sha256: '408f43b54b2b0f7aa5c8fade4ff917a7461333dbfec8f44f6ee1f7b66671b88d',
},
'comic.s01.c06.p06': {
kind: 'image',
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P06-H24-protect-table-v1.jpg',
remotePath: 'comic/s01-c06/S01-C06-P06-H24-protect-table-v1.84156df566a6.jpg',
sha256: '84156df566a673834861d991505b43495894fd3a7bbd87478ce4bdcf7d616e53',
},
'comic.s01.c06.p07': {
kind: 'image',
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P07-EM06-keep-seat-v1.jpg',
remotePath: 'comic/s01-c06/S01-C06-P07-EM06-keep-seat-v1.c61d898fd982.jpg',
sha256: 'c61d898fd982b5c7953ca8dc448a8d96e5c5a3894a5647707b0290a8a3b0cc9b',
},
'comic.s01.c06.p08': {
kind: 'image',
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P08-table-held-cliffhanger-v1.jpg',
remotePath: 'comic/s01-c06/S01-C06-P08-table-held-cliffhanger-v1.bcb8ec7e4720.jpg',
sha256: 'bcb8ec7e472079206983c8e6b897ad9d611a70a280251b0cdd85462938de59a5',
},
'comic.s01.c07.p01': {
kind: 'image',
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P01-title-v1.jpg',
remotePath: 'comic/s01-c07/S01-C07-P01-title-v1.42031fff6b37.jpg',
sha256: '42031fff6b3722bac2c389babd6f1eb283cb0b013d1c9a055292e585888dc720',
},
'comic.s01.c07.p02': {
kind: 'image',
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P02-opening-ensemble-v1.jpg',
remotePath: 'comic/s01-c07/S01-C07-P02-opening-ensemble-v1.81e28334e743.jpg',
sha256: '81e28334e74358e7549d2ee82bd48d7bbcf16bf424274cdf4dc568f9f040aed2',
},
'comic.s01.c07.p03': {
kind: 'image',
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P03-H25-full-bowl-v1.jpg',
remotePath: 'comic/s01-c07/S01-C07-P03-H25-full-bowl-v1.fb9d1cd9910d.jpg',
sha256: 'fb9d1cd9910dfd50c039404134443e760e6afffc9ab19eeafb528de46d6a69a5',
},
'comic.s01.c07.p04': {
kind: 'image',
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P04-H26-ladle-across-table-v1.jpg',
remotePath: 'comic/s01-c07/S01-C07-P04-H26-ladle-across-table-v1.8e12ab04091a.jpg',
sha256: '8e12ab04091a938f2155573ae0d30896f668e3c2b77a3185481dc75156950ba3',
},
'comic.s01.c07.p05': {
kind: 'image',
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P05-H27-door-and-watch-v1.jpg',
remotePath: 'comic/s01-c07/S01-C07-P05-H27-door-and-watch-v1.5d65745996d6.jpg',
sha256: '5d65745996d614acd9d38d960467658cbeea6c638e15a27861a73e2114ae01f4',
},
'comic.s01.c07.p06': {
kind: 'image',
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P06-H28-shift-table-v1.jpg',
remotePath: 'comic/s01-c07/S01-C07-P06-H28-shift-table-v1.7365643f439b.jpg',
sha256: '7365643f439bbb1d2a87f9995e3e34f7383eae1754738e2ab69a54fe54a45c6a',
},
'comic.s01.c07.p07': {
kind: 'image',
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P07-EM07-listen-until-finished-v1.jpg',
remotePath: 'comic/s01-c07/S01-C07-P07-EM07-listen-until-finished-v1.19ea61163655.jpg',
sha256: '19ea6116365526daa302aa43f9354cfd127ade2308808df565ac60ae6cb1aff5',
},
'comic.s01.c07.p08': {
kind: 'image',
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P08-order-slip-cliffhanger-v1.jpg',
remotePath: 'comic/s01-c07/S01-C07-P08-order-slip-cliffhanger-v1.f7f84ebecba2.jpg',
sha256: 'f7f84ebecba290ae9aa9f5b1960896b2151c3a1964011835d64c80cee0fa073e',
},
'comic.s01.c08.p01': {
kind: 'image',
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P01-title-v1.jpg',
remotePath: 'comic/s01-c08/S01-C08-P01-title-v1.b292709d6f22.jpg',
sha256: 'b292709d6f22e6865125f75c8b9b753a61467b09c90012b97eb206829fc64ddc',
},
'comic.s01.c08.p02': {
kind: 'image',
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P02-opening-ensemble-v1.jpg',
remotePath: 'comic/s01-c08/S01-C08-P02-opening-ensemble-v1.4b755190ba3d.jpg',
sha256: '4b755190ba3d8d9e879b89bc9579909792b7ae82059ad73bf4a4d62bfb27b562',
},
'comic.s01.c08.p03': {
kind: 'image',
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P03-H29-extra-dishes-v1.jpg',
remotePath: 'comic/s01-c08/S01-C08-P03-H29-extra-dishes-v1.2a5bc840cc2d.jpg',
sha256: '2a5bc840cc2dee6d4952ee632dc537ad7368034edbe416ff18cfef65b7a2689c',
},
'comic.s01.c08.p04': {
kind: 'image',
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P04-H30-rejected-box-v1.jpg',
remotePath: 'comic/s01-c08/S01-C08-P04-H30-rejected-box-v1.737e9996a8f3.jpg',
sha256: '737e9996a8f3d67a253d84455bfd2e96341a05e144d4b649e844875eb8eaf935',
},
'comic.s01.c08.p05': {
kind: 'image',
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P05-H31-menu-gaps-v1.jpg',
remotePath: 'comic/s01-c08/S01-C08-P05-H31-menu-gaps-v1.41da420b4e7b.jpg',
sha256: '41da420b4e7b8c585950a998d32a5cb44e6b1fc855dbd7f403c4d786f49bf618',
},
'comic.s01.c08.p06': {
kind: 'image',
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P06-H32-removed-plaque-v1.jpg',
remotePath: 'comic/s01-c08/S01-C08-P06-H32-removed-plaque-v1.fb66a9f2234c.jpg',
sha256: 'fb66a9f2234c6159678334201138e8236b4dd3f5cbc239cce0ec1e41dcddf111',
},
'comic.s01.c08.p07': {
kind: 'image',
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P07-EM08-back-kitchen-light-v1.jpg',
remotePath: 'comic/s01-c08/S01-C08-P07-EM08-back-kitchen-light-v1.6c748cf7c1a6.jpg',
sha256: '6c748cf7c1a6ea69a4be5eff221cda903e6a60f30fef95b4d7623b8d74aa54ea',
},
'comic.s01.c08.p08': {
kind: 'image',
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P08-cashbox-phone-cliffhanger-v1.jpg',
remotePath: 'comic/s01-c08/S01-C08-P08-cashbox-phone-cliffhanger-v1.2deaee916f92.jpg',
sha256: '2deaee916f92a02b7e326770ad0a6ec59410347cd1ea7133cbf61193fd7f4743',
},
'comic.s01.c09.p01': {
kind: 'image',
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P01-opening-memory-v1.jpg',
remotePath: 'comic/s01-c09/S01-C09-P01-opening-memory-v1.59db94b9c5dd.jpg',
sha256: '59db94b9c5dd52822089ebdb749959c9f2b780e16d5e6acb21cd4deb082efc2f',
},
'comic.s01.c09.p02': {
kind: 'image',
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P02-opening-ensemble-v1.jpg',
remotePath: 'comic/s01-c09/S01-C09-P02-opening-ensemble-v1.0584053b2e87.jpg',
sha256: '0584053b2e873ed91d448b2b643666e93f1733ef22dd641dfe9cdb4665d49e8d',
},
'comic.s01.c09.p03': {
kind: 'image',
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P03-H33-driver-no-alcohol-v1.jpg',
remotePath: 'comic/s01-c09/S01-C09-P03-H33-driver-no-alcohol-v1.9081d9c71e8f.jpg',
sha256: '9081d9c71e8f07c687428192cd51bb59b7a7dad9dc4ab630d924a9cd7cbc26af',
},
'comic.s01.c09.p04': {
kind: 'image',
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P04-H34-tea-toast-v1.jpg',
remotePath: 'comic/s01-c09/S01-C09-P04-H34-tea-toast-v1.cf00e6434a90.jpg',
sha256: 'cf00e6434a902127999662a040917b6807e36e4d20384426e0fb014f8eae33ff',
},
'comic.s01.c09.p05': {
kind: 'image',
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P05-H35-personal-plan-text-supported-v1-ffq2.jpg',
remotePath: 'comic/s01-c09/S01-C09-P05-H35-personal-plan-text-supported-v1-ffq2.5de6f5b46666.jpg',
sha256: '5de6f5b466664980c159f1c442be226297ea5d2832a4791ed6eda3acb18a085a',
},
'comic.s01.c09.p06': {
kind: 'image',
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P06-H36-stop-and-stay-v1.jpg',
remotePath: 'comic/s01-c09/S01-C09-P06-H36-stop-and-stay-v1.5bb10be0addb.jpg',
sha256: '5bb10be0addb68102b9b83569596523113c2b6e2053cc14cbe2b082807e55443',
},
'comic.s01.c09.p07': {
kind: 'image',
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P07-EM09-keep-my-seat-v1.jpg',
remotePath: 'comic/s01-c09/S01-C09-P07-EM09-keep-my-seat-v1.9f4b4b8b1aa9.jpg',
sha256: '9f4b4b8b1aa98844603c0c5539a89196032663d341a444dc9512b9b81c8a6e4f',
},
'comic.s01.c09.p08': {
kind: 'image',
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P08-push-cup-cliffhanger-v1-ffq2.jpg',
remotePath: 'comic/s01-c09/S01-C09-P08-push-cup-cliffhanger-v1-ffq2.b3628d1471f2.jpg',
sha256: 'b3628d1471f2c227db9a1cd2b1e00c5c92503aa7ef39fc602c054d8edc67e724',
},
'comic.s01.c10.p01': {
kind: 'image',
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P01-one-palm-space-v1.jpg',
remotePath: 'comic/s01-c10/S01-C10-P01-one-palm-space-v1.42d2b657a20f.jpg',
sha256: '42d2b657a20f5db8e18e80155301c902132c911f57fda32e32752435675ec1d8',
},
'comic.s01.c10.p02': {
kind: 'image',
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P02-opening-ensemble-v1.jpg',
remotePath: 'comic/s01-c10/S01-C10-P02-opening-ensemble-v1.30c7fa3f8e9a.jpg',
sha256: '30c7fa3f8e9af47a3289049c4e029e9c9888de93cb1a6af2dc5d19c906a3ae55',
},
'comic.s01.c10.p03': {
kind: 'image',
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P03-H37-standing-meal-v1.jpg',
remotePath: 'comic/s01-c10/S01-C10-P03-H37-standing-meal-v1.23e14923a5fd.jpg',
sha256: '23e14923a5fd3db39424fd764b1d795b2cb88e73293e1534ff2fa493e3ad05b9',
},
'comic.s01.c10.p04': {
kind: 'image',
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P04-H38-open-palm-v3.jpg',
remotePath: 'comic/s01-c10/S01-C10-P04-H38-open-palm-v3.be147c9d7c0c.jpg',
sha256: 'be147c9d7c0c5bccc7b5ddac206314c2ff96d8d424c27c0d01d87e9497f99cde',
},
'comic.s01.c10.p05': {
kind: 'image',
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P05-H39-phone-and-cold-meal-v1.jpg',
remotePath: 'comic/s01-c10/S01-C10-P05-H39-phone-and-cold-meal-v1.a21bec0d89b6.jpg',
sha256: 'a21bec0d89b6d5322a7e8ee11679ab89c89f8e4ffc8afbea0387bed5b359e5dd',
},
'comic.s01.c10.p06': {
kind: 'image',
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P06-H40-table-not-usable-v1.jpg',
remotePath: 'comic/s01-c10/S01-C10-P06-H40-table-not-usable-v1.6fa3594b0e5a.jpg',
sha256: '6fa3594b0e5ac61c432c9ee3d833c0f13bf757437c1cac6700d30826becca446',
},
'comic.s01.c10.p07': {
kind: 'image',
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P07-EM10-put-meal-down-v1.jpg',
remotePath: 'comic/s01-c10/S01-C10-P07-EM10-put-meal-down-v1.139439432818.jpg',
sha256: '13943943281892dced9132e2454c8e3b914dfda4d905668e5ed1b4d54dc6cb99',
},
'comic.s01.c10.p08': {
kind: 'image',
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P08-plan-line-last-stool-v1.jpg',
remotePath: 'comic/s01-c10/S01-C10-P08-plan-line-last-stool-v1.2499342b7c3c.jpg',
sha256: '2499342b7c3c92c101c264e59c700217989aa1157e2a95a55520ab968b4ee1a0',
},
'comic.s01.c11.p01': {
kind: 'image',
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P01-pen-above-paper-v1.jpg',
remotePath: 'comic/s01-c11/S01-C11-P01-pen-above-paper-v1.840b82256bca.jpg',
sha256: '840b82256bca2a83bc655aa86faf0025dbd5fea3a75bd5eec4c32b974f74bb2a',
},
'comic.s01.c11.p02': {
kind: 'image',
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P02-opening-ensemble-v1.jpg',
remotePath: 'comic/s01-c11/S01-C11-P02-opening-ensemble-v1.112f4fe7b4f9.jpg',
sha256: '112f4fe7b4f90a65e0769cdf78b00e2ede90b2206ca031c1fd0dc8e71bc19ff1',
},
'comic.s01.c11.p03': {
kind: 'image',
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P03-H41-display-and-seat-v1.jpg',
remotePath: 'comic/s01-c11/S01-C11-P03-H41-display-and-seat-v1.23ef59f3f23d.jpg',
sha256: '23ef59f3f23dbc3de7dcde1fd1994ff4d2321bab5218aa0197f6fae797cf2e51',
},
'comic.s01.c11.p04': {
kind: 'image',
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P04-H42-inspect-old-table-v1.jpg',
remotePath: 'comic/s01-c11/S01-C11-P04-H42-inspect-old-table-v1.3e93fafafe75.jpg',
sha256: '3e93fafafe758c040aec7a2cd0f21bc1cf71656db593cc0babe65e99c69d0a50',
},
'comic.s01.c11.p05': {
kind: 'image',
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P05-H43-clear-passage-v1.jpg',
remotePath: 'comic/s01-c11/S01-C11-P05-H43-clear-passage-v1.c0c8c01f4e37.jpg',
sha256: 'c0c8c01f4e376f29ccfbfe8b84094f79d85839afbab1d09d5301740a10311ad5',
},
'comic.s01.c11.p06': {
kind: 'image',
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P06-H44-pen-before-signature-v1.jpg',
remotePath: 'comic/s01-c11/S01-C11-P06-H44-pen-before-signature-v1.4a805d19bb22.jpg',
sha256: '4a805d19bb229c6f6df349f06fffe9acfebc3176bcf0f3b63de12e328468ca6c',
},
'comic.s01.c11.p07': {
kind: 'image',
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P07-EM11-wrap-and-record-v1.jpg',
remotePath: 'comic/s01-c11/S01-C11-P07-EM11-wrap-and-record-v1.9fb66ad32712.jpg',
sha256: '9fb66ad32712d5ad9e8ea85e4631e2ecf5bbc7d900a49066095c45579497a79a',
},
'comic.s01.c11.p08': {
kind: 'image',
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P08-can-we-open-now-v1.jpg',
remotePath: 'comic/s01-c11/S01-C11-P08-can-we-open-now-v1.b80149fb0407.jpg',
sha256: 'b80149fb0407529bf25dcd4188e79bc9d17a25e794b2d8e735b189792090e1de',
},
'comic.s01.c12.p01': {
kind: 'image',
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P01-key-pauses-half-turn-v1.jpg',
remotePath: 'comic/s01-c12/S01-C12-P01-key-pauses-half-turn-v1.e7f7138b21bb.jpg',
sha256: 'e7f7138b21bb415ff7f3fee4427973d475c5d24bf749bc0ee6bb05d8b06fee0a',
},
'comic.s01.c12.p02': {
kind: 'image',
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P02-modern-table-ensemble-v1.jpg',
remotePath: 'comic/s01-c12/S01-C12-P02-modern-table-ensemble-v1.6d2ba8cf6cfe.jpg',
sha256: '6d2ba8cf6cfe0212976b65986eda881c893673933d319a9a0579adebbf373602',
},
'comic.s01.c12.p03': {
kind: 'image',
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P03-H45-provenance-chain-v1.jpg',
remotePath: 'comic/s01-c12/S01-C12-P03-H45-provenance-chain-v1.c07a27557685.jpg',
sha256: 'c07a27557685087e0d7c772f2288d99e6847db42099ae32c72ded6f4003b18e9',
},
'comic.s01.c12.p04': {
kind: 'image',
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P04-H46-real-choice-counter-v1.jpg',
remotePath: 'comic/s01-c12/S01-C12-P04-H46-real-choice-counter-v1.1a5c07f21eae.jpg',
sha256: '1a5c07f21eaed0cdf76cec99129176cb9f8c0f608e985e5e2bfbe6e38cf1190e',
},
'comic.s01.c12.p05': {
kind: 'image',
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P05-H47-plus-minus-at-family-table-v1.jpg',
remotePath: 'comic/s01-c12/S01-C12-P05-H47-plus-minus-at-family-table-v1.695c7ebf0573.jpg',
sha256: '695c7ebf057367e728775292c2c483f66b4b7df757e568dfa247bf7a90cdb5f0',
},
'comic.s01.c12.p06': {
kind: 'image',
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P06-H48-card-is-not-a-promise-v1.jpg',
remotePath: 'comic/s01-c12/S01-C12-P06-H48-card-is-not-a-promise-v1.9b332e2a0d6d.jpg',
sha256: '9b332e2a0d6dcd499963a3ae7184aa02ce752705ba3526da3bee6a6080abed76',
},
'comic.s01.c12.p07': {
kind: 'image',
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P07-EM12-hand-choice-back-v1.jpg',
remotePath: 'comic/s01-c12/S01-C12-P07-EM12-hand-choice-back-v1.338240b0bc54.jpg',
sha256: '338240b0bc5417c467024398bc891d099a10c76384861a5ab794bdd0fcae8db5',
},
'comic.s01.c12.p08': {
kind: 'image',
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P08-rolled-speech-cliffhanger-v1.jpg',
remotePath: 'comic/s01-c12/S01-C12-P08-rolled-speech-cliffhanger-v1.a6aeee055eed.jpg',
sha256: 'a6aeee055eed00449f1945a5e6c1f01c9a1b7e63e96224b115bedf23f051cb6f',
},
'comic.s01.c13.p01': {
kind: 'image',
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P01-three-chopsticks-collide-v1.jpg',
remotePath: 'comic/s01-c13/S01-C13-P01-three-chopsticks-collide-v1.2184f354e70f.jpg',
sha256: '2184f354e70f1255420a8f1da36fdbe9f7b1f7150818a21bdb434ffabf582e7e',
},
'comic.s01.c13.p02': {
kind: 'image',
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P02-opening-ensemble-v1.jpg',
remotePath: 'comic/s01-c13/S01-C13-P02-opening-ensemble-v1.a9abf81e15a6.jpg',
sha256: 'a9abf81e15a66c0ae9e757e1f690947728a4c17a7a67059c50c4ed72a9f26198',
},
'comic.s01.c13.p03': {
kind: 'image',
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P03-H49-ask-before-serving-v1.jpg',
remotePath: 'comic/s01-c13/S01-C13-P03-H49-ask-before-serving-v1.13813c3961f7.jpg',
sha256: '13813c3961f74c0b29810f9caab66f0a9b3b24ef15324f82ec30340f2cae4f9b',
},
'comic.s01.c13.p04': {
kind: 'image',
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P04-H50-name-card-leaves-first-v1.jpg',
remotePath: 'comic/s01-c13/S01-C13-P04-H50-name-card-leaves-first-v1.d4002bf11c74.jpg',
sha256: 'd4002bf11c746f495a13903447afe6b28c2cb13f4c69b32f512f7a4400baa44d',
},
'comic.s01.c13.p05': {
kind: 'image',
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P05-H51-family-double-standard-v1.jpg',
remotePath: 'comic/s01-c13/S01-C13-P05-H51-family-double-standard-v1.2c5923ca40e2.jpg',
sha256: '2c5923ca40e232135128842308948099bad5e2e8f4ed481c4030b72965329aff',
},
'comic.s01.c13.p06': {
kind: 'image',
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P06-H52-tape-stays-rolled-v1.jpg',
remotePath: 'comic/s01-c13/S01-C13-P06-H52-tape-stays-rolled-v1.8ccbb50e6342.jpg',
sha256: '8ccbb50e6342c3063c060291583c9960b9b9bbc8fa747308558e88368516173d',
},
'comic.s01.c13.p07': {
kind: 'image',
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P07-EM13-listen-to-child-v1.jpg',
remotePath: 'comic/s01-c13/S01-C13-P07-EM13-listen-to-child-v1.edac7d0ccb9f.jpg',
sha256: 'edac7d0ccb9f0c1306265a53e6188e900a918558ef379ac9537ca07e0b68d45b',
},
'comic.s01.c13.p08': {
kind: 'image',
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P08-cook-crosses-threshold-v1.jpg',
remotePath: 'comic/s01-c13/S01-C13-P08-cook-crosses-threshold-v1.f40996a2f7d0.jpg',
sha256: 'f40996a2f7d01d33c6413fa018e049bbbada0a5cc774bbc68a364f2cefa79dcc',
},
'comic.s01.c14.p01': {
kind: 'image',
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P01-lid-opens-old-scent-v1.jpg',
remotePath: 'comic/s01-c14/S01-C14-P01-lid-opens-old-scent-v1.6609c450b024.jpg',
sha256: '6609c450b0246d0f00cb1358d2deaba7dbe25ef4287e81fcf46eb761a70248f2',
},
'comic.s01.c14.p02': {
kind: 'image',
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P02-responsibility-ensemble-v1.jpg',
remotePath: 'comic/s01-c14/S01-C14-P02-responsibility-ensemble-v1.15678b028566.jpg',
sha256: '15678b0285667a5ce84e0f486d8c8ee9ffe60ef59a6850dfcadfc0b400febd48',
},
'comic.s01.c14.p03': {
kind: 'image',
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P03-H53-ordinary-soup-no-myth-v1.jpg',
remotePath: 'comic/s01-c14/S01-C14-P03-H53-ordinary-soup-no-myth-v1.9d1a6e4c130a.jpg',
sha256: '9d1a6e4c130ad9e3873c1c73dfcadd58c216b2cc87f85c3228a2a8d5d448d365',
},
'comic.s01.c14.p04': {
kind: 'image',
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P04-H54-evidence-chain-v1.jpg',
remotePath: 'comic/s01-c14/S01-C14-P04-H54-evidence-chain-v1.899c26b37f6a.jpg',
sha256: '899c26b37f6a0ff45d13bdad0ef14c2015467f669da5ffbeb712feaf60db256e',
},
'comic.s01.c14.p05': {
kind: 'image',
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P05-H55-doctor-takes-ordinary-seat-v1.jpg',
remotePath: 'comic/s01-c14/S01-C14-P05-H55-doctor-takes-ordinary-seat-v1.b87841f55cc9.jpg',
sha256: 'b87841f55cc96f46cd199a65098ff2d4e0085da7cecf1b880d7fa073dff03801',
},
'comic.s01.c14.p06': {
kind: 'image',
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P06-H56-tea-stays-in-the-circle-v1.jpg',
remotePath: 'comic/s01-c14/S01-C14-P06-H56-tea-stays-in-the-circle-v1.c17546b0c7e9.jpg',
sha256: 'c17546b0c7e960ea8cae5fcc2cfd7e5daef907a3ecbc49d4e7823f6cf99ab3bd',
},
'comic.s01.c14.p07': {
kind: 'image',
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P07-EM14-watch-face-down-v1.jpg',
remotePath: 'comic/s01-c14/S01-C14-P07-EM14-watch-face-down-v1.d0bcbbb428bc.jpg',
sha256: 'd0bcbbb428bc9aeb5c53b0bbff2b3b6bb8b0eb3391a35ee2229e9f825b302b8d',
},
'comic.s01.c14.p08': {
kind: 'image',
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P08-measure-the-empty-place-v1.jpg',
remotePath: 'comic/s01-c14/S01-C14-P08-measure-the-empty-place-v1.09f5a22c8a7f.jpg',
sha256: '09f5a22c8a7f63deb287191b1758eed82e838b3a0a3bd6f753122a6ed946cf2c',
},
'comic.s01.c15.p01': {
kind: 'image',
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P01-chair-opens-for-late-workers-v1.jpg',
remotePath: 'comic/s01-c15/S01-C15-P01-chair-opens-for-late-workers-v1.194fda1fa427.jpg',
sha256: '194fda1fa427f25c020af7395caa55fffbc546843c724561402a20f3f2d5b8ab',
},
'comic.s01.c15.p02': {
kind: 'image',
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P02-everyone-makes-room-ensemble-v1.jpg',
remotePath: 'comic/s01-c15/S01-C15-P02-everyone-makes-room-ensemble-v1.b0e82af85954.jpg',
sha256: 'b0e82af85954a2bd1da0676810fdbd40f3357a756442768332d67768d217fd8b',
},
'comic.s01.c15.p03': {
kind: 'image',
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P03-H57-three-real-portions-v1.jpg',
remotePath: 'comic/s01-c15/S01-C15-P03-H57-three-real-portions-v1.b3838eb91bea.jpg',
sha256: 'b3838eb91bea2b1f246540996f796265912a5b6dd38abc215646db65c4560173',
},
'comic.s01.c15.p04': {
kind: 'image',
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P04-H58-water-within-reach-v1.jpg',
remotePath: 'comic/s01-c15/S01-C15-P04-H58-water-within-reach-v1.2471d7dd78aa.jpg',
sha256: '2471d7dd78aaea522d00454592863e36df6402211f25fdc7687f55d7c42383ea',
},
'comic.s01.c15.p05': {
kind: 'image',
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P05-H59-ask-before-serving-v1.jpg',
remotePath: 'comic/s01-c15/S01-C15-P05-H59-ask-before-serving-v1.215691878476.jpg',
sha256: '21569187847697f005939f8ff56a5144a3c15e06041749b6fbfa44374787540a',
},
'comic.s01.c15.p06': {
kind: 'image',
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P06-H60-check-before-packing-v1.jpg',
remotePath: 'comic/s01-c15/S01-C15-P06-H60-check-before-packing-v1.3f1cd9abb08e.jpg',
sha256: '3f1cd9abb08e28239b612b77f17cdd533efd976e39e4b4d4010fbd7598dbdf50',
},
'comic.s01.c15.p07': {
kind: 'image',
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P07-EM15-old-bowl-before-choice-v1.jpg',
remotePath: 'comic/s01-c15/S01-C15-P07-EM15-old-bowl-before-choice-v1.98a32b939746.jpg',
sha256: '98a32b939746dc1fa8c039ecba168f9290007f93527c9e355ee25e6aa7796e98',
},
'comic.s01.c15.p08': {
kind: 'image',
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P08-people-seated-season-close-v2.jpg',
remotePath: 'comic/s01-c15/S01-C15-P08-people-seated-season-close-v2.520d68ce0a72.jpg',
sha256: '520d68ce0a7297e431b335d7171d3d4a8acf598c4759ef1502ef945d43e0f113',
},
'character.female-cook': {
kind: 'image',
localSeed: '/assets/characters/female-cook.jpg',
remotePath: 'characters/female-cook.c56420b2c632.jpg',
sha256: 'c56420b2c632315aab3a44317b38358f5823424c97f80a1a8e046b0ad7ffec4f',
},
'character.lele': {
kind: 'image',
localSeed: '/assets/characters/lele.jpg',
remotePath: 'characters/lele.807f5232b1e8.jpg',
sha256: '807f5232b1e8a855dac0147bbdb203237d586ee00e2234e50ca1f30a107cc168',
},
'character.lin-xiulan': {
kind: 'image',
localSeed: '/assets/characters/lin-xiulan.jpg',
remotePath: 'characters/lin-xiulan.56e84abec4e5.jpg',
sha256: '56e84abec4e5bfa7bf1167197d1cf73e7a5cd170bf39dbe1d6123b64b0648361',
},
'character.qin-xiaoman': {
kind: 'image',
localSeed: '/assets/characters/qin-xiaoman.jpg',
remotePath: 'characters/qin-xiaoman.22fa229d5b09.jpg',
sha256: '22fa229d5b091943e2ff14a200205bb69081c6bfb467ffdb26685af65c6a2cb5',
},
'character.qin-zhicheng': {
kind: 'image',
localSeed: '/assets/characters/qin-zhicheng.jpg',
remotePath: 'characters/qin-zhicheng.7f4be48ece92.jpg',
sha256: '7f4be48ece925311081874279c63c0f5758ace85629e2b28a5f19efac0ac34d2',
},
'character.tang-mingyuan': {
kind: 'image',
localSeed: '/assets/characters/tang-mingyuan.jpg',
remotePath: 'characters/tang-mingyuan.5ca2fa88f885.jpg',
sha256: '5ca2fa88f88596f3618147e6679da0c1f7d116c476fec7c424e4ebc70ff5aceb',
},
'character.tang-shouan': {
kind: 'image',
localSeed: '/assets/characters/tang-shouan.jpg',
remotePath: 'characters/tang-shouan.4ed8d25ca911.jpg',
sha256: '4ed8d25ca9111f8a6a8597c355ad45c0e9f010c778b4e108d9b9126498f51b89',
},
'character.xiaozhen': {
kind: 'image',
localSeed: '/assets/characters/xiaozhen.jpg',
remotePath: 'characters/xiaozhen.d5e9614f6006.jpg',
sha256: 'd5e9614f60061b11914d8bfc70fa5ab18ae9c3a00bcd5e4de85dcb058e5174cb',
},
'character.zhao-jianguo': {
kind: 'image',
localSeed: '/assets/characters/zhao-jianguo.jpg',
remotePath: 'characters/zhao-jianguo.1c393fb68b02.jpg',
sha256: '1c393fb68b020b7f27bde617fb663cd943f8e184b1e78d63506cf6821a88c8e0',
},
'scene.gui-xiang.1978': {
kind: 'image',
localSeed: '/assets/scenes/gui-xiang-1978.jpg',
remotePath: 'scenes/gui-xiang-1978.bb21d9342e37.jpg',
sha256: 'bb21d9342e370ba3aed9b47555a3b77e1be48674f786aaf0ca753245c978a5d5',
},
'scene.gui-xiang.1995': {
kind: 'image',
localSeed: '/assets/scenes/gui-xiang-1995.jpg',
remotePath: 'scenes/gui-xiang-1995.a9701e2d55ad.jpg',
sha256: 'a9701e2d55ad94819becf074072f63e21d6cfbbcfbe58a483ac2e35ee10d206f',
},
'scene.gui-xiang.1998': {
kind: 'image',
localSeed: '/assets/scenes/gui-xiang-1998.jpg',
remotePath: 'scenes/gui-xiang-1998.d4d7bebfd04a.jpg',
sha256: 'd4d7bebfd04a8b755ba393699708f8971b525fc29f57b457334f73c525d1be14',
},
'scene.gui-xiang.2001': {
kind: 'image',
localSeed: '/assets/scenes/gui-xiang-2001.jpg',
remotePath: 'scenes/gui-xiang-2001.9143455b349d.jpg',
sha256: '9143455b349d990d141c6d971a90c31295ec56665502f3d0c55feee6517cfba8',
},
'scene.gui-xiang.2003': {
kind: 'image',
localSeed: '/assets/scenes/gui-xiang-2003.jpg',
remotePath: 'scenes/gui-xiang-2003.68349c74c916.jpg',
sha256: '68349c74c9168d16edb91ad308e3071e70b9a9b9abebb21bca8291df1e1f3431',
},
'scene.gui-xiang.2008': {
kind: 'image',
localSeed: '/assets/scenes/gui-xiang-2008.jpg',
remotePath: 'scenes/gui-xiang-2008.c0fd9418aec2.jpg',
sha256: 'c0fd9418aec24f2599d52167f15dbba1b1696295df309a931e125da530bc62f5',
},
'scene.gui-xiang.2026': {
kind: 'image',
localSeed: '/assets/scenes/gui-xiang-2026.jpg',
remotePath: 'scenes/gui-xiang-2026.55e63e85388e.jpg',
sha256: '55e63e85388e3c64d63167aadbfce3d0b62762d55aeb144a02508bd67cb2c6a7',
},
'audio.cast-audition.v1': {
kind: 'audio',
reviewStatus: 'audition',
localSeed: '',
remotePath: 'audio/cast-audition-v1.7129d884f445.mp3',
sha256: '7129d884f44542434e131efb86f2f5619bedf188a1d8b21e7d0864131b58dd18',
},
}
// Legacy C01 cue-level seeds are retained as immutable backup assets only.
// The reader no longer maps them as page narration; C01 uses full-page tracks
// through the two package-local audio player pages instead.
for (const cueId of [
'S01-C01-MS007','S01-C01-MS010',
]) {
const file = `${cueId}.mp3`
const sha256 = {
'S01-C01-MS007': '1ebae89180bcfef9d1efd174bc6be466593e59fd43366af50603cc5d2c71dde8', 'S01-C01-MS010': '628e19cea16a617c2600d9321339aaa78da683fd33b9c1fd6482a870702ee597',
}[cueId]
releaseAssets[`audio.s01.c01.${cueId.toLowerCase()}`] = { kind: 'audio', reviewStatus: 'approved', localSeed: `/package-game/assets/audio/${file}`, remotePath: `audio/${file}.${sha256.slice(0, 12)}.mp3`, sha256 }
}
const audioC02Sha256 = {
'S01-C02-MS001': '580bbaa7effcdbd1af308842b79943069d22753d74ca9d27f6bf24e216f07233',
'S01-C02-MS002-ATTR': 'aa54f392e8d035d9952cfc8ff127d1bd7069f12e8c6c3a08b9c10da349fb46a8',
'S01-C02-MS002': 'ccb9fb8872e49880a5e11105b2c721cddef264fa248120989a61806c99995ffe',
'S01-C02-MS003': '3f3fc816f6e79d4ee83ab36cd1c2986c9898254bb097582124c50c529bed169f',
'S01-C02-MS004': 'e9099bad6986b0a6f48094fb033305045d1d97813d42d46b131186d43a639f7c',
'S01-C02-MS005': '6ac0d1f9bea8f4a3280ccad178cdd1976c8735b7bd63c261385a493fee44d34e',
'S01-C02-MS006': '067715e6a3715433d499bfd55cd637a6d1f149e6b78b37588d5675c3429f3e88',
'S01-C02-MS007': '7d2c022a7e2a4ccb0bb9690fe7cea6cb677f390255e71a7727e9dc22b0511a8c',
'S01-C02-MS008': '288131da5b75ec8257345d0aa1c99c3b5ec534435ca02f7040ebbd5542cf940f',
'S01-C02-MS009': '5ad03edbc491459429fcd8098a3c56f86f5d4519eca75a8427bdd5a4a50fcda2',
'S01-C02-MS010': 'd371d1db33d9adbc2b0f8f8fbb9b0d15cd81a43cb3585d261d73a122608ce71d',
'S01-C02-MS011': '2606be3875e8461b102d0417326c2d8cf44207c5cd6662cda6e75ee50fe0baeb',
'S01-C02-MS012': '33dc5837d97a745024e080ab76c11af33d492c707e129338a11b9662c64d5b9a',
'S01-C02-MT000': '5085e1471366cd7296a1e5bc170b80a26e24d7dff33d768b11fe3f3b7f56586e',
'S01-C02-TE900': 'd57638264944a9bb87a3433006f70eba01b08ba7d3220a50d358bbfc77b6a9c1',
}
const audioC03Sha256 = {
'S01-C03-MS001': 'b27feb03e2f5db957fa0e81aa5b9ca3e498620cfd2521fdb70b1252343fb9c43',
'S01-C03-MS002-ATTR': 'ea3de72562174a4e1b48480b368401df890b2becf98e3fdadb46e58eb3a771dd',
'S01-C03-MS002': 'c108896a42d9ec316c7c1dea41d3855edaf577c85e16efede0b28e10613cf83c',
'S01-C03-MS003': 'af5b0743979b0ae87c7e0c7a17b20940eebe75ab9cd1bb6b29ce63afc6039184',
'S01-C03-MS004': 'c131d2d80ad17a7e6bad708efc919c6349269d320b20eee2dae3393f906f27da',
'S01-C03-MS005-ATTR': '4ad0fd3ea4ef7996a95fce3201ecbb05754ea9af08e87f4a03ab664ccf9c6a6e',
'S01-C03-MS005': '46de0ae7e37f855412bda3e441fe3d537bb7979f5db0260508dccfd1b543a442',
'S01-C03-MS006': '2ae6d3ae24430162941f5ecdb4d426a4e865e6fab0ee37b1445eca1c71a374f5',
'S01-C03-MS007-ATTR': 'f172cf850e6f90e8209b7691d5ecd6af637c6bc4f1cbe25206cbe9e78ebcb0c0',
'S01-C03-MS007': '68757f28e84dd17fb85faa19eebc4e1e8d6d39e1d49687621606cb61cb738038',
'S01-C03-MS008': 'd9352fc81b3bae1bf49f53a742745f7c7ab418e406b1b5c27d54f5da0e2d62f7',
'S01-C03-MS009': '97d9395b869e7faa051a461f0ac927aaf3cf73d9ca9cd27b3d0af200dc1131ed',
'S01-C03-MS010': '49467f7ac6584f5a9d1e355d4ca2bce5732dd037b6ff22dbf6e899513ed6e1f0',
'S01-C03-MS011': '6d76cac756daae850530e4fc295fffec5593af46009be35064fb7820dd54ea25',
'S01-C03-MS013': '8c50a597cc7fd01d93d2fc6c2ba27b85690fcde5ce2eea8323b7587889e2771a',
'S01-C03-MS014': '8eb817be47b0d933acc8a587b3226bb42160cb64f02fea9f6bbdcf773fc7dfa0',
'S01-C03-MS015': '661905745a5bd542da92d2680ab589d80f3e7d8236401acdb61b81b461f2e6bb',
'S01-C03-MT000': '374390a364c06922b44dca48e7ffca51f8775ae0d02bff8548aab3e1ff122a65',
'S01-C03-TE900': 'f9d770f2104da0e37d1b479f3e25479e49af4ec84ef51339940aef8d90f63993',
}
// C02/C03 cue files are retained only as rollback/source material. A single
// cue is not a complete page track, so these assets must not pass the runtime
// audio gate or appear as “试听本页”.
for (const [chapterNumber, audioHashes] of Object.entries({ '02': audioC02Sha256, '03': audioC03Sha256 })) {
for (const [cueId, sha256] of Object.entries(audioHashes)) {
const file = `${cueId}.mp3`
releaseAssets[`audio.s01.c${chapterNumber}.${cueId.toLowerCase()}`] = {
kind: 'audio',
reviewStatus: 'reserved',
localSeed: `/package-chapter-${chapterNumber}/assets/audio/${file}`,
remotePath: `audio/${cueId}.${sha256.slice(0, 12)}.mp3`,
sha256,
}
}
}
module.exports = {
RELEASE_ASSET_MANIFEST_VERSION,
releaseAssets,
}
@@ -0,0 +1,97 @@
/**
* C02-C15 remote full-page audio manifest v1.
*
* Import contract for a future reviewed batch:
*
* 'S01-C09-P05': Object.freeze({
* pageId: 'S01-C09-P05',
* chapterNumber: 9,
* pageNumber: 5,
* assetId: 'audio.page.s01.c09.p05',
* durationSeconds: 42.1,
* reviewStatus: 'approved',
* })
*
* The matching releaseAssetManifest entry must independently be an approved,
* remote-only, immutable hash-named audio asset. Automated QA, pending human
* listening, and missing CDN records must never be relabelled as approved.
*/
const REMOTE_PAGE_AUDIO_MANIFEST_VERSION = 1
const REMOTE_PAGE_AUDIO_ID_PATTERN = /^S01-C(0[2-9]|1[0-5])-P(0[1-8])$/
const SHA256_PATTERN = /^[a-f0-9]{64}$/
// Deliberately empty until full-page tracks pass the required human reviews
// and their immutable CDN objects are available. This is a release gate, not
// a placeholder that the UI may treat as playable.
const remotePageAudioPages = Object.freeze({})
function clean(value) {
return typeof value === 'string' ? value.trim() : ''
}
function remotePageAudioAssetId(pageId) {
const stablePageId = clean(pageId)
const match = REMOTE_PAGE_AUDIO_ID_PATTERN.exec(stablePageId)
if (!match) return ''
return `audio.page.s01.c${match[1]}.p${match[2]}`
}
function getApprovedRemotePageAudio(
pageId,
releaseAssets = {},
pageManifest = remotePageAudioPages,
) {
const stablePageId = clean(pageId)
const match = REMOTE_PAGE_AUDIO_ID_PATTERN.exec(stablePageId)
if (!match) return null
const entry = pageManifest && pageManifest[stablePageId]
if (!entry || typeof entry !== 'object') return null
const chapterNumber = Number(match[1])
const pageNumber = Number(match[2])
const assetId = remotePageAudioAssetId(stablePageId)
const durationSeconds = Number(entry.durationSeconds)
if (
clean(entry.reviewStatus).toLowerCase() !== 'approved'
|| entry.pageId !== stablePageId
|| Number(entry.chapterNumber) !== chapterNumber
|| Number(entry.pageNumber) !== pageNumber
|| entry.assetId !== assetId
|| !Number.isFinite(durationSeconds)
|| durationSeconds <= 0
) {
return null
}
const asset = releaseAssets && releaseAssets[assetId]
const sha256 = asset && typeof asset.sha256 === 'string'
? asset.sha256
: ''
const remotePath = asset && asset.remotePath
const expectedRemotePath = (
`audio/${stablePageId.slice(0, 7).toLowerCase()}`
+ `/${stablePageId}.${sha256.slice(0, 12)}.mp3`
)
if (
!asset
|| asset.kind !== 'audio'
|| clean(asset.reviewStatus).toLowerCase() !== 'approved'
|| asset.localSeed !== ''
|| !SHA256_PATTERN.test(sha256)
|| typeof remotePath !== 'string'
|| remotePath !== expectedRemotePath
) {
return null
}
return { ...entry, assetId }
}
module.exports = {
REMOTE_PAGE_AUDIO_ID_PATTERN,
REMOTE_PAGE_AUDIO_MANIFEST_VERSION,
getApprovedRemotePageAudio,
remotePageAudioAssetId,
remotePageAudioPages,
}
@@ -0,0 +1,872 @@
const {
getApprovedRemotePageAudio,
} = require('../../data/remotePageAudioManifest')
const {
releaseAssets,
} = require('../../data/releaseAssetManifest')
const assetReleaseConfig = require('../../data/assetReleaseConfig')
const {
createAssetPlatformFacade,
createAssetManager,
} = require('../../utils/assetManager')
const {
chapterRoute,
normalizeChapterNumber,
} = require('../../utils/chapterRoute')
const REMOTE_PAGE_ID_PATTERN = /^S01-C(0[2-9]|1[0-5])-P(0[1-8])$/
const SEEK_TIMEOUT_MS = 1600
const PAUSE_LOCK_TIMEOUT_MS = 1200
const RATE_VALUES = Object.freeze([0.8, 1, 1.2])
const RATE_OPTIONS = Object.freeze([
Object.freeze({ value: 0.8, label: '0.8倍' }),
Object.freeze({ value: 1, label: '1.0倍' }),
Object.freeze({ value: 1.2, label: '1.2倍' }),
])
const FALLBACK_RATE_OPTIONS = Object.freeze([
Object.freeze({ value: 1, label: '1.0倍' }),
])
function formatTime(value) {
const seconds = Math.max(0, Math.floor(Number(value) || 0))
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`
}
function clamp(value, minimum, maximum) {
return Math.min(maximum, Math.max(minimum, Number(value) || 0))
}
function eventValue(event) {
return Number(event && event.detail && event.detail.value) || 0
}
function resolveErrorMessage(reason) {
if (reason === 'remote-disabled') {
return '声音下载地址还没配置好,请返回连环画,稍后再试。'
}
if (reason === 'remote-failed') {
return '网络不太稳,声音没有下载下来。请稍后再试。'
}
if (reason === 'remote-integrity-failed') {
return '声音文件下载不完整,安全校验没有通过。请稍后重试。'
}
if (reason === 'audio-unapproved') {
return '这一页的声音还在审核,暂时不能播放。'
}
return '这一页的声音暂时没有打开,请返回连环画,稍后再试。'
}
Page({
data: {
pageId: '',
chapterNumber: 2,
pageNumber: 1,
chapterLabel: '第02回',
pageLabel: '第1页',
playing: false,
loading: false,
audioReady: false,
hasStarted: false,
seekLocked: false,
retryAvailable: false,
error: '',
currentLabel: '0:00',
durationLabel: '0:00',
durationSeconds: 1,
sliderValue: 0,
progressStyle: 'width:0%',
playbackRate: 1,
rateOptions: FALLBACK_RATE_OPTIONS,
rateControlsVisible: false,
},
onLoad(options = {}) {
this._unloaded = false
this._lifecycleGeneration = (Number(this._lifecycleGeneration) || 0) + 1
this._contextGeneration = 0
this._intentGeneration = 0
this._seekOperationGeneration = 0
this._controlOperationGeneration = 0
this._prepareGeneration = 0
this._desiredPlayback = false
this._activePlayIntentGeneration = 0
this._preparePlayIntentGeneration = 0
this._controlLocked = false
this._scrubbing = false
this._pendingSeek = null
this._seekTimer = null
this._pendingPauseLock = null
this._pauseLockTimer = null
this._preparePromise = null
this._pageAudio = null
this.bindAudioInterruptionHandlers()
const pageId = String(options.pageId || '').trim()
const match = REMOTE_PAGE_ID_PATTERN.exec(pageId)
const chapterNumber = normalizeChapterNumber(options.chapterNumber)
const pageChapterNumber = match ? Number(match[1]) : 0
const pageNumber = match ? Number(match[2]) : 0
this.setData({
pageId,
chapterNumber,
pageNumber: pageNumber || 1,
chapterLabel: `${String(chapterNumber).padStart(2, '0')}`,
pageLabel: `${pageNumber || 1}`,
})
if (
!match
|| chapterNumber < 2
|| pageChapterNumber !== chapterNumber
) {
this.setData({ error: '页面地址不对,请返回连环画重新打开。' })
return
}
const pageAudio = getApprovedRemotePageAudio(pageId, releaseAssets)
if (!pageAudio) {
this.setData({ error: '这一页的声音还在审核,暂时不能播放。' })
return
}
this._pageAudio = pageAudio
const durationSeconds = Math.max(1, Number(pageAudio.durationSeconds) || 1)
this.setData({
durationLabel: formatTime(durationSeconds),
durationSeconds,
})
},
onReady() {
// Opening the leaf is text-only. Downloading starts after an explicit tap.
return Promise.resolve(null)
},
isCurrentContext(context, generation) {
return !this._unloaded
&& this.audioContext === context
&& this._contextGeneration === generation
},
setProgress(value, durationValue) {
const duration = Math.max(
1,
Number(durationValue) || Number(this.data.durationSeconds) || 1,
)
const current = clamp(value, 0, duration)
const percent = Math.min(100, current / duration * 100)
this.setData({
currentLabel: formatTime(current),
durationLabel: formatTime(duration),
durationSeconds: duration,
sliderValue: current,
progressStyle: `width:${percent}%`,
})
},
disableRateControls(context) {
try {
if (context && typeof context.playbackRate === 'number') {
context.playbackRate = 1
}
} catch (_) {
// A runtime that rejects playbackRate remains safely at normal speed.
}
this.setData({
playbackRate: 1,
rateOptions: FALLBACK_RATE_OPTIONS,
rateControlsVisible: false,
})
},
configureRateControls(context, generation) {
try {
if (typeof context.playbackRate !== 'number') {
this.disableRateControls(context)
return
}
for (const rate of RATE_VALUES) {
context.playbackRate = rate
if (Math.abs(Number(context.playbackRate) - rate) > 0.001) {
this.disableRateControls(context)
return
}
}
context.playbackRate = 1
if (
!this.isCurrentContext(context, generation)
|| Math.abs(Number(context.playbackRate) - 1) > 0.001
) {
this.disableRateControls(context)
return
}
this.setData({
playbackRate: 1,
rateOptions: RATE_OPTIONS,
rateControlsVisible: true,
})
} catch (_) {
this.disableRateControls(context)
}
},
getAssetManager() {
if (!this._assetManager) {
this._assetManager = createAssetManager({
assetPlatform: createAssetPlatformFacade(),
manifest: releaseAssets,
...assetReleaseConfig,
})
}
return this._assetManager
},
prepareAudio(intentGeneration) {
if (
!this._pageAudio
|| this._unloaded
|| intentGeneration !== this._intentGeneration
|| !this._desiredPlayback
) return Promise.resolve(null)
this._preparePlayIntentGeneration = intentGeneration
if (this._preparePromise) return this._preparePromise
const prepareGeneration = this._prepareGeneration + 1
this._prepareGeneration = prepareGeneration
this._controlLocked = true
this.setData({
loading: true,
playing: false,
hasStarted: true,
retryAvailable: false,
error: '',
})
const assetId = this._pageAudio.assetId
const promise = this.getAssetManager().resolve(assetId)
.then((result) => {
if (
this._unloaded
|| this._prepareGeneration !== prepareGeneration
) return null
const activeIntent = this._preparePlayIntentGeneration
if (!result || !result.available || !result.uri) {
if (
activeIntent === this._intentGeneration
&& this._desiredPlayback
) {
this.invalidatePlaybackIntent(false)
this.setData({
playing: false,
loading: false,
retryAvailable: true,
error: resolveErrorMessage(result && result.reason),
})
}
return result || null
}
if (
!activeIntent
|| activeIntent !== this._intentGeneration
|| !this._desiredPlayback
) return result
this.createAudioContext(result.uri)
if (
activeIntent === this._intentGeneration
&& this._desiredPlayback
) {
this.startPlaybackWithIntent(activeIntent)
}
return result
})
.catch(() => {
if (
!this._unloaded
&& this._prepareGeneration === prepareGeneration
&& this._preparePlayIntentGeneration === this._intentGeneration
&& this._desiredPlayback
) {
this.invalidatePlaybackIntent(false)
this.setData({
playing: false,
loading: false,
retryAvailable: true,
error: resolveErrorMessage('remote-failed'),
})
}
return null
})
.finally(() => {
if (this._preparePromise === promise) this._preparePromise = null
})
this._preparePromise = promise
return promise
},
createAudioContext(src) {
if (this._unloaded) return null
if (this.audioContext) this.destroyAudioContext()
const context = wx.createInnerAudioContext()
const generation = this._contextGeneration + 1
this._contextGeneration = generation
this.audioContext = context
context.autoplay = false
context.src = src
this.setData({
audioReady: true,
loading: false,
playing: false,
retryAvailable: false,
error: '',
})
this.configureRateControls(context, generation)
context.onCanplay(() => {
if (!this.isCurrentContext(context, generation)) return
this.setData({ audioReady: true, error: '' })
})
context.onPlay(() => {
if (!this.isCurrentContext(context, generation)) return
if (
!this._desiredPlayback
|| this._activePlayIntentGeneration !== this._intentGeneration
) {
context.pause()
this._controlLocked = false
this.setData({ playing: false, loading: false })
return
}
this._controlLocked = false
this.setData({
playing: true,
loading: false,
hasStarted: true,
retryAvailable: false,
error: '',
})
})
context.onPause(() => {
if (!this.isCurrentContext(context, generation)) return
if (this._desiredPlayback) return
this.releasePauseLockFromEvent(context, generation)
this.setData({ playing: false, loading: false })
})
context.onStop(() => {
if (!this.isCurrentContext(context, generation)) return
this.invalidatePlaybackIntent(false)
this.setData({ playing: false, loading: false })
})
context.onWaiting(() => {
if (
!this.isCurrentContext(context, generation)
|| !this._desiredPlayback
) return
this.setData({ playing: false, loading: true })
})
context.onEnded(() => {
if (!this.isCurrentContext(context, generation)) return
this.invalidatePlaybackIntent(false)
this.setProgress(this.data.durationSeconds, this.data.durationSeconds)
this.setData({ playing: false, loading: false })
})
context.onTimeUpdate(() => {
if (
!this.isCurrentContext(context, generation)
|| this._scrubbing
|| this._pendingSeek
) return
const duration = Number(context.duration) || this.data.durationSeconds
const current = Number(context.currentTime) || 0
this.setProgress(current, duration)
})
if (typeof context.onSeeked === 'function') {
context.onSeeked(() => {
if (!this.isCurrentContext(context, generation)) return
this.finishSeekFromEvent(context, generation)
})
}
context.onError(() => {
if (!this.isCurrentContext(context, generation)) return
this.invalidatePlaybackIntent(false)
this.setData({
playing: false,
loading: false,
retryAvailable: true,
error: '声音播放失败了。您可以重试,或返回连环画继续看。',
})
})
this.bindAudioInterruptionHandlers()
return context
},
beginPlaybackIntent() {
this._intentGeneration += 1
this._desiredPlayback = true
this._activePlayIntentGeneration = this._intentGeneration
return this._intentGeneration
},
clearSeekState(updateData = true) {
if (this._seekTimer !== null) {
clearTimeout(this._seekTimer)
this._seekTimer = null
}
this._seekOperationGeneration += 1
this._pendingSeek = null
this._scrubbing = false
if (updateData && !this._unloaded) this.setData({ seekLocked: false })
},
clearPauseLock(unlock = true) {
if (this._pauseLockTimer !== null) {
clearTimeout(this._pauseLockTimer)
this._pauseLockTimer = null
}
this._controlOperationGeneration += 1
this._pendingPauseLock = null
if (unlock) this._controlLocked = false
},
beginPauseLock(context) {
this.clearPauseLock(true)
const operationGeneration = this._controlOperationGeneration + 1
this._controlOperationGeneration = operationGeneration
const pending = {
operationGeneration,
contextGeneration: this._contextGeneration,
intentGeneration: this._intentGeneration,
lifecycleGeneration: this._lifecycleGeneration,
context,
}
this._pendingPauseLock = pending
this._controlLocked = true
this._pauseLockTimer = setTimeout(() => {
this.releasePauseLock(
operationGeneration,
pending.contextGeneration,
pending.intentGeneration,
pending.lifecycleGeneration,
context,
)
}, PAUSE_LOCK_TIMEOUT_MS)
},
releasePauseLock(
operationGeneration,
contextGeneration,
intentGeneration,
lifecycleGeneration,
context,
) {
const pending = this._pendingPauseLock
if (
!pending
|| pending.operationGeneration !== operationGeneration
|| pending.contextGeneration !== contextGeneration
|| pending.intentGeneration !== intentGeneration
|| pending.lifecycleGeneration !== lifecycleGeneration
|| pending.context !== context
|| !this.isCurrentContext(context, contextGeneration)
|| this._intentGeneration !== intentGeneration
|| this._lifecycleGeneration !== lifecycleGeneration
|| this._desiredPlayback
) return false
if (this._pauseLockTimer !== null) {
clearTimeout(this._pauseLockTimer)
this._pauseLockTimer = null
}
this._pendingPauseLock = null
this._controlLocked = false
return true
},
releasePauseLockFromEvent(context, contextGeneration) {
const pending = this._pendingPauseLock
if (!pending) {
if (this.isCurrentContext(context, contextGeneration)) {
this._controlLocked = false
}
return
}
this.releasePauseLock(
pending.operationGeneration,
contextGeneration,
pending.intentGeneration,
pending.lifecycleGeneration,
context,
)
},
invalidatePlaybackIntent(pauseContext = true) {
this._intentGeneration += 1
this._activePlayIntentGeneration = 0
this._preparePlayIntentGeneration = 0
this._desiredPlayback = false
this.clearPauseLock(true)
this.clearSeekState(!this._unloaded)
if (
pauseContext
&& this.audioContext
&& typeof this.audioContext.pause === 'function'
) {
this.audioContext.pause()
}
},
startPlaybackWithIntent(intentGeneration) {
const context = this.audioContext
if (
!context
|| this._unloaded
|| intentGeneration !== this._intentGeneration
|| !this._desiredPlayback
) return false
this._controlLocked = true
this.setData({
loading: true,
playing: false,
hasStarted: true,
retryAvailable: false,
error: '',
})
context.play()
return true
},
bindAudioInterruptionHandlers() {
if (this._audioInterruptionBeginHandler) return
const generation = this._lifecycleGeneration
this._audioInterruptionBeginHandler = () => {
if (this._unloaded || this._lifecycleGeneration !== generation) return
this.pauseWithoutAutoResume()
}
this._audioInterruptionEndHandler = () => {
if (this._unloaded || this._lifecycleGeneration !== generation) return
this.invalidatePlaybackIntent(false)
this.setData({ playing: false, loading: false })
}
if (typeof wx.onAudioInterruptionBegin === 'function') {
wx.onAudioInterruptionBegin(this._audioInterruptionBeginHandler)
}
if (typeof wx.onAudioInterruptionEnd === 'function') {
wx.onAudioInterruptionEnd(this._audioInterruptionEndHandler)
}
},
unbindAudioInterruptionHandlers() {
if (
this._audioInterruptionBeginHandler
&& typeof wx.offAudioInterruptionBegin === 'function'
) {
wx.offAudioInterruptionBegin(this._audioInterruptionBeginHandler)
}
if (
this._audioInterruptionEndHandler
&& typeof wx.offAudioInterruptionEnd === 'function'
) {
wx.offAudioInterruptionEnd(this._audioInterruptionEndHandler)
}
this._audioInterruptionBeginHandler = null
this._audioInterruptionEndHandler = null
},
pauseWithoutAutoResume() {
const context = this.audioContext
this.invalidatePlaybackIntent(false)
if (context && typeof context.pause === 'function') {
this.beginPauseLock(context)
try {
context.pause()
} catch (_) {
this.clearPauseLock(true)
}
}
if (!this._unloaded) {
this.setData({ playing: false, loading: false })
}
},
onHide() {
this.pauseWithoutAutoResume()
},
onShow() {
if (this._unloaded) return
this.clearPauseLock(true)
this.setData({ playing: false, loading: false, seekLocked: false })
},
togglePlayback() {
if (this.data.loading || this._controlLocked || this._unloaded) return
if (!this.audioContext) {
const intentGeneration = this.beginPlaybackIntent()
return this.prepareAudio(intentGeneration)
}
if (this.data.playing || this._desiredPlayback) {
this.pauseWithoutAutoResume()
return
}
const intentGeneration = this.beginPlaybackIntent()
this.startPlaybackWithIntent(intentGeneration)
},
requestSeek(targetValue, options = {}) {
const context = this.audioContext
if (
!context
|| this._unloaded
|| this.data.loading
|| this._pendingSeek
) return false
const duration = Math.max(
1,
Number(context.duration) || Number(this.data.durationSeconds) || 1,
)
const target = clamp(targetValue, 0, duration)
const actualBeforeSeek = Number(context.currentTime)
const previousValue = clamp(
Number.isFinite(actualBeforeSeek)
? actualBeforeSeek
: Number(this.data.sliderValue) || 0,
0,
duration,
)
const operationGeneration = this._seekOperationGeneration + 1
this._seekOperationGeneration = operationGeneration
const pending = {
operationGeneration,
contextGeneration: this._contextGeneration,
intentGeneration: this._intentGeneration,
context,
target,
previousValue,
playAfterSeek: Boolean(options.playAfterSeek),
}
this._pendingSeek = pending
this._scrubbing = false
this.setProgress(target, duration)
this.setData({ seekLocked: true, error: '' })
this._seekTimer = setTimeout(() => {
this.failSeek(
operationGeneration,
pending.contextGeneration,
pending.intentGeneration,
context,
)
}, SEEK_TIMEOUT_MS)
try {
context.seek(target)
} catch (_) {
this.failSeek(
operationGeneration,
pending.contextGeneration,
pending.intentGeneration,
context,
)
return false
}
return true
},
finishSeekFromEvent(context, contextGeneration) {
const pending = this._pendingSeek
if (!pending) return
const actual = Number(context.currentTime)
if (Number.isFinite(actual) && Math.abs(actual - pending.target) > 1.25) return
this.finishSeek(
pending.operationGeneration,
contextGeneration,
pending.intentGeneration,
context,
)
},
finishSeek(
operationGeneration,
contextGeneration,
intentGeneration,
context,
) {
const pending = this._pendingSeek
if (
!pending
|| pending.operationGeneration !== operationGeneration
|| pending.contextGeneration !== contextGeneration
|| pending.intentGeneration !== intentGeneration
|| pending.context !== context
|| !this.isCurrentContext(context, contextGeneration)
|| this._intentGeneration !== intentGeneration
) return
if (this._seekTimer !== null) {
clearTimeout(this._seekTimer)
this._seekTimer = null
}
this._pendingSeek = null
this.setData({ seekLocked: false })
if (
pending.playAfterSeek
&& this._desiredPlayback
&& this._activePlayIntentGeneration === intentGeneration
) {
this.startPlaybackWithIntent(intentGeneration)
}
},
failSeek(
operationGeneration,
contextGeneration,
intentGeneration,
context,
) {
const pending = this._pendingSeek
if (
!pending
|| pending.operationGeneration !== operationGeneration
|| pending.contextGeneration !== contextGeneration
|| pending.intentGeneration !== intentGeneration
|| pending.context !== context
|| !this.isCurrentContext(context, contextGeneration)
|| this._intentGeneration !== intentGeneration
) return false
if (this._seekTimer !== null) {
clearTimeout(this._seekTimer)
this._seekTimer = null
}
this._pendingSeek = null
this._scrubbing = false
const actual = Number(context.currentTime)
const restoredValue = Number.isFinite(actual)
? actual
: pending.previousValue
if (pending.playAfterSeek) this.invalidatePlaybackIntent(false)
this.setProgress(restoredValue, this.data.durationSeconds)
this.setData({
playing: pending.playAfterSeek ? false : this.data.playing,
loading: false,
seekLocked: false,
error: '进度没有调好,请再试一次。',
})
return true
},
previewSeek(event) {
if (!this.audioContext || this.data.loading || this._pendingSeek) return
this._scrubbing = true
this.setProgress(eventValue(event), this.data.durationSeconds)
},
commitSeek(event) {
if (!this.audioContext || this.data.loading || this._pendingSeek) return
this._scrubbing = false
this.requestSeek(eventValue(event))
},
rewindTenSeconds() {
if (!this.audioContext || this.data.loading || this._pendingSeek) return
const current = Number(this.audioContext.currentTime)
const fallback = Number(this.data.sliderValue) || 0
this.requestSeek(Math.max(0, (Number.isFinite(current) ? current : fallback) - 10))
},
replay() {
if (
!this.audioContext
|| this.data.loading
|| this._controlLocked
|| this._pendingSeek
|| this._unloaded
) return
const alreadyPlaying = this.data.playing && this._desiredPlayback
const intentGeneration = alreadyPlaying
? this._intentGeneration
: this.beginPlaybackIntent()
this.setData({ hasStarted: true, error: '' })
this.requestSeek(0, {
playAfterSeek: !alreadyPlaying,
intentGeneration,
})
},
changePlaybackRate(event) {
const requested = Number(
event && event.currentTarget && event.currentTarget.dataset.rate,
)
if (
!RATE_VALUES.includes(requested)
|| !this.audioContext
|| !this.data.rateControlsVisible
) return
const allowed = this.data.rateOptions.some((item) => item.value === requested)
if (!allowed) return
try {
this.audioContext.playbackRate = requested
if (Math.abs(Number(this.audioContext.playbackRate) - requested) > 0.001) {
this.disableRateControls(this.audioContext)
return
}
this.setData({ playbackRate: requested })
} catch (_) {
this.disableRateControls(this.audioContext)
}
},
retryLoad() {
if (this.data.loading || this._controlLocked || this._unloaded) return
if (this.audioContext) {
const intentGeneration = this.beginPlaybackIntent()
this.startPlaybackWithIntent(intentGeneration)
return
}
const intentGeneration = this.beginPlaybackIntent()
return this.prepareAudio(intentGeneration)
},
goBack() {
const pages = typeof getCurrentPages === 'function' ? getCurrentPages() : []
const returnToChapter = () => {
if (typeof wx.reLaunch === 'function') {
wx.reLaunch({
url: chapterRoute(this.data.chapterNumber),
})
}
}
if (pages.length > 1 && typeof wx.navigateBack === 'function') {
wx.navigateBack({ delta: 1, fail: returnToChapter })
return
}
returnToChapter()
},
destroyAudioContext() {
const context = this.audioContext
if (!context) return
this.invalidatePlaybackIntent(false)
this._contextGeneration += 1
this.audioContext = null
context.destroy()
if (!this._unloaded) {
this.setData({
audioReady: false,
playing: false,
loading: false,
rateOptions: FALLBACK_RATE_OPTIONS,
rateControlsVisible: false,
playbackRate: 1,
})
}
},
onUnload() {
this._unloaded = true
this._lifecycleGeneration += 1
this._prepareGeneration += 1
this._preparePlayIntentGeneration = 0
this._intentGeneration += 1
this._activePlayIntentGeneration = 0
this._desiredPlayback = false
this._controlLocked = true
this.clearPauseLock(false)
this.clearSeekState(false)
this.unbindAudioInterruptionHandlers()
const context = this.audioContext
this._contextGeneration += 1
this.audioContext = null
if (context) context.destroy()
},
})
@@ -0,0 +1,3 @@
{
"disableScroll": true
}
@@ -0,0 +1,73 @@
<view class="audio-page">
<view class="audio-topbar">
<button class="back-top" bindtap="goBack" aria-label="返回刚才的连环画"> 返回连环画</button>
<view class="page-heading">
<text class="page-kicker">{{chapterLabel}} · 有声夹页</text>
<text class="page-count">{{pageNumber}}8</text>
</view>
</view>
<view class="audio-spread">
<view class="story-card" aria-label="本页文字封面">
<view class="story-card-border">
<text class="story-card-kicker">桂香里的有声夹页</text>
<text class="story-card-title">桂香里的这一页</text>
<view class="story-card-rule"></view>
<text class="story-card-chapter">{{chapterLabel}}</text>
<text class="story-card-page">{{pageLabel}}</text>
<text class="story-card-note">声音单独下载,画面仍留在原来的连环画里。</text>
<text class="story-card-stamp">桂香故事</text>
</view>
</view>
<view class="audio-copy">
<text class="audio-title">听这一页</text>
<text class="audio-caption">点“开始听”后才下载并播放;来电话或离开页面会暂停,不会自己续播。</text>
<view class="audio-seek-row">
<slider
class="audio-slider"
aria-label="播放进度,可左右拖动"
min="0"
max="{{durationSeconds}}"
step="1"
value="{{sliderValue}}"
activeColor="#9b3328"
backgroundColor="#d6c6a1"
block-color="#8f271f"
block-size="24"
disabled="{{loading || !audioReady || seekLocked}}"
bindchanging="previewSeek"
bindchange="commitSeek"
></slider>
<text class="audio-time">{{currentLabel}} / {{durationLabel}}</text>
</view>
<view class="audio-controls">
<button class="audio-button" disabled="{{loading || !audioReady || seekLocked}}" bindtap="rewindTenSeconds">后退10秒</button>
<button class="audio-button primary" disabled="{{loading || seekLocked || error}}" bindtap="togglePlayback">
{{loading ? '正在准备' : playing ? '暂停' : hasStarted ? '继续听' : '开始听'}}
</button>
<button class="audio-button" disabled="{{loading || !audioReady || seekLocked}}" bindtap="replay">从头重听</button>
</view>
<view wx:if="{{rateControlsVisible}}" class="audio-rate-row" aria-label="播放速度">
<text class="audio-rate-label">语速</text>
<button
wx:for="{{rateOptions}}"
wx:key="value"
class="audio-rate-button {{playbackRate == item.value ? 'active' : ''}}"
data-rate="{{item.value}}"
disabled="{{loading || seekLocked}}"
bindtap="changePlaybackRate"
>{{item.label}}</button>
</view>
<view wx:if="{{error}}" class="audio-error" role="alert">
<text>{{error}}</text>
<button wx:if="{{retryAvailable}}" class="retry-button" disabled="{{loading}}" bindtap="retryLoad">再试一次</button>
</view>
<button class="back-bottom" bindtap="goBack">回到连环画</button>
</view>
</view>
</view>
@@ -0,0 +1,192 @@
page {
width: 100%;
min-height: 100%;
background: #1c130f;
color: #2f241d;
}
button::after { border: 0; }
.audio-page {
box-sizing: border-box;
width: 100vw;
height: 100vh;
min-height: 320px;
padding: max(8px, env(safe-area-inset-top)) max(12px, env(safe-area-inset-right)) max(8px, env(safe-area-inset-bottom)) max(12px, env(safe-area-inset-left));
display: flex;
flex-direction: column;
gap: 8px;
background: radial-gradient(circle at 48% 45%, #443125 0, #211611 70%);
}
.audio-topbar {
height: 52px;
flex: 0 0 52px;
display: grid;
grid-template-columns: 160px minmax(0, 1fr);
gap: 10px;
align-items: center;
padding-right: max(92px, env(safe-area-inset-right));
color: #f5e6b7;
}
.back-top {
box-sizing: border-box;
width: 100%;
max-width: 100%;
min-height: 48px;
border: 1px solid #9b7a51;
border-radius: 10px;
background: #38251b;
color: #f7e8bd;
font-size: 20px;
line-height: 48px;
text-align: center;
padding: 0 12px;
}
.page-heading { display: flex; align-items: baseline; gap: 12px; }
.page-kicker { font-size: 20px; font-weight: 700; }
.page-count { color: #d9bb78; font-size: 18px; }
.audio-spread {
min-height: 0;
flex: 1;
display: grid;
grid-template-columns: minmax(0, 3fr) minmax(300px, 2fr);
border: 2px solid #b99762;
background: #efe2bd;
box-shadow: 0 10px 34px rgba(0, 0, 0, .4);
}
.story-card {
box-sizing: border-box;
min-width: 0;
min-height: 0;
padding: 18px;
background: repeating-linear-gradient(0deg, rgba(245, 225, 177, .035) 0, rgba(245, 225, 177, .035) 1px, transparent 1px, transparent 5px), #251813;
border-right: 4px solid #8d271f;
}
.story-card-border {
box-sizing: border-box;
width: 100%;
height: 100%;
min-height: 0;
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border: 2px solid #b99762;
outline: 1px solid rgba(185, 151, 98, .55);
outline-offset: -8px;
color: #f4e3b8;
text-align: center;
padding: 28px;
}
.story-card-kicker { color: #d1b274; font-size: 17px; letter-spacing: 3px; }
.story-card-title { margin-top: 10px; font-size: 32px; font-weight: 800; letter-spacing: 5px; }
.story-card-rule { width: 58%; height: 2px; margin: 16px 0; background: #8f3027; }
.story-card-chapter { color: #f8eac8; font-size: 28px; font-weight: 800; }
.story-card-page { margin-top: 6px; color: #d8bd83; font-size: 22px; }
.story-card-note { max-width: 430px; margin-top: 20px; color: #d8c8a5; font-size: 18px; line-height: 1.55; }
.story-card-stamp { position: absolute; right: 22px; bottom: 18px; padding: 6px 10px; border: 2px solid #9d3a2e; color: #d98673; font-size: 16px; letter-spacing: 2px; }
.audio-copy {
min-width: 0;
min-height: 0;
padding: 18px 20px 14px;
display: flex;
flex-direction: column;
background: repeating-linear-gradient(0deg, rgba(119, 87, 46, .04) 0, rgba(119, 87, 46, .04) 1px, transparent 1px, transparent 4px), #f6edcf;
}
.audio-title { color: #8f271f; font-size: 28px; font-weight: 800; line-height: 1.25; }
.audio-caption { margin-top: 10px; font-size: 20px; font-weight: 600; line-height: 1.48; }
.audio-seek-row {
min-width: 0;
min-height: 48px;
margin-top: auto;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 6px;
align-items: center;
}
.audio-slider { min-width: 0; width: 100%; margin: 0; }
.audio-time { min-width: 82px; color: #6d5b48; font-size: 17px; text-align: right; }
.audio-controls {
min-width: 0;
margin-top: 6px;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.audio-button,
.retry-button,
.back-bottom {
box-sizing: border-box;
min-height: 48px;
border: 2px solid #7e6648;
border-radius: 10px;
background: #eadbb5;
color: #37291f;
font-size: 18px;
font-weight: 700;
line-height: 46px;
min-width: 0;
margin: 0;
padding: 0 6px;
}
.audio-button { width: 100%; max-width: 100%; }
.audio-button.primary { border-color: #8f271f; background: #9e3027; color: #fff2cf; }
.audio-button[disabled], .retry-button[disabled] { opacity: .72; }
.audio-rate-row {
min-width: 0;
min-height: 48px;
margin-top: 6px;
display: grid;
grid-template-columns: auto repeat(3, minmax(48px, 1fr));
gap: 6px;
align-items: center;
}
.audio-rate-label { color: #6d5b48; font-size: 18px; font-weight: 700; }
.audio-rate-button {
box-sizing: border-box;
min-width: 48px;
min-height: 48px;
margin: 0;
padding: 0 4px;
border: 2px solid #9d896a;
border-radius: 9px;
background: #f3e7c8;
color: #4d3b2d;
font-size: 17px;
font-weight: 700;
line-height: 46px;
}
.audio-rate-button { width: 100%; max-width: 100%; }
.audio-rate-button.active { border-color: #8f271f; background: #8f271f; color: #fff2cf; }
.audio-rate-button[disabled] { opacity: .72; }
.audio-error { margin-top: 8px; color: #8f271f; font-size: 17px; line-height: 1.35; }
.retry-button { width: 100%; min-height: 48px; margin-top: 8px; line-height: 46px; font-size: 18px; }
.back-bottom { margin-top: 8px; width: 100%; }
@media (max-height: 430px) {
.audio-page { gap: 4px; padding-top: 4px; padding-bottom: 4px; }
.audio-topbar { height: 48px; flex-basis: 48px; }
.story-card { padding: 10px; }
.story-card-border { padding: 16px; }
.story-card-title { font-size: 26px; }
.story-card-note { margin-top: 10px; font-size: 16px; }
.audio-copy { padding: 8px 12px; overflow-y: auto; }
.audio-title { font-size: 23px; }
.audio-caption { margin-top: 4px; font-size: 17px; line-height: 1.3; }
.audio-seek-row { min-height: 48px; }
.audio-controls, .audio-rate-row { margin-top: 3px; gap: 5px; }
.audio-button, .retry-button, .back-bottom { min-height: 48px; line-height: 46px; font-size: 17px; }
.audio-rate-button { min-height: 48px; line-height: 46px; font-size: 16px; }
.audio-rate-label { font-size: 16px; }
.back-bottom { display: none; }
}
@@ -0,0 +1,674 @@
const CACHE_INDEX_VERSION = 2
const CACHE_FOLDER = 'tang-detective-assets-v2'
const CACHE_INDEX_FILE = 'index.json'
const DEFAULT_IMAGE_BUDGET = 28 * 1024 * 1024
const DEFAULT_AUDIO_BUDGET = 32 * 1024 * 1024
const DEFAULT_AUDIO_MAX_ENTRIES = 32
const SHA256_ROUND_CONSTANTS = [
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5,
0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3,
0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc,
0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3,
0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5,
0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
]
function utf8Bytes(value) {
const encoded = encodeURIComponent(String(value || ''))
const bytes = []
for (let index = 0; index < encoded.length; index += 1) {
if (encoded[index] === '%') {
bytes.push(parseInt(encoded.slice(index + 1, index + 3), 16))
index += 2
} else {
bytes.push(encoded.charCodeAt(index))
}
}
return new Uint8Array(bytes)
}
function toBytes(value) {
if (typeof value === 'string') return utf8Bytes(value)
if (value instanceof ArrayBuffer) return new Uint8Array(value)
if (ArrayBuffer.isView(value)) {
return new Uint8Array(value.buffer, value.byteOffset, value.byteLength)
}
throw new Error('unsupported SHA-256 input')
}
function rotateRight(value, bits) {
return (value >>> bits) | (value << (32 - bits))
}
function sha256Hex(value) {
const bytes = toBytes(value)
const paddedLength = Math.ceil((bytes.length + 9) / 64) * 64
const message = new Uint8Array(paddedLength)
message.set(bytes)
message[bytes.length] = 0x80
const bitLength = bytes.length * 8
const highBits = Math.floor(bitLength / 0x100000000)
const lowBits = bitLength >>> 0
const lengthOffset = paddedLength - 8
message[lengthOffset] = (highBits >>> 24) & 0xff
message[lengthOffset + 1] = (highBits >>> 16) & 0xff
message[lengthOffset + 2] = (highBits >>> 8) & 0xff
message[lengthOffset + 3] = highBits & 0xff
message[lengthOffset + 4] = (lowBits >>> 24) & 0xff
message[lengthOffset + 5] = (lowBits >>> 16) & 0xff
message[lengthOffset + 6] = (lowBits >>> 8) & 0xff
message[lengthOffset + 7] = lowBits & 0xff
const hash = [
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a,
0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
]
const words = new Uint32Array(64)
for (let offset = 0; offset < paddedLength; offset += 64) {
for (let index = 0; index < 16; index += 1) {
const start = offset + index * 4
words[index] = (
(message[start] << 24)
| (message[start + 1] << 16)
| (message[start + 2] << 8)
| message[start + 3]
) >>> 0
}
for (let index = 16; index < 64; index += 1) {
const word15 = words[index - 15]
const word2 = words[index - 2]
const sigma0 = (
rotateRight(word15, 7)
^ rotateRight(word15, 18)
^ (word15 >>> 3)
)
const sigma1 = (
rotateRight(word2, 17)
^ rotateRight(word2, 19)
^ (word2 >>> 10)
)
words[index] = (
words[index - 16]
+ sigma0
+ words[index - 7]
+ sigma1
) >>> 0
}
let a = hash[0]
let b = hash[1]
let c = hash[2]
let d = hash[3]
let e = hash[4]
let f = hash[5]
let g = hash[6]
let h = hash[7]
for (let index = 0; index < 64; index += 1) {
const sum1 = rotateRight(e, 6) ^ rotateRight(e, 11) ^ rotateRight(e, 25)
const choice = (e & f) ^ (~e & g)
const temp1 = (
h + sum1 + choice + SHA256_ROUND_CONSTANTS[index] + words[index]
) >>> 0
const sum0 = rotateRight(a, 2) ^ rotateRight(a, 13) ^ rotateRight(a, 22)
const majority = (a & b) ^ (a & c) ^ (b & c)
const temp2 = (sum0 + majority) >>> 0
h = g
g = f
f = e
e = (d + temp1) >>> 0
d = c
c = b
b = a
a = (temp1 + temp2) >>> 0
}
hash[0] = (hash[0] + a) >>> 0
hash[1] = (hash[1] + b) >>> 0
hash[2] = (hash[2] + c) >>> 0
hash[3] = (hash[3] + d) >>> 0
hash[4] = (hash[4] + e) >>> 0
hash[5] = (hash[5] + f) >>> 0
hash[6] = (hash[6] + g) >>> 0
hash[7] = (hash[7] + h) >>> 0
}
return hash.map((word) => word.toString(16).padStart(8, '0')).join('')
}
function constantTimeEqualHex(left, right) {
const leftValue = String(left || '').toLowerCase()
const rightValue = String(right || '').toLowerCase()
const length = Math.max(leftValue.length, rightValue.length)
let difference = leftValue.length ^ rightValue.length
for (let index = 0; index < length; index += 1) {
const leftCode = index < leftValue.length ? leftValue.charCodeAt(index) : 0
const rightCode = index < rightValue.length ? rightValue.charCodeAt(index) : 0
difference |= leftCode ^ rightCode
}
return difference === 0
}
function trimSlash(value) {
return String(value || '').replace(/\/+$/, '')
}
function joinPath(left, right) {
return `${trimSlash(left)}/${String(right || '').replace(/^\/+/, '')}`
}
function normalizeIndex(value) {
if (
!value
|| value.version !== CACHE_INDEX_VERSION
|| !value.entries
|| typeof value.entries !== 'object'
) {
return {
version: CACHE_INDEX_VERSION,
entries: {},
}
}
return value
}
function callFs(fs, method, options) {
return new Promise((resolve, reject) => {
if (!fs || typeof fs[method] !== 'function') {
reject(new Error(`fs.${method} unavailable`))
return
}
fs[method]({
...options,
success: resolve,
fail: reject,
})
})
}
function createAssetPlatformFacade() {
if (typeof wx === 'undefined') return null
const env = Object.freeze({
USER_DATA_PATH: String(
wx.env && wx.env.USER_DATA_PATH || '',
),
})
return Object.freeze({
downloadFile(options) {
if (typeof wx.downloadFile !== 'function') {
if (options && typeof options.fail === 'function') {
options.fail(new Error('wx.downloadFile unavailable'))
}
return undefined
}
return wx.downloadFile(options)
},
getFileSystemManager() {
if (typeof wx.getFileSystemManager !== 'function') return null
return wx.getFileSystemManager()
},
env,
})
}
function download(assetPlatform, url) {
return new Promise((resolve, reject) => {
if (!assetPlatform || typeof assetPlatform.downloadFile !== 'function') {
reject(new Error('wx.downloadFile unavailable'))
return
}
assetPlatform.downloadFile({
url,
success(result) {
if (
result
&& result.statusCode >= 200
&& result.statusCode < 300
&& result.tempFilePath
) {
resolve(result)
return
}
reject(new Error(`download status ${result && result.statusCode}`))
},
fail: reject,
})
})
}
function safeCacheName(assetId, asset) {
const extensionMatch = String(asset.remotePath || '').match(/(\.[a-z0-9]+)$/i)
const extension = extensionMatch ? extensionMatch[1].toLowerCase() : '.bin'
const safeId = assetId.replace(/[^a-z0-9._-]/gi, '_')
return `${safeId}.${asset.sha256.slice(0, 12)}${extension}`
}
function createAssetManager(options = {}) {
const assetPlatform = options.assetPlatform || createAssetPlatformFacade()
const manifest = options.manifest || {}
const cdnBaseUrl = trimSlash(options.cdnBaseUrl)
const imageBudgetBytes = Number.isFinite(options.imageCacheBudgetBytes)
? Math.max(0, options.imageCacheBudgetBytes)
: DEFAULT_IMAGE_BUDGET
const audioBudgetBytes = Number.isFinite(options.audioCacheBudgetBytes)
? Math.max(0, options.audioCacheBudgetBytes)
: DEFAULT_AUDIO_BUDGET
const audioMaxEntries = Number.isFinite(options.audioCacheMaxEntries)
? Math.max(0, Math.floor(options.audioCacheMaxEntries))
: DEFAULT_AUDIO_MAX_ENTRIES
const downloadConcurrency = Number.isFinite(options.downloadConcurrency)
? Math.max(1, Math.floor(options.downloadConcurrency))
: 2
const now = typeof options.now === 'function' ? options.now : Date.now
const fs = assetPlatform
&& typeof assetPlatform.getFileSystemManager === 'function'
? assetPlatform.getFileSystemManager()
: null
const userDataPath = (
assetPlatform
&& assetPlatform.env
&& assetPlatform.env.USER_DATA_PATH
) || ''
const cacheRoot = userDataPath ? joinPath(userDataPath, CACHE_FOLDER) : ''
const indexPath = cacheRoot ? joinPath(cacheRoot, CACHE_INDEX_FILE) : ''
let initialized = false
let index = normalizeIndex(null)
let activeDownloads = 0
const inflight = new Map()
const downloadWaiters = []
function fallback(assetId, reason) {
return {
assetId,
available: false,
uri: '',
source: 'text-fallback',
reason,
}
}
async function ensureCacheFolder() {
if (!cacheRoot) return false
try {
await callFs(fs, 'mkdir', {
dirPath: cacheRoot,
recursive: true,
})
} catch (error) {
// EEXIST and older base-library mkdir failures are both safe to ignore.
}
return true
}
async function persistIndex() {
if (!indexPath) return
try {
await ensureCacheFolder()
await callFs(fs, 'writeFile', {
filePath: indexPath,
data: JSON.stringify(index),
encoding: 'utf8',
})
} catch (error) {
// Cache metadata must never block the text-first game.
}
}
async function fileExists(filePath) {
if (!filePath) return false
try {
await callFs(fs, 'access', { path: filePath })
return true
} catch (error) {
return false
}
}
async function fileMatchesSha256(filePath, expectedSha256) {
try {
const result = await callFs(fs, 'readFile', { filePath })
return constantTimeEqualHex(
sha256Hex(result.data),
expectedSha256,
)
} catch (error) {
return false
}
}
async function init() {
if (initialized) return
initialized = true
if (!indexPath) return
try {
const result = await callFs(fs, 'readFile', {
filePath: indexPath,
encoding: 'utf8',
})
index = normalizeIndex(JSON.parse(result.data))
} catch (error) {
index = normalizeIndex(null)
}
let changed = false
for (const [assetId, entry] of Object.entries(index.entries)) {
if (!manifest[assetId] || !(await fileExists(entry.filePath))) {
delete index.entries[assetId]
changed = true
}
}
if (changed) await persistIndex()
await enforceImageBudget()
await enforceAudioBudget()
}
function cacheBytes(kind) {
return Object.values(index.entries).reduce(
(total, entry) => (
entry.kind === kind
? total + Math.max(0, Number(entry.size) || 0)
: total
),
0,
)
}
function cacheEntries(kind) {
return Object.values(index.entries)
.filter((entry) => entry.kind === kind)
.length
}
function imageCacheBytes() {
return cacheBytes('image')
}
function audioCacheBytes() {
return cacheBytes('audio')
}
async function unlinkQuietly(filePath) {
try {
await callFs(fs, 'unlink', { filePath })
} catch (error) {
// A missing cache file is already evicted.
}
}
async function enforceImageBudget(protectedAssetId = '') {
let total = imageCacheBytes()
if (total <= imageBudgetBytes) return
const candidates = Object.entries(index.entries)
.filter(([assetId, entry]) => (
entry.kind === 'image' && assetId !== protectedAssetId
))
.sort((left, right) => (
(Number(left[1].lastAccessedAt) || 0)
- (Number(right[1].lastAccessedAt) || 0)
))
for (const [assetId, entry] of candidates) {
if (total <= imageBudgetBytes) break
await unlinkQuietly(entry.filePath)
total -= Math.max(0, Number(entry.size) || 0)
delete index.entries[assetId]
}
await persistIndex()
}
async function enforceAudioBudget(protectedAssetId = '') {
let total = audioCacheBytes()
let entries = cacheEntries('audio')
if (total <= audioBudgetBytes && entries <= audioMaxEntries) return
const candidates = Object.entries(index.entries)
.filter(([assetId, entry]) => (
entry.kind === 'audio' && assetId !== protectedAssetId
))
.sort((left, right) => (
(Number(left[1].lastAccessedAt) || 0)
- (Number(right[1].lastAccessedAt) || 0)
))
for (const [assetId, entry] of candidates) {
if (total <= audioBudgetBytes && entries <= audioMaxEntries) break
await unlinkQuietly(entry.filePath)
total -= Math.max(0, Number(entry.size) || 0)
entries -= 1
delete index.entries[assetId]
}
await persistIndex()
}
async function resolveCached(assetId, asset) {
const entry = index.entries[assetId]
if (!entry || entry.sha256 !== asset.sha256) return null
if (!(await fileExists(entry.filePath))) {
delete index.entries[assetId]
await persistIndex()
return null
}
if (!(await fileMatchesSha256(entry.filePath, asset.sha256))) {
await unlinkQuietly(entry.filePath)
delete index.entries[assetId]
await persistIndex()
return fallback(assetId, 'remote-integrity-failed')
}
entry.lastAccessedAt = now()
await persistIndex()
return {
assetId,
available: true,
uri: entry.filePath,
source: 'cache',
kind: asset.kind,
}
}
async function getTempFileSize(tempFilePath, response) {
if (Number.isFinite(response.fileSize)) return response.fileSize
try {
const statResult = await callFs(fs, 'stat', {
path: tempFilePath,
})
const stat = statResult && statResult.stats
return Math.max(0, Number(stat && stat.size) || 0)
} catch (error) {
return 0
}
}
async function fetchRemote(assetId, asset) {
if (!cdnBaseUrl || !asset.remotePath) {
return fallback(assetId, 'remote-disabled')
}
const response = await new Promise((resolve, reject) => {
const run = () => {
activeDownloads += 1
download(assetPlatform, joinPath(cdnBaseUrl, asset.remotePath))
.then(resolve, reject)
.finally(() => {
activeDownloads -= 1
const next = downloadWaiters.shift()
if (next) next()
})
}
if (activeDownloads < downloadConcurrency) run()
else downloadWaiters.push(run)
})
if (!(await fileMatchesSha256(response.tempFilePath, asset.sha256))) {
await unlinkQuietly(response.tempFilePath)
return fallback(assetId, 'remote-integrity-failed')
}
const size = await getTempFileSize(response.tempFilePath, response)
const exceedsCachePolicy = (
(asset.kind === 'image' && size > imageBudgetBytes)
|| (
asset.kind === 'audio'
&& (size > audioBudgetBytes || audioMaxEntries < 1)
)
)
if (!cacheRoot || exceedsCachePolicy) {
return {
assetId,
available: true,
uri: response.tempFilePath,
source: 'remote',
kind: asset.kind,
persistent: false,
}
}
try {
await ensureCacheFolder()
const filePath = joinPath(cacheRoot, safeCacheName(assetId, asset))
await callFs(fs, 'saveFile', {
tempFilePath: response.tempFilePath,
filePath,
})
index.entries[assetId] = {
assetId,
filePath,
kind: asset.kind,
sha256: asset.sha256,
size,
lastAccessedAt: now(),
}
await enforceImageBudget(assetId)
await enforceAudioBudget(assetId)
await persistIndex()
return {
assetId,
available: true,
uri: filePath,
source: 'remote',
kind: asset.kind,
persistent: true,
}
} catch (error) {
return {
assetId,
available: true,
uri: response.tempFilePath,
source: 'remote',
kind: asset.kind,
persistent: false,
}
}
}
async function resolve(assetId, resolveOptions = {}) {
await init()
const asset = manifest[assetId]
if (!asset) return fallback(assetId, 'unknown-asset')
if (
asset.kind === 'audio'
&& String(asset.reviewStatus || '').toLowerCase() !== 'approved'
) {
return fallback(assetId, 'audio-unapproved')
}
// 包内种子永远优先:首屏无需等网络,离线也能继续读和玩。
if (asset.localSeed && resolveOptions.preferRemote !== true) {
return {
assetId,
available: true,
uri: asset.localSeed,
source: 'local',
kind: asset.kind,
}
}
const cached = await resolveCached(assetId, asset)
if (cached) return cached
if (resolveOptions.allowRemote === false) {
if (asset.localSeed) {
return {
assetId,
available: true,
uri: asset.localSeed,
source: 'local',
kind: asset.kind,
}
}
return fallback(assetId, 'remote-disallowed')
}
if (inflight.has(assetId)) return inflight.get(assetId)
const request = fetchRemote(assetId, asset)
.catch(() => (
asset.localSeed
? {
assetId,
available: true,
uri: asset.localSeed,
source: 'local',
kind: asset.kind,
}
: fallback(assetId, 'remote-failed')
))
.finally(() => inflight.delete(assetId))
inflight.set(assetId, request)
return request
}
async function prefetch(assetIds) {
const ids = Array.isArray(assetIds) ? assetIds : []
return Promise.all(ids.map((assetId) => resolve(assetId)))
}
async function clearCache() {
await init()
const entries = Object.values(index.entries)
for (const entry of entries) await unlinkQuietly(entry.filePath)
index = normalizeIndex(null)
await persistIndex()
}
function getCacheStats() {
return {
entries: Object.keys(index.entries).length,
imageBytes: imageCacheBytes(),
imageBudgetBytes,
audioEntries: cacheEntries('audio'),
audioBytes: audioCacheBytes(),
audioBudgetBytes,
audioMaxEntries,
}
}
return {
init,
resolve,
prefetch,
clearCache,
getCacheStats,
getAsset(assetId) {
return manifest[assetId] || null
},
}
}
module.exports = {
CACHE_INDEX_VERSION,
DEFAULT_AUDIO_BUDGET,
DEFAULT_AUDIO_MAX_ENTRIES,
constantTimeEqualHex,
createAssetPlatformFacade,
createAssetManager,
sha256Hex,
}
@@ -0,0 +1,25 @@
const SHARED_GAME_CHAPTERS = new Set([1])
function normalizeChapterNumber(value) {
const number = Number(value)
if (!Number.isFinite(number)) return 1
return Math.min(15, Math.max(1, Math.floor(number)))
}
function chapterPackageRoot(value) {
const chapter = normalizeChapterNumber(value)
if (SHARED_GAME_CHAPTERS.has(chapter)) return 'package-game'
return `package-chapter-${String(chapter).padStart(2, '0')}`
}
function chapterRoute(value, options = {}) {
const chapter = normalizeChapterNumber(value)
const replay = options && options.replay === true ? '&replay=1' : ''
return `/${chapterPackageRoot(chapter)}/pages/chapter/chapter?chapter=${chapter}${replay}`
}
module.exports = {
normalizeChapterNumber,
chapterPackageRoot,
chapterRoute,
}

Some files were not shown because too many files have changed in this diff Show More