diff --git a/docs/侧脸皱纹浮层异步加载-2026-10-16.md b/docs/侧脸皱纹浮层异步加载-2026-10-16.md
index bba4ecc..5ed627b 100644
--- a/docs/侧脸皱纹浮层异步加载-2026-10-16.md
+++ b/docs/侧脸皱纹浮层异步加载-2026-10-16.md
@@ -2,7 +2,7 @@
主测肤请求只等待主接口、评分及报告保存,不再同步等待左右侧脸的皱纹浮层。正面浮层随主接口返回。前端在报告切屏并经过两帧绘制后,才调用 `POST api/wrinkle-views`,获取该报告所属任务的左右侧脸标注。
-加载期间保留两张侧脸原图并显示「侧脸皱纹标注加载中…」。完成后只更新侧脸图片,不重算分数、不改结论或护理建议。失败/超时仅退回原图提示,报告继续使用。扫描等待页 30 秒兜底只针对主报告,不受浮层任务影响;可选浮层请求前端预算 65 秒,服务端两次顺序请求仍各最多 20 秒、间隔 4 秒。
+加载期间保留两张侧脸原图,各在图片上覆盖半透明「皱纹标注 / 加载中…」提示,不在图片下方重复显示加载文字。完成或失败后自动移除提示浮层,图片尺寸、裁切和点击查看大图规则不变。完成后只更新侧脸图片,不重算分数、不改结论或护理建议。失败/超时仅退回原图提示,报告继续使用。扫描等待页 30 秒兜底只针对主报告,不受浮层任务影响;可选浮层请求前端预算 65 秒,服务端两次顺序请求仍各最多 20 秒、间隔 4 秒。
使用随机任务令牌并绑定服务器会话所属账号;不同账号无法获取他人的标注。重复/并发请求共用同一任务,成功或失败结果都缓存,不自动重新收费调用。微信任务元数据及完成结果持久化,补写原报告 payload、后台标注状态和原始结果,不修改 vm/presentation。服务重启后可继续尚未启动的任务;已标记运行但被重启打断的任务不自动重跑,避免重复调用。老报告无任务令牌时不自动补检。
diff --git a/h5/css/ui-20261012.css b/h5/css/ui-20261012.css
index d474923..c3e5464 100644
--- a/h5/css/ui-20261012.css
+++ b/h5/css/ui-20261012.css
@@ -215,6 +215,8 @@
.contour-side-photo img, .wrinkle-side-photo img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; object-position: center; transform: translateY(-25%) scale(2.8); transform-origin: center; -webkit-user-drag: none; }
.contour-side-photo:focus-visible, .wrinkle-side-photo:focus-visible { outline: 2px solid #333; outline-offset: 3px; }
.wrinkle-layer-note { width:calc(215 * var(--u)); margin:0 auto calc(12 * var(--u)); color:#888; font-size:10px; line-height:1.4; }
+.wrinkle-loading { position:absolute; inset:0; z-index:1; display:flex; align-items:center; justify-content:center; background:rgba(0,0,0,.4); color:#fff; text-align:center; font-size:calc(10 * var(--u)); line-height:1.6; pointer-events:none; }
+.wrinkle-loading[hidden] { display:none; }
@media (prefers-reduced-motion: reduce) {
.detail-panels, .detail-viewport { transition-duration: .01ms; }
}
diff --git a/h5/index.html b/h5/index.html
index 82d82cf..e3ee91f 100644
--- a/h5/index.html
+++ b/h5/index.html
@@ -221,8 +221,8 @@
本次未获取皱纹检测图
-
-
+
+
部分侧脸未获取皱纹标注,未标注的侧脸仅展示原图。
diff --git a/h5/js/app.js b/h5/js/app.js
index 9f54da9..ed4861b 100644
--- a/h5/js/app.js
+++ b/h5/js/app.js
@@ -1635,17 +1635,21 @@
function renderWrinkleSides(reportPhotos, payload) {
var epoch = ++wrinklePhotoEpoch, wrap = $('#wrinkle-sides'), note = $('#wrinkle-layer-note');
function refresh() {
- note.textContent=payload && payload.wrinkle_job && payload.wrinkle_job.status==='pending' ?
- '侧脸皱纹标注加载中…' : '部分侧脸未获取皱纹标注,未标注的侧脸仅展示原图。';
+ var pending=!!(payload && payload.wrinkle_job && payload.wrinkle_job.status==='pending');
+ note.textContent='部分侧脸未获取皱纹标注,未标注的侧脸仅展示原图。';
+ wrap.querySelectorAll('figure').forEach(function(figure){
+ figure.querySelector('.wrinkle-loading').hidden=!pending || figure.hidden || figure.querySelector('img').dataset.hasAnnotation==='true';
+ });
var visible = Array.from(wrap.querySelectorAll('figure:not([hidden])'));
wrap.hidden = !visible.length;
- note.hidden = !visible.some(function(f) { return f.querySelector('img').dataset.hasAnnotation !== 'true'; });
+ note.hidden = pending || !visible.some(function(f) { return f.querySelector('img').dataset.hasAnnotation !== 'true'; });
var card = wrap.closest('.card-detail'), state = detailSwipes.get(card);
if (state) state.height(Number(card.querySelector('.dtab.active').dataset.tab));
}
wrap.hidden = note.hidden = true;
['left', 'right'].forEach(function(view, i) {
var img = $('#wrinkle-' + view), figure = img.closest('figure');
+ figure.querySelector('.wrinkle-loading').hidden=true;
img.onload = img.onerror = null; img.removeAttribute('src');
img.dataset.hasAnnotation = 'false'; img.draggable = false; figure.hidden = true;
var src = reportPhotos && reportPhotos[i + 1];
diff --git a/h5/test/wrinkle-async-browser.mjs b/h5/test/wrinkle-async-browser.mjs
index bb874c8..d985ec7 100644
--- a/h5/test/wrinkle-async-browser.mjs
+++ b/h5/test/wrinkle-async-browser.mjs
@@ -29,7 +29,12 @@ try {
const result=await(await primary).json();assert.equal(result.ok,true);assert.equal(result.wrinkle_views.left.status,'pending');
assert.equal(fs.readFileSync(path.join(dir,'provider-calls.jsonl'),'utf8').trim().split('\n').length,1,'main response cannot wait for side paid calls');
await page.waitForSelector('#s-report.active');const conclusion=await page.locator('#ditem-para2').textContent();
- await page.waitForFunction(()=>document.querySelector('#wrinkle-layer-note').textContent.includes('加载中'));
+ await page.waitForFunction(()=>document.querySelectorAll('#wrinkle-sides .wrinkle-loading:not([hidden])').length===2);
+ assert(await page.locator('#wrinkle-layer-note').evaluate(e=>e.hidden),'pending text is on images, not duplicated below');
+ for(const view of ['left','right']) {
+ const sizes=await page.locator('#wrinkle-'+view).evaluate(img=>{const a=img.closest('button').getBoundingClientRect(),b=img.parentNode.querySelector('.wrinkle-loading').getBoundingClientRect();return [a.width,a.height,b.width,b.height];});
+ assert.equal(sizes[0],sizes[2]);assert.equal(sizes[1],sizes[3]);
+ }
await page.waitForFunction(()=>window.__annotationStarts.length===1);
await page.evaluate(()=>window.__asyncScreen('s-splash'));
const api=page.context().request;
@@ -39,6 +44,7 @@ try {
assert.equal(await page.locator('#wrinkle-left').getAttribute('data-has-annotation'),'false','hidden report is not patched by stale response');
await page.evaluate(()=>window.__asyncScreen('s-report'));
await page.waitForFunction(()=>['left','right'].every(v=>document.querySelector('#wrinkle-'+v).dataset.hasAnnotation==='true'));
+ assert.equal(await page.locator('#wrinkle-sides .wrinkle-loading:not([hidden])').count(),0,'loading mask disappears when layers are ready');
assert.equal(await page.locator('#ditem-para2').textContent(),conclusion);
assert.deepEqual(await page.evaluate(()=>window.__annotationStarts),[{report:true,scan:false}]);
const stored=await(await api.get(base+'api/reports/'+result.record_id)).json();assert.equal(stored.report.payload.wrinkle_job.status,'complete');assert.deepEqual(stored.report.vm,result.vm);assert.deepEqual(stored.report.presentation,result.presentation);
@@ -49,6 +55,7 @@ try {
await page.evaluate(()=>window.__startAsyncReport());await page.waitForSelector('#s-report.active');
await page.waitForFunction(()=>document.querySelector('#wrinkle-layer-note').textContent.includes('部分侧脸未获取'));
assert.equal(await page.locator('#s-report.active').count(),1,'optional annotation failure keeps report accessible');
+ assert.equal(await page.locator('#wrinkle-sides .wrinkle-loading:not([hidden])').count(),0,'failed load never leaves a permanent mask');
assert.equal(await page.locator('#ditem-para2').textContent(),conclusion);
assert.equal(fs.readFileSync(path.join(dir,'provider-calls.jsonl'),'utf8').trim().split('\n').length,4,'failed optional endpoint adds no provider retry');
assert.deepEqual(errors,[]);console.log('PASS: fast main report, side requests only after active report, pending/ready UI, unchanged conclusions/scores, owner isolation, snapshot cache, exactly 3 fixture calls');