Files
TJWaterFrontend_Refine/public/three-dimensional/zjb/v29/resourceCache.mjs
T
jiang 4a522bf42a
Generic Container CI/CD / test-build-publish (push) Successful in 1m11s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 1m11s
feat(frontend): cache 3D assets and restore loading feedback
Persist versioned models and validated network metadata in IndexedDB. Show scene download progress and recoverable errors with the existing frosted material.

Connect existing skeletons to pending navigation and route boundaries while preserving the map. Previously only dynamic imports showed fallbacks, and rejected model promises prevented retries.

Verified: 32 focused unit tests; 4 navigation and scene browser checks; 7 cache browser checks including invalid-metadata recovery; isolated production build and type checks.
2026-10-16 18:13:04 +08:00

115 lines
4.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Only static scene assets enter this database. Live simulation/SCADA data do not.
const DATABASE = 'tjwater-scene-assets-v1';
const STORE = 'resources';
const REQUEST_TIMEOUT_MS = 30_000;
function openDatabase() {
return new Promise(resolve => {
let settled = false;
const finish = value => { if (!settled) { settled = true; resolve(value); } };
try {
const request = globalThis.indexedDB.open(DATABASE, 1);
request.onupgradeneeded = () => request.result.createObjectStore(STORE, {keyPath:'url'});
request.onerror = request.onblocked = () => finish(null);
request.onsuccess = () => {
const database = request.result;
if (settled) { database.close(); return; }
database.onversionchange = () => database.close();
finish(database);
};
} catch { finish(null); }
});
}
function transact(database, mode, operation) {
return new Promise((resolve, reject) => {
const transaction = database.transaction(STORE, mode);
const request = operation(transaction.objectStore(STORE));
transaction.oncomplete = () => resolve(request.result);
transaction.onerror = transaction.onabort = () => reject(transaction.error ?? request.error);
});
}
export function createSceneResourceCache(baseUrl, onProgress = () => {}) {
// Open lazily, after synchronous WebGL setup, so database callbacks can run promptly.
let database;
const pending = new Map();
async function read(file, revision, decode) {
const url = new URL(file, baseUrl);
const key = url.href;
const report = (stage, loaded = 0, total = 0) => onProgress({file, stage, loaded, total});
const storage = await (database ??= openDatabase());
if (storage && revision) {
try {
const entry = await transact(storage, 'readonly', store => store.get(key));
if (entry?.revision === revision && entry.data instanceof ArrayBuffer) {
report('cache', entry.data.byteLength, entry.data.byteLength);
const result = await decode(entry.data);
report('ready', entry.data.byteLength, entry.data.byteLength);
return result;
}
} catch { /* Unavailable or invalid cache: fetch a fresh copy below. */ }
}
// Revisioned URLs also keep the browser's HTTP cache in sync with the manifest.
if (revision) url.searchParams.set('v', revision);
const controller = new AbortController();
let timer;
const resetTimeout = () => {
clearTimeout(timer);
timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
};
report('download');
resetTimeout();
let data;
try {
const response = await fetch(url, {signal:controller.signal, cache:'no-cache'});
if (!response.ok) throw Error('HTTP ' + response.status);
const total = Number(response.headers.get('content-length')) || 0;
if (response.body) {
const reader = response.body.getReader();
const chunks = [];
let loaded = 0;
while (true) {
const {done, value} = await reader.read();
if (done) break;
chunks.push(value); loaded += value.byteLength;
report('download', loaded, total); resetTimeout();
}
const bytes = new Uint8Array(loaded);
let offset = 0;
for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; }
data = bytes.buffer;
} else {
data = await response.arrayBuffer();
}
} catch (error) {
throw Error(file + ':' + (controller.signal.aborted ? '下载超时,请重试' : error.message));
} finally { clearTimeout(timer); }
report('parse', data.byteLength, data.byteLength);
// Validate/parse before persisting so an HTML error page or bad GLB cannot poison the cache.
const result = await decode(data);
if (storage && revision) {
try {
// One entry per URL: a successful new revision replaces the old bytes.
await transact(storage, 'readwrite', store => store.put({url:key, revision, data}));
} catch { /* Storage denied/full must not prevent scene rendering. */ }
}
report('ready', data.byteLength, data.byteLength);
return result;
}
return {
load(file, revision, decode = data => data) {
const key = file + ':' + revision;
if (!pending.has(key)) {
const task = read(file, revision, decode).finally(() => pending.delete(key));
pending.set(key, task);
}
return pending.get(key);
},
};
}