/* ═══════════════════════════════════════════════════════════
巨子测肤 · 后台查看页脚本(纯原生 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 iso.replace('T', ' ').slice(0, 19);
}
function fmtMs(ms) { return ms == null ? '-' : ms + 'ms'; }
function fmtScore(v) { return v == null ? '-' : v; }
var LV_CLASS = { '预警': 'lv-bad', '改善': 'lv-warn', '良好': 'lv-good' };
var DIM_NAMES = { firm: '紧塑力', vita: '活力感', stab: '稳定力' };
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(level) + ' ';
}
function dimsHtml(dims) {
if (!dims) return '- ';
var h = '';
for (var k in DIM_NAMES) {
var d = dims[k];
h += '' +
DIM_NAMES[k].slice(0, 1) + (d ? esc(d.level) : '-') + ' ';
}
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(' / '));
}
/* ═══════════ 记录列表 ═══════════ */
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) + ' ' +
'' + esc(it.upload_status) + ' ' +
'' + esc(it.detect_status) + ' ' +
(it.fail_code ? '' + esc(it.fail_code) + '
' : '') + ' ' +
'' + fmtScore(it.total_score) + ' ' +
'' + dimsHtml(it.dims) + ' ' +
'' + esc(it.essence || '-') + ' ' +
'' + 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 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) {
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 += '' +
'' + 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) + ' ' +
'
');
/* 档案 */
h += dSec('档案信息', '' +
'年龄 ' + (r.real_age != null ? 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('综合', '' +
'总分 ' + fmtScore(r.total_score) + ' ' +
'肌肤年龄(接口原始) ' + fmtScore(r.skin_age) + '岁 ' +
'六分类均分S ' + fmtScore(r.avg_s) + ' ' +
'左下颌线角 ' + (r.jawline_angle_l != null ? r.jawline_angle_l + '°' : '-') + ' ' +
'右下颌线角 ' + (r.jawline_angle_r != null ? r.jawline_angle_r + '°' : '-') + ' ' +
'
');
/* 6 分类表格 */
var catRows = '';
for (var i = 0; i < CAT_ORDER.length; i++) {
var ck = CAT_ORDER[i], cat = r.cats && r.cats[ck];
var catName = (cat && cat.name) || ({ contour: '轮廓', wrinkles: '皱纹', smooth: '光滑', tone: '肤色水润', sens: '敏感', acne: '痘痘' })[ck];
catRows += '' + esc(catName) + ' ' + fmtScore(cat && cat.P) + ' ' + fmtScore(cat && cat.S) + ' ' + lvHtml(cat && cat.level) + ' ';
}
h += dSec('六分类评分', '');
/* 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(dim && dim.level) + ' ';
}
h += dSec('三维度评分', '');
/* 文案全文 */
var copyHtml = '';
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('生成文案', copyHtml);
if (r.careAdvice && r.careAdvice.length) {
var advice = '';
for (var a = 0; a < r.careAdvice.length; a++) {
advice += '[' + esc(r.careAdvice[a].tag) + '] ' + esc(r.careAdvice[a].text) + '
';
}
h += dSec('护理建议', advice);
}
h += dSec('推荐精华', '' + esc((r.essence && r.essence.name) || '-') + ' ' +
esc((r.essence && r.essence.text) || '') + '
' +
'触发判据 ' + esc(r.trigger || '-') + '
');
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();