/* ═══════════════════════════════════════════════════════════
巨子测肤 · 后台查看页脚本(纯原生 ES5,零依赖)
页签:记录列表 / 失败与异常 / 统计概览;行点击开详情抽屉。
═══════════════════════════════════════════════════════════ */
'use strict';
/* ─── 工具 ─── */
function $(sel, root) { return (root || document).querySelector(sel); }
function el(tag, cls, text) {
var e = document.createElement(tag);
if (cls) e.className = cls;
if (text != null) e.textContent = text;
return e;
}
function esc(s) { // 拼 HTML 前转义(接口文案/失败信息不可信)
return String(s == null ? '' : s)
.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
}
function fmtTime(iso) {
if (!iso) return '-';
return BeijingTime.format(iso);
}
function fmtMs(ms) { return ms == null ? '-' : ms + 'ms'; }
function fmtScore(v) { return v == null ? '-' : v; }
function fmtTotal(v) { return v == null ? '--' : v; }
function fmtToneScore(v) { return v == null ? '-' : Math.round((v + 1e-9) * 100) / 100; }
var LV_CLASS = { '预警': 'lv-bad', '改善': 'lv-warn', '良好': 'lv-good' };
var DIM_NAMES = ReportUI.dimNames;
var CAT_ORDER = ['contour', 'wrinkles', 'smooth', 'tone', 'sens', 'acne'];
var ST_CLASS = { '成功': 'st-ok', '失败': 'st-fail', '检测中': 'st-ing', '待检测': 'st-wait' };
function toast(msg) {
var t = $('#toast');
t.textContent = msg;
t.classList.remove('hidden');
clearTimeout(toast._timer);
toast._timer = setTimeout(function () { t.classList.add('hidden'); }, 2600);
}
function api(url, opts) {
return fetch(url, opts || {}).then(function (res) {
return res.json().then(function (data) {
if (!res.ok || data.ok === false) throw new Error(data.message || ('HTTP ' + res.status));
return data;
});
});
}
/* ─── 页签切换 ─── */
var TABS = { records: 1, fails: 1, stats: 1 };
function switchTab(name) {
var tabs = document.querySelectorAll('.tab');
for (var i = 0; i < tabs.length; i++) tabs[i].classList.toggle('on', tabs[i].dataset.tab === name);
var panels = document.querySelectorAll('.panel');
for (var j = 0; j < panels.length; j++) panels[j].classList.toggle('on', panels[j].id === 'tab-' + name);
if (name === 'records') loadRecords();
else if (name === 'fails') loadFails();
else if (name === 'stats') loadStats();
}
/* ─── 档位标签 ─── */
function lvHtml(level) {
if (!level) return '- ';
return '' + esc(ReportUI.levelMap[level] || level) + ' ';
}
function dimsHtml(dims) {
if (!dims) return '- ';
var h = '';
for (var k in DIM_NAMES) {
var d = dims[k];
var grade = ReportUI.internalLevel(d && d.S);
h += '
' +
DIM_NAMES[k] + ' ' + ReportUI.dimensionScore(d && d.S) + ' ' +
esc(ReportUI.levelMap[grade] || '-') + '
';
}
return h;
}
function profileHtml(it) {
var parts = [];
parts.push(it.real_age != null ? it.real_age + '岁' : '-');
parts.push(it.gender || '-');
parts.push(it.region || '-');
parts.push(it.daily_skin_type || '-');
return esc(parts.join(' / '));
}
function ageText(age) { return age == null ? '-' : esc(age) + '岁'; }
function skinAgeHtml(r) {
return '原始 ' + ageText(r.skin_age) + ' 展示 ' + ageText(r.skin_age_display) + '
';
}
function captureQualityHtml(r) {
var q = r.capture_quality;
if (!q) return '';
var label = q.status === 'invalid' ? '侧脸质量异常(未拦截)' : q.status === 'unavailable' ? '侧脸质量未获取' : '侧脸质量有效';
return '' + esc(label) + '
';
}
function productName(name) {
for (var i = 0; i < ReportUI.products.length; i++) {
var p = ReportUI.products[i];
if (p.match === name) return p.title + '(' + ['25+', '35+', '50+'][i] + ')';
}
return name || '-';
}
function ruleLabel(r) { return r.recommendation ? r.recommendation.version : '旧版规则(历史记录)'; }
function tierText(tier) { return tier ? ['25+ / I', '35+ / II', '50+ / III'][tier - 1] : '无'; }
/* ═══════════ 记录列表 ═══════════ */
var pageState = { page: 1, page_size: 20, total: 0 };
function collectFilters() {
var p = [];
function add(k, v) { if (v) p.push(k + '=' + encodeURIComponent(v)); }
add('from', $('#f-from').value);
add('to', $('#f-to').value);
add('detect_status', $('#f-status').value);
add('fail_code', $('#f-failcode').value);
add('age_min', $('#f-agemin').value);
add('age_max', $('#f-agemax').value);
add('group', $('#f-group').value);
add('dim', $('#f-dim').value);
add('dim_level', $('#f-dimlevel').value);
add('q', $('#f-q').value.trim());
p.push('page=' + pageState.page);
p.push('page_size=' + pageState.page_size);
return p.join('&');
}
function loadRecords() {
api('/api/admin/records?' + collectFilters()).then(function (data) {
pageState.total = data.total;
var tbody = $('#tbl-records tbody');
tbody.innerHTML = '';
if (!data.items.length) {
tbody.innerHTML = '暂无记录 ';
}
for (var i = 0; i < data.items.length; i++) {
(function (it) {
var tr = el('tr');
var photos = it.photos || {};
var thumb = photos.front
? ' '
: '无 ';
tr.innerHTML =
'' + thumb + ' ' +
'' + esc(it.record_id) + (it.retry_of ? '重试自 ' + esc(it.retry_of) + '
' : '') + ' ' +
'' + profileHtml(it) + ' ' +
'' + esc(it.shoot_status) + captureQualityHtml(it) + ' ' +
'' + esc(it.upload_status) + ' ' +
'' + esc(it.detect_status) + ' ' +
(it.fail_code ? '' + esc(it.fail_code) + '
' : '') + ' ' +
'' + fmtTotal(it.total_score) + ' ' +
'' + skinAgeHtml(it) + ' ' +
'' + dimsHtml(it.dims) + ' ' +
'' + esc(productName(it.essence)) + '' + esc(ruleLabel(it)) + '
' +
'' + fmtMs(it.cost_ms) + ' ' +
'' + fmtTime(it.created_at) + ' ';
tr.addEventListener('click', function (e) {
if (e.target && e.target.className === 'thumb') { showLightbox(e.target.src); return; }
openDetail(it.record_id);
});
tbody.appendChild(tr);
})(data.items[i]);
}
var pages = Math.max(1, Math.ceil(data.total / data.page_size));
$('#page-info').textContent = '第 ' + data.page + ' / ' + pages + ' 页 · 共 ' + data.total + ' 条';
$('#btn-prev').disabled = data.page <= 1;
$('#btn-next').disabled = data.page >= pages;
}).catch(function (e) { toast('加载失败:' + e.message); });
}
/* ═══════════ 失败与异常 ═══════════ */
function renderCaptureFunnel(st) {
var funnel = st.capture_funnel;
var panel = $('#capture-funnel');
if (funnel && panel) {
var labels = [['started','开始拍照'],['front','正面拍完'],['right','右脸拍完'],['left','三张拍完'],['submitted','提交检测'],['response','收到成功结果'],['report','进入报告页']];
var html = '阶段 累计流程 今日流程 ';
labels.forEach(function (row) { html += '' + row[1] + ' ' + funnel.total.counts[row[0]] + ' ' + funnel.today.counts[row[0]] + ' '; });
html += '
';
html += '首次采集:' + (funnel.since ? fmtTime(new Date(funnel.since).toISOString()) : '暂无数据') + '
';
[['total','累计'],['today','今日']].forEach(function (row) {
var f = funnel[row[0]];
html += '' + row[1] + ':进入报告转化率 ' + f.rate + '% · 成功进入平均耗时 ' + (f.avg_ms == null ? '—' : (f.avg_ms / 1000).toFixed(1) + ' 秒') +
' · 成功结果但未进入 ' + f.response_without_report + ' · 明确失败 ' + f.counts.failed + ' · 主动取消 ' + f.counts.canceled + ' · 发生后台中断 ' + f.counts.interrupted + ' · 尚未完成 ' + f.unfinished + '
';
});
panel.innerHTML = html;
}
}
function loadFails() {
api('/api/admin/stats').then(function (st) {
var box = $('#fail-dist');
box.innerHTML = '';
if (!st.fail_dist.length) box.appendChild(el('div', 'pie-empty', '暂无失败记录'));
for (var i = 0; i < st.fail_dist.length; i++) {
(function (f) {
var chip = el('div', 'fail-chip');
chip.innerHTML = '' + esc(f.code) + ' ' +
'' + f.count + ' 次 · 占比 ' + f.pct + '% ' +
' ';
box.appendChild(chip);
})(st.fail_dist[i]);
}
}).catch(function (e) { toast('加载失败:' + e.message); });
api('/api/admin/records?detect_status=失败&page_size=100').then(function (data) {
var tbody = $('#tbl-fails tbody');
tbody.innerHTML = '';
if (!data.items.length) {
tbody.innerHTML = '暂无失败记录 ';
}
for (var i = 0; i < data.items.length; i++) {
(function (it) {
var tr = el('tr');
tr.innerHTML =
'' + esc(it.record_id) + ' ' +
'' + esc(it.fail_stage || '-') + ' ' +
'' + esc(it.fail_code || '-') + ' ' +
'' + esc(it.fail_msg || '-') + ' ' +
'' + (it.real_age != null ? it.real_age + '岁' : '-') + ' ' +
'' + esc(it.shoot_status) + ' ' +
'' + fmtTime(it.created_at) + ' ' +
'重试 ';
tr.addEventListener('click', function (e) {
if (e.target && e.target.tagName === 'BUTTON') { doRetry(it.record_id); return; }
openDetail(it.record_id);
});
tbody.appendChild(tr);
})(data.items[i]);
}
}).catch(function (e) { toast('加载失败:' + e.message); });
}
function doRetry(id) {
if (!confirm('将使用原照片重新检测并生成新记录,确认重试 ' + id + ' ?')) return;
toast('重试中,请稍候(最长约 1 分钟)…');
api('/api/admin/records/' + encodeURIComponent(id) + '/retry', { method: 'POST' }).then(function (data) {
toast('重试完成,新记录 ' + data.record.record_id + '(' + data.record.detect_status + ')');
var active = $('.panel.on').id;
if (active === 'tab-records') loadRecords();
else if (active === 'tab-fails') loadFails();
}).catch(function (e) { toast('重试失败:' + e.message); });
}
/* ═══════════ 统计概览 ═══════════ */
var PIE_COLORS = ['#2563eb', '#2fa84f', '#f0a020', '#8a5cf6', '#d93026'];
function loadStats() {
api('/api/admin/stats').then(function (st) {
renderCaptureFunnel(st);
var cards = [
['今日检测', st.today_total],
['累计检测', st.total],
['成功率', st.success_rate + '%'],
['平均耗时', st.avg_cost_ms + 'ms']
];
var box = $('#stat-cards');
box.innerHTML = '';
for (var i = 0; i < cards.length; i++) {
var c = el('div', 'card');
c.innerHTML = '' + cards[i][0] + '
' + cards[i][1] + '
';
box.appendChild(c);
}
/* 三维度档位分布:每组三根横条 */
var dd = $('#dim-dist');
dd.innerHTML = '';
for (var d in DIM_NAMES) {
var dist = st.dim_dist[d] || {};
var max = Math.max(1, dist['预警'] || 0, dist['改善'] || 0, dist['良好'] || 0);
var row = el('div', 'dim-row');
var bars = '';
var levels = [['预警', 'b-bad'], ['改善', 'b-warn'], ['良好', 'b-good']];
for (var l = 0; l < levels.length; l++) {
var cnt = dist[levels[l][0]] || 0;
bars += '' +
'' + ReportUI.levelMap[levels[l][0]] + ' ' +
'' +
' ' +
'' + cnt + '
';
}
row.innerHTML = '' + DIM_NAMES[d] + '
' + bars;
dd.appendChild(row);
}
/* 产品组别分布:纯 CSS conic-gradient 饼图 */
var gd = $('#group-dist');
gd.innerHTML = '';
var keys = Object.keys(st.group_dist);
if (!keys.length) {
gd.appendChild(el('div', 'pie-empty', '暂无成功记录'));
} else {
var total = 0, k;
for (k in st.group_dist) total += st.group_dist[k];
var acc = 0, segs = [];
for (var g = 0; g < keys.length; g++) {
var from = acc * 100 / total;
acc += st.group_dist[keys[g]];
var to = acc * 100 / total;
segs.push(PIE_COLORS[g % PIE_COLORS.length] + ' ' + from + '% ' + to + '%');
}
var legend = '';
for (var g2 = 0; g2 < keys.length; g2++) {
legend += ' ' +
esc(keys[g2]) + ':' + st.group_dist[keys[g2]] + ' 次(' + Math.round(st.group_dist[keys[g2]] * 100 / total) + '%)
';
}
gd.innerHTML = '
' +
'' + legend + '
';
}
/* 近 14 天每日检测量(蓝=总量,绿=成功数) */
var dc = $('#daily-chart');
dc.innerHTML = '';
var daily = st.daily || [];
var dmax = 1;
for (var n = 0; n < daily.length; n++) dmax = Math.max(dmax, daily[n].total);
for (var m = 0; m < daily.length; m++) {
(function (day) {
var col = el('div', 'daily-col');
var h = Math.max(2, day.total * 130 / dmax);
var okH = day.total ? Math.max(1, day.success * 130 / dmax) : 0;
col.innerHTML = '' + day.total + ' ' +
'' +
'' + day.date.slice(5) + ' ';
col.title = day.date + ':检测 ' + day.total + ' 次,成功 ' + day.success + ' 次';
dc.appendChild(col);
})(daily[m]);
}
}).catch(function (e) { toast('加载失败:' + e.message); });
}
/* ═══════════ 记录详情(抽屉) ═══════════ */
var currentDetailId = null;
function openDetail(id) {
currentDetailId = id;
$('#d-title').textContent = '记录详情 ' + id;
$('#btn-retry').disabled = true;
$('#d-body').innerHTML = '加载中…
';
$('#drawer').classList.remove('hidden');
$('#drawer-mask').classList.remove('hidden');
api('/api/admin/records/' + encodeURIComponent(id)).then(renderDetail)
.catch(function (e) { $('#d-body').innerHTML = '加载失败:' + esc(e.message) + '
'; });
}
function closeDetail() {
$('#drawer').classList.add('hidden');
$('#drawer-mask').classList.add('hidden');
currentDetailId = null;
}
function dSec(title, innerHtml) {
return '
' + title + ' ' + innerHtml + '';
}
function renderDetail(data) {
var r = data.record;
$('#btn-retry').disabled = !(data.photos && data.photos.front);
var h = '';
/* 三张照片 */
var photosHtml = '';
var whichNames = { front: '正脸', left: '左脸', right: '右脸' };
var anyPhoto = false;
for (var w in whichNames) {
var u = data.photos[w];
if (!u) continue;
anyPhoto = true;
photosHtml += '
';
}
photosHtml += anyPhoto ? '' : '
无照片 ';
photosHtml += '
';
h += dSec('检测照片', photosHtml);
/* 状态行 */
h += dSec('状态', '' +
'检测状态 ' + esc(r.detect_status) + ' ' +
'拍摄状态 ' + esc(r.shoot_status) + ' ' +
'上传状态 ' + esc(r.upload_status) + ' ' +
'创建时间 ' + fmtTime(r.created_at) + ' ' +
'更新时间 ' + fmtTime(r.updated_at) + ' ' +
'耗时 ' + fmtMs(r.cost_ms) + ' ' +
(r.retry_of ? '重试来源 ' + esc(r.retry_of) + ' ' : '') +
'被重试次数 ' + (r.retry_count || 0) + ' ' +
'
');
/* 档案 */
if (r.capture_quality || r.capture_diagnostics) {
var quality = captureQualityHtml(r);
if (r.capture_quality) ['left', 'right'].forEach(function (key) {
var view = r.capture_quality.views[key];
quality += '' + (key === 'left' ? '左脸' : '右脸') + ':quality=' + esc(view.quality == null ? '未获取' : view.quality) + ',角度=' + esc(view.angle == null ? '未获取' : view.angle) + ',状态=' + esc(view.status) + '
';
});
if (r.capture_diagnostics) quality += '前端参数版本:' + esc(r.capture_diagnostics.version) + '(客户端自报,仅作校准参考)
' + esc(JSON.stringify(r.capture_diagnostics, null, 2)) + ' ';
h += dSec('拍摄质量与前端校准', quality);
}
/* 档案 */
h += dSec('档案信息', '' +
'实际年龄 ' + ageText(r.real_age) + ' ' +
'性别 ' + esc(r.gender || '-') + ' ' +
'地区 ' + esc(r.region || '-') + ' ' +
'日常肤质 ' + esc(r.daily_skin_type || '-') + ' ' +
'产品组别 ' + esc(r.group || '-') + ' ' +
'
');
/* 失败:错误 + 版本链 */
if (r.detect_status === '失败') {
h += dSec('失败信息', '' + esc(r.fail_code || '-') + ' (环节:' + esc(r.fail_stage || '-') + ') ' + esc(r.fail_msg || '-') + '
');
}
/* 版本链(重试关系) */
var vHtml = '';
if (data.versions && data.versions.parent) {
vHtml += '';
}
if (data.versions && data.versions.children && data.versions.children.length) {
vHtml += '';
}
if (vHtml) h += dSec('版本链', '' + vHtml + '
');
/* 成功:评分 + 文案 */
if (r.detect_status === '成功') {
h += dSec('综合', '' +
'综合得分 ' + fmtTotal(r.total_score) + ' ' +
'原始肌龄(接口返回) ' + ageText(r.skin_age) + ' ' +
'报告展示肌龄(推荐依据) ' + ageText(r.skin_age_display) + ' ' +
'六分类均分S ' + fmtScore(r.avg_s) + ' ' +
'左下颌线角 ' + (r.jawline_angle_l != null ? r.jawline_angle_l + '°' : '-') + ' ' +
'右下颌线角 ' + (r.jawline_angle_r != null ? r.jawline_angle_r + '°' : '-') + ' ' +
'
');
h += '综合得分=紧塑力×4+透亮感×3+稳韧力×3,按已计算的维度S分数(两位小数)加权,四舍五入取整数;任一维度缺失不生成综合得分,不使用接口总分。
';
if (r.wrinkle_score_rule === 'api-overall-v1') h += '皱纹评分直接使用接口整体皱纹分数,不做100减分,也不再按分区严重度限分。
';
if (r.tone_scoring && r.tone_scoring.version === 'color-dark-circle-20-v1') {
var tone = r.tone_scoring;
h += '肤色项=色素扣斑分×80%+黑眼圈评分×20%;' + (r.vita_score_rule === 'independent-color-water-smooth-min-v1' ? '该历史版本透亮感取独立肤色、水分、平滑度三项最低分。' : '肤色项与水分项各占50%合成肤色水润分,透亮感取肤色水润与平滑度的最低分。') + '色素扣斑分:' + esc(fmtToneScore(tone.pigment_adjusted_score)) + ',黑眼圈评分:' + esc(fmtToneScore(tone.dark_circle_score)) + ',独立肤色项:' + esc(fmtToneScore(tone.color_score)) + ',水分项:' + esc(fmtToneScore(tone.water_adjusted_score)) + '。肤色缺项按有效权重归一。
';
}
h += '展示肌龄限制在实际年龄±5岁;当前推荐使用报告展示肌龄及边界规则。原始检测数据和历史快照保留。
';
/* 6 分类表格 */
var catRows = '';
var scoreOrder = r.sub_scores ? ['contour','wrinkles','color','smooth','water','sens','acne'] : CAT_ORDER;
for (var i = 0; i < scoreOrder.length; i++) {
var ck = scoreOrder[i], cat = (r.sub_scores || r.cats || {})[ck];
var catName = ({ contour: '轮廓', wrinkles: '皱纹', smooth: '平滑度', color:'肤色', water:'水分', tone: '肤色 / 水分(历史)', sens: '泛红', acne: '痘痘' })[ck];
catRows += '' + esc(catName) + ' ' + fmtScore(cat && cat.P) + ' ' + fmtScore(cat && cat.S) + ' ' + lvHtml(ReportUI.internalLevel(cat && cat.S)) + ' ';
}
h += dSec(r.sub_scores ? '七项独立评分' : '历史六分类评分(肤色 / 水分共用一项)', '');
/* 3 维度表格 */
var dimRows = '';
for (var dk in DIM_NAMES) {
var dim = r.dims && r.dims[dk];
dimRows += '' + DIM_NAMES[dk] + ' ' + fmtScore(dim && dim.P) + ' ' + fmtScore(dim && dim.S) + ' ' + lvHtml(ReportUI.internalLevel(dim && dim.S)) + ' ';
}
h += dSec('三维度评分', '');
if (r.acne_scoring && r.acne_scoring.version === 'three-view-type-max-v1') {
var audit = r.acne_scoring, acneRows = '';
(audit.types || []).forEach(function(t) {
var v = t.views || {};
acneRows += '' + esc(t.name) + ' ' + esc(fmtScore(v.front)) + ' ' + esc(fmtScore(v.left)) + ' ' + esc(fmtScore(v.right)) + ' ' + esc(fmtScore(t.count)) + ' ' + esc(fmtScore(t.weight)) + ' ' + esc(fmtScore(t.deduction)) + ' ';
});
h += dSec('痘痘三视图计分明细', '每种皮损分别取三视图最大数量,不相加,也不是全脸去重总数;“-”表示未获取。整体评分仍用正脸:' + esc(fmtScore(audit.front_score)) + ',映射分:' + esc(fmtScore(audit.mapped_score)) + '。计数扣分合计:' + esc(fmtScore(audit.raw_deduction)) + ',封顶50后:' + esc(fmtScore(audit.deduction)) + '。
类型 正脸 左脸 右脸 计分数量 单处扣分 该项扣分 ' + acneRows + '
');
}
if (r.wrinkle_view_status) {
h += dSec('侧脸皱纹标注获取状态', '左右侧脸各自独立调用,仅用于图层展示,不参与计分;查看报告不重新调用。
' +
['left','right'].map(function(view) { var s=r.wrinkle_view_status[view]||{}; return ''+ (view==='left'?'左脸':'右脸') +':'+esc(s.status==='ready'?'已获取':'未获取')+(s.error_code?'('+esc(s.error_code)+')':'')+'
'; }).join(''));
}
/* 文案全文 */
var copyHtml = '';
var presentation = r.presentation;
if (presentation && presentation.views) {
for (var v = 0; v < presentation.views.length; v++) {
var view = presentation.views[v];
copyHtml += '' + esc(view.name) + ' / ' + esc(view.level || '未获取') + ': ' + esc(view.text) + '
';
}
} else if (r.copy_6) {
for (var c2 = 0; c2 < CAT_ORDER.length; c2++) {
var txt = r.copy_6[CAT_ORDER[c2]];
if (txt) copyHtml += '' + esc((r.cats[CAT_ORDER[c2]] || {}).name || '') + ': ' + esc(txt) + '
';
}
}
if (r.copy_summary) copyHtml += '旧版算法摘要(原始留档,不用于当前文案分档): ' + esc(r.copy_summary) + '
';
if (r.lifestyle) copyHtml += '旧版生活方式(原始留档): ' + esc(r.lifestyle) + '
';
if (copyHtml) h += dSec(presentation ? (presentation.copyRule === ReportUI.copyRule ? '前端报告结论(当前文案展示,原始快照保留)与计算摘要' : '前端报告结论(保存快照,未刷新文案)与计算摘要') : '旧版生成文案(无前端快照)', copyHtml);
// Technical region metrics stay in the protected admin, not customer prose.
var wrinkleView = presentation && presentation.views && presentation.views.filter(function(view) { return view.key === 'wrinkles'; })[0];
var wrinkleRows = wrinkleView && wrinkleView.details;
if (!wrinkleRows && data.raw_json && data.raw_json.result) wrinkleRows = ReportUI.wrinkleDetails(data.raw_json.result);
if (wrinkleRows) {
var metricDefs = [['wrinkle_score', '皱纹分'], ['wrinkle_severity_level', '严重等级'],
['wrinkle_deep_num', '深纹条数'], ['wrinkle_shallow_num', '浅纹条数'],
['wrinkle_norm_length', '标准化长度'], ['wrinkle_norm_depth', '标准化深度'],
['wrinkle_pixel_density', '像素密度'], ['wrinkle_area_ratio', '面积比例'], ['wrinkle_deep_ratio', '深纹比例']];
var wrinkleHead = '分区 ', wrinkleBody = '';
metricDefs.forEach(function(metric) { wrinkleHead += '' + metric[1] + ' '; });
wrinkleRows.forEach(function(row) {
wrinkleBody += '' + esc(row.name) + ' ';
metricDefs.forEach(function(metric) {
var value = row.metrics[metric[0]];
wrinkleBody += '' + (value == null ? '未获取' : esc(String(value))) + ' ';
});
wrinkleBody += ' ';
});
h += dSec('皱纹分区原始指标(仅后台复核;左右以本人为准)', '' +
wrinkleHead + ' ' + wrinkleBody + '
');
}
var care = presentation ? presentation.care : r.careAdvice;
if (care && care.length) {
var advice = '';
for (var a = 0; a < care.length; a++) {
advice += '[' + esc(care[a].name || care[a].tag) + '] ' + esc(care[a].text) + '
';
}
h += dSec('护理建议', advice);
}
var product = presentation && presentation.product;
h += dSec('推荐精华', '' + esc(productName(r.essence && r.essence.name)) + ' ' +
esc(product ? product.name + '双仓精华液' : (r.essence && r.essence.text) || '') + '
' +
'规则版本 ' + esc(ruleLabel(r)) + ' ' +
'触发判据 ' + esc(r.trigger || '-') + '
');
if (r.recommendation) {
var rec = r.recommendation, scoreParts = [];
for (var sk in DIM_NAMES) {
var score = rec.scores[sk];
scoreParts.push(DIM_NAMES[sk] + ':' + (score == null ? '未获取' : String(score)));
}
h += dSec('推荐依据', '' + (rec.ageBasis === 'display' ? '使用展示肌龄' : '使用原始肌龄') + ' ' + ageText(rec.skinAge) + ' ' +
'基础档 ' + esc(tierText(rec.baseTier)) + ' ' +
'最终档 ' + esc(tierText(rec.tier)) + '
' +
'' + esc(rec.reason) + '
判定分(未舍入S,不使用前端一位小数):' + esc(scoreParts.join(';')) + '
');
}
if (r.notes && r.notes.length) {
var notes = '';
for (var n2 = 0; n2 < r.notes.length; n2++) notes += '' + esc(r.notes[n2]) + ' ';
notes += ' ';
h += dSec('计算备注', notes);
}
}
/* 接口原始 JSON:details 折叠,pre 展示(face_maps 已在服务端裁剪,完整版走下载链接) */
if (data.raw_json) {
var rawDl = data.raw_file
? ' 下载完整原始 JSON '
: '';
h += dSec('接口原始 JSON', '展开/收起(' + (data.raw_file || '') + ')' + rawDl + ' ' +
esc(JSON.stringify(data.raw_json, null, 1)) + ' ');
} else {
h += dSec('接口原始 JSON', '无(失败记录或未落盘)
');
}
var body = $('#d-body');
body.innerHTML = h;
/* 图片点击看大图 */
var imgs = body.querySelectorAll('img[data-zoom]');
for (var z = 0; z < imgs.length; z++) {
imgs[z].addEventListener('click', function () { showLightbox(this.getAttribute('data-zoom')); });
}
/* 版本链跳转 */
var links = body.querySelectorAll('.d-versions a');
for (var v = 0; v < links.length; v++) {
links[v].addEventListener('click', function () { openDetail(this.getAttribute('data-id')); });
}
}
/* ═══════════ 图片查看 ═══════════ */
function showLightbox(src) {
$('#lightbox-img').src = src;
$('#lightbox').classList.remove('hidden');
}
/* ═══════════ 事件绑定与初始化 ═══════════ */
function bind() {
var tabs = document.querySelectorAll('.tab');
for (var i = 0; i < tabs.length; i++) {
tabs[i].addEventListener('click', function () { switchTab(this.dataset.tab); });
}
$('#btn-search').addEventListener('click', function () { pageState.page = 1; loadRecords(); });
$('#btn-reset').addEventListener('click', function () {
var ids = ['f-from', 'f-to', 'f-status', 'f-failcode', 'f-agemin', 'f-agemax', 'f-group', 'f-dim', 'f-dimlevel', 'f-q'];
for (var i2 = 0; i2 < ids.length; i2++) $('#' + ids[i2]).value = '';
pageState.page = 1;
loadRecords();
});
$('#btn-prev').addEventListener('click', function () { if (pageState.page > 1) { pageState.page--; loadRecords(); } });
$('#btn-next').addEventListener('click', function () { pageState.page++; loadRecords(); });
$('#btn-close').addEventListener('click', closeDetail);
$('#drawer-mask').addEventListener('click', closeDetail);
$('#btn-retry').addEventListener('click', function () { if (currentDetailId) doRetry(currentDetailId); });
$('#lightbox').addEventListener('click', function () { this.classList.add('hidden'); });
$('#btn-export').addEventListener('click', function () {
var p = [];
if ($('#exp-from').value) p.push('from=' + $('#exp-from').value);
if ($('#exp-to').value) p.push('to=' + $('#exp-to').value);
var url = '/api/admin/export' + (p.length ? '?' + p.join('&') : '');
var a = el('a');
a.href = url;
a.download = '';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') { closeDetail(); $('#lightbox').classList.add('hidden'); }
});
}
bind();
loadRecords();