597 lines
30 KiB
JavaScript
597 lines
30 KiB
JavaScript
/* ═══════════════════════════════════════════════════════════
|
||
巨子测肤 · 后台查看页脚本(纯原生 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, '>').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 '<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 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) {
|
||
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 ? '透亮感取独立肤色、水分、平滑度三项最低分,不再使用肤色水润平均分。' : '历史版本与水分项各占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>');
|
||
}
|
||
|
||
/* 文案全文 */
|
||
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>使用原始肌龄 <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 rawDl = data.raw_file
|
||
? ' <a href="/api/admin/records/' + encodeURIComponent(r.record_id) + '/raw" download>下载完整原始 JSON</a>'
|
||
: '';
|
||
h += dSec('接口原始 JSON', '<details><summary>展开/收起(' + (data.raw_file || '') + ')' + 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 = '/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();
|