gengxin
This commit is contained in:
@@ -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()
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user