Files
juzi/h5/js/admin.js
T

623 lines
33 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.
/* ═══════════════════════════════════════════════════════════
巨子测肤 · 后台查看页脚本(纯原生 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
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 adminURL(url) {
return window.JuziEntry && window.JuziEntry.mode === 'rednote' && url.indexOf('/api/') === 0 ? '/rednote' + url : url;
}
function api(url, opts) {
return fetch(adminURL(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 '<span class="sub">-</span>';
return '<span class="lv ' + (LV_CLASS[level] || '') + '">' + esc(ReportUI.levelMap[level] || level) + '</span>';
}
function dimsHtml(dims) {
if (!dims) return '<span class="sub">-</span>';
var h = '';
for (var k in DIM_NAMES) {
var d = dims[k];
var grade = ReportUI.internalLevel(d && d.S);
h += '<div><span class="lv ' + (LV_CLASS[grade] || '') + '" title="' + DIM_NAMES[k] + '">' +
DIM_NAMES[k] + ' ' + ReportUI.dimensionScore(d && d.S) + ' ' +
esc(ReportUI.levelMap[grade] || '-') + '</span></div>';
}
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 '<b>原始 ' + ageText(r.skin_age) + '</b><div class="sub">展示 ' + ageText(r.skin_age_display) + '</div>';
}
function captureQualityHtml(r) {
var q = r.capture_quality;
if (!q) return '';
var label = q.status === 'invalid' ? '侧脸质量异常(未拦截)' : q.status === 'unavailable' ? '侧脸质量未获取' : '侧脸质量有效';
return '<div class="sub">' + esc(label) + '</div>';
}
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 = '<tr><td colspan="12" style="text-align:center;color:#8a94a6;padding:32px">暂无记录</td></tr>';
}
for (var i = 0; i < data.items.length; i++) {
(function (it) {
var tr = el('tr');
var photos = it.photos || {};
var thumb = photos.front
? '<img class="thumb" loading="lazy" decoding="async" src="' + photos.front + '" alt="正脸">'
: '<span class="sub">无</span>';
tr.innerHTML =
'<td>' + thumb + '</td>' +
'<td class="mono">' + esc(it.record_id) + (it.retry_of ? '<div class="sub">重试自 ' + esc(it.retry_of) + '</div>' : '') + '</td>' +
'<td>' + profileHtml(it) + '</td>' +
'<td>' + esc(it.shoot_status) + captureQualityHtml(it) + '</td>' +
'<td>' + esc(it.upload_status) + '</td>' +
'<td><span class="st ' + (ST_CLASS[it.detect_status] || '') + '">' + esc(it.detect_status) + '</span>' +
(it.fail_code ? '<div class="sub">' + esc(it.fail_code) + '</div>' : '') + '</td>' +
'<td><b>' + fmtTotal(it.total_score) + '</b></td>' +
'<td>' + skinAgeHtml(it) + '</td>' +
'<td>' + dimsHtml(it.dims) + '</td>' +
'<td>' + esc(productName(it.essence)) + '<div class="sub">' + esc(ruleLabel(it)) + '</div></td>' +
'<td class="mono">' + fmtMs(it.cost_ms) + '</td>' +
'<td class="mono">' + fmtTime(it.created_at) + '</td>';
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 = '<table><thead><tr><th>阶段</th><th>累计流程</th><th>今日流程</th></tr></thead><tbody>';
labels.forEach(function (row) { html += '<tr><td>' + row[1] + '</td><td>' + funnel.total.counts[row[0]] + '</td><td>' + funnel.today.counts[row[0]] + '</td></tr>'; });
html += '</tbody></table>';
html += '<p class="hint">首次采集:' + (funnel.since ? fmtTime(new Date(funnel.since).toISOString()) : '暂无数据') + '</p>';
[['total','累计'],['today','今日']].forEach(function (row) {
var f = funnel[row[0]];
html += '<p>' + 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 + '</p>';
});
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 = '<span class="code">' + esc(f.code) + '</span>' +
'<span class="pct">' + f.count + ' 次 · 占比 ' + f.pct + '%</span>' +
'<span class="bar"><i style="width:' + Math.min(100, f.pct) + '%"></i></span>';
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 = '<tr><td colspan="8" style="text-align:center;color:#8a94a6;padding:32px">暂无失败记录</td></tr>';
}
for (var i = 0; i < data.items.length; i++) {
(function (it) {
var tr = el('tr');
tr.innerHTML =
'<td class="mono">' + esc(it.record_id) + '</td>' +
'<td>' + esc(it.fail_stage || '-') + '</td>' +
'<td><span class="st st-fail">' + esc(it.fail_code || '-') + '</span></td>' +
'<td style="white-space:normal;max-width:340px">' + esc(it.fail_msg || '-') + '</td>' +
'<td>' + (it.real_age != null ? it.real_age + '岁' : '-') + '</td>' +
'<td>' + esc(it.shoot_status) + '</td>' +
'<td class="mono">' + fmtTime(it.created_at) + '</td>' +
'<td><button type="button" class="btn small primary">重试</button></td>';
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 = '<div class="lab">' + cards[i][0] + '</div><div class="num">' + cards[i][1] + '</div>';
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 += '<div style="display:flex;align-items:center;gap:8px;margin:3px 0">' +
'<span style="width:34px;color:#5a6578;font-size:12px">' + ReportUI.levelMap[levels[l][0]] + '</span>' +
'<span style="flex:1;height:14px;background:#f0f2f5;border-radius:3px;overflow:hidden">' +
'<i style="display:block;height:100%;width:' + (cnt * 100 / max) + '%" class="' + levels[l][1] + '"></i></span>' +
'<span style="width:24px;text-align:right;font-size:12px;color:#4a5568">' + cnt + '</span></div>';
}
row.innerHTML = '<div class="dim-name">' + DIM_NAMES[d] + '</div>' + 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 += '<div><i style="background:' + PIE_COLORS[g2 % PIE_COLORS.length] + '"></i>' +
esc(keys[g2]) + ':' + st.group_dist[keys[g2]] + ' 次(' + Math.round(st.group_dist[keys[g2]] * 100 / total) + '%)</div>';
}
gd.innerHTML = '<div class="pie" style="background:conic-gradient(' + segs.join(',') + ')"></div>' +
'<div class="pie-legend">' + legend + '</div>';
}
/* 近 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 = '<span class="daily-val">' + day.total + '</span>' +
'<div class="daily-bar" style="height:' + h + 'px"><div class="daily-bar ok" style="width:70%;left:15%;height:' + okH + 'px"></div></div>' +
'<span class="daily-date">' + day.date.slice(5) + '</span>';
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 = '<div class="sub">加载中…</div>';
$('#drawer').classList.remove('hidden');
$('#drawer-mask').classList.remove('hidden');
api('/api/admin/records/' + encodeURIComponent(id)).then(renderDetail)
.catch(function (e) { $('#d-body').innerHTML = '<div class="sub">加载失败:' + esc(e.message) + '</div>'; });
}
function closeDetail() {
$('#drawer').classList.add('hidden');
$('#drawer-mask').classList.add('hidden');
currentDetailId = null;
}
function dSec(title, innerHtml) {
return '<div class="d-sec"><h3>' + title + '</h3>' + innerHtml + '</div>';
}
function renderDetail(data) {
var r = data.record;
$('#btn-retry').disabled = !(data.photos && data.photos.front);
var h = '';
/* 三张照片 */
var photosHtml = '<div class="d-photos">';
var whichNames = { front: '正脸', left: '左脸', right: '右脸' };
var anyPhoto = false;
for (var w in whichNames) {
var u = data.photos[w];
if (!u) continue;
anyPhoto = true;
photosHtml += '<div class="d-photo"><img loading="lazy" decoding="async" src="' + u + '" alt="' + whichNames[w] + '" data-zoom="' + u + '">' +
'<a href="' + u + '" download="' + r.record_id + '-' + w + '.jpg">下载' + whichNames[w] + '</a></div>';
}
photosHtml += anyPhoto ? '' : '<span class="sub">无照片</span>';
photosHtml += '</div>';
h += dSec('检测照片', photosHtml);
/* 状态行 */
h += dSec('状态', '<div class="d-kv">' +
'<span>检测状态 <b>' + esc(r.detect_status) + '</b></span>' +
'<span>拍摄状态 <b>' + esc(r.shoot_status) + '</b></span>' +
'<span>上传状态 <b>' + esc(r.upload_status) + '</b></span>' +
'<span>创建时间 <b>' + fmtTime(r.created_at) + '</b></span>' +
'<span>更新时间 <b>' + fmtTime(r.updated_at) + '</b></span>' +
'<span>耗时 <b>' + fmtMs(r.cost_ms) + '</b></span>' +
(r.retry_of ? '<span>重试来源 <b class="mono">' + esc(r.retry_of) + '</b></span>' : '') +
'<span>被重试次数 <b>' + (r.retry_count || 0) + '</b></span>' +
'</div>');
/* 档案 */
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 += '<p>' + (key === 'left' ? '左脸' : '右脸') + ':quality=' + esc(view.quality == null ? '未获取' : view.quality) + ',角度=' + esc(view.angle == null ? '未获取' : view.angle) + ',状态=' + esc(view.status) + '</p>';
});
if (r.capture_diagnostics) quality += '<p>前端参数版本:' + esc(r.capture_diagnostics.version) + '(客户端自报,仅作校准参考)</p><pre class="d-copy">' + esc(JSON.stringify(r.capture_diagnostics, null, 2)) + '</pre>';
h += dSec('拍摄质量与前端校准', quality);
}
/* 档案 */
h += dSec('档案信息', '<div class="d-kv">' +
'<span>实际年龄 <b>' + ageText(r.real_age) + '</b></span>' +
'<span>性别 <b>' + esc(r.gender || '-') + '</b></span>' +
'<span>地区 <b>' + esc(r.region || '-') + '</b></span>' +
'<span>日常肤质 <b>' + esc(r.daily_skin_type || '-') + '</b></span>' +
'<span>产品组别 <b>' + esc(r.group || '-') + '</b></span>' +
'</div>');
/* 失败:错误 + 版本链 */
if (r.detect_status === '失败') {
h += dSec('失败信息', '<div class="d-copy"><b>' + esc(r.fail_code || '-') + '</b>(环节:' + esc(r.fail_stage || '-') + ')<br>' + esc(r.fail_msg || '-') + '</div>');
}
/* 版本链(重试关系) */
var vHtml = '';
if (data.versions && data.versions.parent) {
vHtml += '<div>上一版本(本次为重试):<a data-id="' + esc(data.versions.parent.record_id) + '">' +
esc(data.versions.parent.record_id) + '(' + esc(data.versions.parent.detect_status) + ')</a></div>';
}
if (data.versions && data.versions.children && data.versions.children.length) {
vHtml += '<div>重试产生的记录:';
for (var c = 0; c < data.versions.children.length; c++) {
vHtml += '<a data-id="' + esc(data.versions.children[c].record_id) + '">' +
esc(data.versions.children[c].record_id) + '(' + esc(data.versions.children[c].detect_status) + ')</a>';
}
vHtml += '</div>';
}
if (vHtml) h += dSec('版本链', '<div class="d-versions">' + vHtml + '</div>');
/* 成功:评分 + 文案 */
if (r.detect_status === '成功') {
h += dSec('综合', '<div class="d-kv">' +
'<span>综合得分 <b>' + fmtTotal(r.total_score) + '</b></span>' +
'<span>原始肌龄(接口返回) <b>' + ageText(r.skin_age) + '</b></span>' +
'<span>报告展示肌龄(推荐依据) <b>' + ageText(r.skin_age_display) + '</b></span>' +
'<span>六分类均分S <b>' + fmtScore(r.avg_s) + '</b></span>' +
'<span>左下颌线角 <b>' + (r.jawline_angle_l != null ? r.jawline_angle_l + '°' : '-') + '</b></span>' +
'<span>右下颌线角 <b>' + (r.jawline_angle_r != null ? r.jawline_angle_r + '°' : '-') + '</b></span>' +
'</div>');
h += '<p class="sub">综合得分=紧塑力×4+透亮感×3+稳韧力×3,按已计算的维度S分数(两位小数)加权,四舍五入取整数;任一维度缺失不生成综合得分,不使用接口总分。</p>';
if (r.wrinkle_score_rule === 'api-overall-v1') h += '<p class="sub">皱纹评分直接使用接口整体皱纹分数,不做100减分,也不再按分区严重度限分。</p>';
if (r.tone_scoring && r.tone_scoring.version === 'color-dark-circle-20-v1') {
var tone = r.tone_scoring;
h += '<p class="sub">肤色项=色素扣斑分×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)) + '。肤色缺项按有效权重归一。</p>';
}
h += '<p class="sub">展示肌龄限制在实际年龄±5岁;当前推荐使用报告展示肌龄及边界规则。原始检测数据和历史快照保留。</p>';
/* 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 += '<tr><td>' + esc(catName) + '</td><td>' + fmtScore(cat && cat.P) + '</td><td>' + fmtScore(cat && cat.S) + '</td><td>' + lvHtml(ReportUI.internalLevel(cat && cat.S)) + '</td></tr>';
}
h += dSec(r.sub_scores ? '七项独立评分' : '历史六分类评分(肤色 / 水分共用一项)', '<table class="d-table"><thead><tr><th>分类</th><th>P</th><th>S</th><th>档位</th></tr></thead><tbody>' + catRows + '</tbody></table>');
/* 3 维度表格 */
var dimRows = '';
for (var dk in DIM_NAMES) {
var dim = r.dims && r.dims[dk];
dimRows += '<tr><td>' + DIM_NAMES[dk] + '</td><td>' + fmtScore(dim && dim.P) + '</td><td>' + fmtScore(dim && dim.S) + '</td><td>' + lvHtml(ReportUI.internalLevel(dim && dim.S)) + '</td></tr>';
}
h += dSec('三维度评分', '<table class="d-table"><thead><tr><th>维度</th><th>P</th><th>S</th><th>档位</th></tr></thead><tbody>' + dimRows + '</tbody></table>');
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 += '<tr><td>' + esc(t.name) + '</td><td>' + esc(fmtScore(v.front)) + '</td><td>' + esc(fmtScore(v.left)) + '</td><td>' + esc(fmtScore(v.right)) + '</td><td>' + esc(fmtScore(t.count)) + '</td><td>' + esc(fmtScore(t.weight)) + '</td><td>' + esc(fmtScore(t.deduction)) + '</td></tr>';
});
h += dSec('痘痘三视图计分明细', '<p class="sub">每种皮损分别取三视图最大数量,不相加,也不是全脸去重总数;“-”表示未获取。整体评分仍用正脸:' + esc(fmtScore(audit.front_score)) + ',映射分:' + esc(fmtScore(audit.mapped_score)) + '。计数扣分合计:' + esc(fmtScore(audit.raw_deduction)) + ',封顶50后:' + esc(fmtScore(audit.deduction)) + '。</p><table class="d-table"><thead><tr><th>类型</th><th>正脸</th><th>左脸</th><th>右脸</th><th>计分数量</th><th>单处扣分</th><th>该项扣分</th></tr></thead><tbody>' + acneRows + '</tbody></table>');
}
if (r.wrinkle_view_status) {
h += dSec('侧脸皱纹标注获取状态', '<p class="sub">左右侧脸各自独立调用,仅用于图层展示,不参与计分;查看报告不重新调用。</p>' +
['left','right'].map(function(view) { var s=r.wrinkle_view_status[view]||{}; return '<p>'+ (view==='left'?'左脸':'右脸') +':'+esc(s.status==='ready'?'已获取':'未获取')+(s.error_code?'('+esc(s.error_code)+')':'')+'</p>'; }).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 += '<div class="d-copy"><b>' + esc(view.name) + ' / ' + esc(view.level || '未获取') + ':</b>' + esc(view.text) + '</div>';
}
} 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 += '<div class="d-copy"><b>' + esc((r.cats[CAT_ORDER[c2]] || {}).name || '') + ':</b>' + esc(txt) + '</div>';
}
}
if (r.copy_summary) copyHtml += '<div class="d-copy"><b>旧版算法摘要(原始留档,不用于当前文案分档):</b>' + esc(r.copy_summary) + '</div>';
if (r.lifestyle) copyHtml += '<div class="d-copy"><b>旧版生活方式(原始留档):</b>' + esc(r.lifestyle) + '</div>';
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 = '<th>分区</th>', wrinkleBody = '';
metricDefs.forEach(function(metric) { wrinkleHead += '<th>' + metric[1] + '</th>'; });
wrinkleRows.forEach(function(row) {
wrinkleBody += '<tr><td>' + esc(row.name) + '</td>';
metricDefs.forEach(function(metric) {
var value = row.metrics[metric[0]];
wrinkleBody += '<td>' + (value == null ? '未获取' : esc(String(value))) + '</td>';
});
wrinkleBody += '</tr>';
});
h += dSec('皱纹分区原始指标(仅后台复核;左右以本人为准)', '<div style="overflow-x:auto"><table class="d-table"><thead><tr>' +
wrinkleHead + '</tr></thead><tbody>' + wrinkleBody + '</tbody></table></div>');
}
var care = presentation ? presentation.care : r.careAdvice;
if (care && care.length) {
var advice = '';
for (var a = 0; a < care.length; a++) {
advice += '<div class="d-copy"><b>[' + esc(care[a].name || care[a].tag) + ']</b>' + esc(care[a].text) + '</div>';
}
h += dSec('护理建议', advice);
}
var product = presentation && presentation.product;
h += dSec('推荐精华', '<div class="d-copy"><b>' + esc(productName(r.essence && r.essence.name)) + '</b><br>' +
esc(product ? product.name + '双仓精华液' : (r.essence && r.essence.text) || '') + '</div>' +
'<div class="d-kv"><span>规则版本 <b>' + esc(ruleLabel(r)) + '</b></span>' +
'<span>触发判据 <b>' + esc(r.trigger || '-') + '</b></span></div>');
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('推荐依据', '<div class="d-kv"><span>' + (rec.ageBasis === 'display' ? '使用展示肌龄' : '使用原始肌龄') + ' <b>' + ageText(rec.skinAge) + '</b></span>' +
'<span>基础档 <b>' + esc(tierText(rec.baseTier)) + '</b></span>' +
'<span>最终档 <b>' + esc(tierText(rec.tier)) + '</b></span></div>' +
'<div class="d-copy">' + esc(rec.reason) + '</div><div class="sub">判定分(未舍入S,不使用前端一位小数):' + esc(scoreParts.join(';')) + '</div>');
}
if (r.notes && r.notes.length) {
var notes = '<ul class="d-notes">';
for (var n2 = 0; n2 < r.notes.length; n2++) notes += '<li>' + esc(r.notes[n2]) + '</li>';
notes += '</ul>';
h += dSec('计算备注', notes);
}
}
/* 接口原始 JSON:details 折叠,pre 展示(face_maps 已在服务端裁剪,完整版走下载链接) */
if (data.raw_json) {
var rawFile = data.raw_file || (window.JuziEntry && window.JuziEntry.mode === 'rednote' ? r.raw_file : null);
var rawDl = rawFile
? ' <a href="' + adminURL('/api/admin/records/' + encodeURIComponent(r.record_id) + '/raw') + '" download>下载完整原始 JSON</a>'
: '';
h += dSec('接口原始 JSON', '<details><summary>展开/收起(' + (rawFile || '') + ')' + rawDl + '</summary><pre>' +
esc(JSON.stringify(data.raw_json, null, 1)) + '</pre></details>');
} else {
h += dSec('接口原始 JSON', '<div class="sub">无(失败记录或未落盘)</div>');
}
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 = adminURL('/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();