/* ═══════════════════════════════════════════════════════════ 巨子测肤 · 后台查看页脚本(纯原生 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 = 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]; h += '
' + DIM_NAMES[k] + ' ' + (d && typeof d.S === 'number' ? d.S.toFixed(1) : '-') + ' ' + (d ? esc(ReportUI.levelMap[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(' / ')); } function ageText(age) { return age == null ? '-' : esc(age) + '岁'; } function skinAgeHtml(r) { return '原始 ' + ageText(r.skin_age) + '
展示 ' + ageText(r.skin_age_display) + '
'; } 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) + '' + '' + esc(it.upload_status) + '' + '' + esc(it.detect_status) + '' + (it.fail_code ? '
' + esc(it.fail_code) + '
' : '') + '' + '' + fmtScore(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 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 += '
' + '' + 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 += '
' + whichNames[w] + '' + '下载' + whichNames[w] + '
'; } 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('档案信息', '
' + '实际年龄 ' + 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 += '
上一版本(本次为重试):' + esc(data.versions.parent.record_id) + '(' + esc(data.versions.parent.detect_status) + ')
'; } if (data.versions && data.versions.children && data.versions.children.length) { vHtml += '
重试产生的记录:'; for (var c = 0; c < data.versions.children.length; c++) { vHtml += '' + esc(data.versions.children[c].record_id) + '(' + esc(data.versions.children[c].detect_status) + ')'; } vHtml += '
'; } if (vHtml) h += dSec('版本链', '
' + vHtml + '
'); /* 成功:评分 + 文案 */ if (r.detect_status === '成功') { h += dSec('综合', '
' + '总分 ' + fmtScore(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 += '

前端展示肌龄沿用实际年龄±3岁的修正;新推荐只使用原始肌龄。历史报告保留当时的推荐。

'; /* 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 = ({ contour: '轮廓', wrinkles: '皱纹', smooth: '平滑度', tone: '肤色 / 水分', sens: '泛红', acne: '痘痘' })[ck]; catRows += '' + esc(catName) + '' + fmtScore(cat && cat.P) + '' + fmtScore(cat && cat.S) + '' + lvHtml(cat && cat.level) + ''; } h += dSec('原始六分类评分(肤色 / 水分共用一项)', '' + catRows + '
分类PS档位
'); /* 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('三维度评分', '' + dimRows + '
维度PS档位
'); /* 文案全文 */ 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 ? '前端报告结论(保存快照)与计算摘要' : '旧版生成文案(无前端快照)', copyHtml); 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('推荐依据', '
使用原始肌龄 ' + 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 = ''; 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();