save
This commit is contained in:
+51
-4
@@ -10,10 +10,18 @@
|
||||
```
|
||||
h5/
|
||||
├── index.html 页面结构(启动 / 档案 / 拍照 / 报告 + 弹层 + 内联 SVG 取景蒙版)
|
||||
├── server.js 后端:静态托管 + POST /api/skin-analyze(Face++ skinanalyze_pro 三视图中转)
|
||||
├── css/style.css 全部样式(375px 基准)
|
||||
├── admin.html 后台查看页(记录列表 / 失败与异常 / 统计概览,零依赖,见下文「后台查看」)
|
||||
├── server.js 后端:静态托管 + POST /api/skin-analyze(Face++ skinanalyze_pro 三视图中转)+ /api/admin/*(记录查询/重试/统计/导出)
|
||||
├── css/style.css C 端全部样式(375px 基准)
|
||||
├── css/admin.css 后台查看页样式
|
||||
├── js/app.js 全部交互(屏幕切换、肤质轮播、引导式三连拍、报告渲染)
|
||||
├── js/skin-score.js 三维度×6分类评分引擎(纯逻辑 UMD,window.SkinScore,可 node 自测)
|
||||
├── js/admin.js 后台查看页脚本(纯原生 ES5)
|
||||
├── js/skin-score.js 三维度×6分类评分引擎(纯逻辑 UMD,window.SkinScore,可 node 自测;服务端评分复用同一份)
|
||||
├── js/head-pose.js 头部姿态估算(纯逻辑 UMD,window.HeadPose,可 node 自测)
|
||||
├── data/records.jsonl 检测记录库(一行一条 JSON,零依赖嵌入式存储,已 .gitignore)
|
||||
├── data/photos/ 检测照片 data/photos/<record_id>/{front,left,right}.jpg(本地磁盘代替对象存储)
|
||||
├── data/seed.js 演示种子数据(一次性:node h5/data/seed.js,覆盖重写 records.jsonl)
|
||||
├── data/results/ 每次检测的 Face++ 原始返回落盘(复核用,已 .gitignore)
|
||||
├── js/head-pose.js 头部姿态估算(纯逻辑 UMD,window.HeadPose,可 node 自测)
|
||||
├── test/regression.js 真值回归(node h5/test/regression.js,对照 docs xlsx 两组三视图真值)
|
||||
├── vendor/faceapi/ face-api.js 0.22.2 + 模型(本地化,见该目录 README)
|
||||
@@ -68,7 +76,7 @@ node server.js # 默认 8080,可用 PORT=xxx 修改
|
||||
|
||||
## 检测流程与评分渲染
|
||||
|
||||
1. 三视图送检:POST `/api/skin-analyze`,请求 `{ front_image, left_image, right_image }`(base64 可带 dataURL 前缀;正脸=photos[0],左=photos[2],右=photos[1])。
|
||||
1. 三视图送检:POST `/api/skin-analyze`,请求 `{ front_image, left_image, right_image, profile? }`(base64 可带 dataURL 前缀;正脸=photos[0],左=photos[2],右=photos[1];`profile` 为可选档案 `{real_age,gender,region,daily_skin_type}`,后台记录用)。
|
||||
2. 服务端转发 Face++ **专业版 `skinanalyze_pro`**(三视图必须走 `image_file` 文件形式),请求 `return_side_results=jawline_info` 取左右下颌线角度,`return_maps`/`return_marks` 取叠加图与标注;响应 `{ ok:true, result, face_rectangle, side:{ left, right } }`,失败 `{ ok:false, code, message }`(message 已是用户可读文案)。`result.face_maps` 为对象,含报告页所需图层的完整 data URL(`red_area`/`brown_area`/`texture_enhanced_lines`/`texture_enhanced_pores`/`texture_enhanced_blackheads`,由 server.js 的 `REPORT_MAPS` 常量控制);`marks` 坐标(`acne`/`brown_spot`/`mole`/`closed_comedones`/`acne_pustule`/`acne_nodule`/`wrinkle_mark`/`melanin_mark`/`dark_circle_mark`)随 `result` 原样透传,与上传图片同一像素空间。
|
||||
3. 评分由 `js/skin-score.js` 引擎完成,公式/锁值/话术/产品推荐以 `docs/三维度评分与话术生成需求文档_V2.3.docx` 为最高契约(V2.3 口径,含 §8.5 护理建议五段):
|
||||
- 6 分类:轮廓 = min(眼袋映射分, 下颌线角度分);皱纹(12 分区);光滑(毛孔/黑头/纹理);肤色水润(色素×水润);敏感(泛红面积取 `red_spot.red_spot_area`/红区分);痘痘(分级映射×丘疹 `acne.count` 计数扣分);分组按实际年龄;
|
||||
@@ -86,6 +94,27 @@ node server.js # 默认 8080,可用 PORT=xxx 修改
|
||||
8. **原始返回落盘**:服务端每次检测成功后把 Face++ 完整原始返回写入 `h5/data/results/<时间戳>-<request_id>.json`(复核/真值用,已 .gitignore;写盘失败不影响响应)。
|
||||
9. 检测失败按服务端 message 提示并退回档案页。
|
||||
|
||||
## 后台查看(admin.html)
|
||||
|
||||
本地查看版后台,浏览器打开 **`http://127.0.0.1:8080/admin.html`**(无登录鉴权,仅内网/本地使用;上线前需加网关或鉴权)。请求体除三张照片外还带可选档案 `profile:{real_age,gender,region,daily_skin_type}`(前端 `js/app.js` 自动附带,年龄非法按 null),服务端据此落库并在检测成功时用 `SkinScore.buildViewModel` 现场评分(与 C 端同一份公式)。
|
||||
|
||||
- **数据落库**:每次有效调用(正脸照片校验通过起)生成 `record_id`,照片写 `data/photos/<record_id>/`,记录(档案/照片地址/拍摄与上传状态/检测状态与失败原因/评分/文案/精华/耗时/raw 文件名/重试关系)追加到 `data/records.jsonl`。C 端响应结构不变。
|
||||
- **记录列表**:多条件筛选(时间区间/检测状态/失败原因/年龄/产品组别/维度+档位/记录ID),行点击开详情抽屉(照片大图与下载、状态、六分类与三维度评分表、文案全文、护理建议、精华与触发判据、计算备注、接口原始 JSON 折叠查看、手动重试)。
|
||||
- **失败与异常**:失败原因分布条 + 失败列表(每条可一键重试:用原照片重新检测,生成新记录并挂版本链)。
|
||||
- **统计概览**:今日/累计/成功率/平均耗时卡片,三维度档位分布,产品组别分布(纯 CSS 饼图),近 14 天每日检测量柱状图,按日期区间导出 CSV(UTF-8 BOM,不含 raw_json)。
|
||||
- **演示数据**:`node h5/data/seed.js` 生成约 10 条演示记录(覆盖重写 records.jsonl,照片复制自 `reference/facepp-skinanalyze/examples/images/` 真实三视图)。
|
||||
|
||||
### Admin API(统一返回 `{ok:false,message}` 错误)
|
||||
|
||||
| 接口 | 说明 |
|
||||
| --- | --- |
|
||||
| `GET /api/admin/records` | 分页查询。参数:`from/to`(ISO 日期)、`detect_status`、`fail_code`、`age_min/age_max`、`group`、`dim`(firm/vita/stab)+`dim_level`、`q`(record_id 前缀)、`page`、`page_size`(默认 20)、`only_fail=1`。返回 `{total,page,page_size,items}`(倒序,含照片 URL,不含 raw_json 与文案全文) |
|
||||
| `GET /api/admin/records/:id` | 完整记录 + `raw_json` 全文(读失败为 null)+ `photos` URL + `versions`(版本链:父记录与重试子记录) |
|
||||
| `POST /api/admin/records/:id/retry` | 手动重试:原照片重新检测,生成新记录(`retry_of`=原 id),原记录 `retry_count+1`,返回新记录 |
|
||||
| `GET /api/admin/photos/:id/:which` | 照片回显(`which`=front/left/right),`image/jpeg` inline;文件不存在 404 |
|
||||
| `GET /api/admin/stats` | 今日/累计检测量、成功率、平均耗时、失败原因分布、三维度档位分布、产品组别分布、近 14 天每日检测量/成功数 |
|
||||
| `GET /api/admin/export?from=&to=` | CSV 导出(UTF-8 BOM,Content-Disposition attachment 带日期区间文件名);禁止 raw_json 进单元格(Excel 单格 32767 字符截断) |
|
||||
|
||||
## 部署
|
||||
|
||||
需要 Node ≥ 18 运行 `server.js`(负责静态与 API 中转),**不能用纯静态托管**(密钥不能进前端)。
|
||||
@@ -94,6 +123,24 @@ node server.js # 默认 8080,可用 PORT=xxx 修改
|
||||
PORT=8080 node server.js # 建议 pm2 / systemd 常驻
|
||||
```
|
||||
|
||||
### 线上部署(juzi.xiangsilian.com)
|
||||
|
||||
- Node 只绑回环(`HOST` 默认 `127.0.0.1`,公网入口仅 nginx;需局域网调试才 `HOST=0.0.0.0`),nginx 反代 + certbot HTTPS,见 `/etc/nginx/sites-available/juzi`。
|
||||
- **后台 Basic Auth**:nginx 对 `admin.html`、`css/admin.css`、`js/admin.js`、`/api/admin/` 做 `auth_basic`(账密 `/etc/nginx/.htpasswd-juzi`),`/data/`(照片/原始 JSON/记录库)由 nginx 404 + server.js 静态层 403 双重封锁,照片只能走鉴权后的 `/api/admin/photos`。**上线正式版前应替换为文档要求的后台账号体系。**
|
||||
- 服务常驻建议 systemd:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=juzi skin h5
|
||||
After=network.target
|
||||
[Service]
|
||||
WorkingDirectory=/home/ubuntu/juzi/h5
|
||||
ExecStart=/usr/bin/node server.js
|
||||
Restart=always
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
### Windows 本机部署(提测用)
|
||||
|
||||
```bat
|
||||
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>巨子测肤 · 后台查看</title>
|
||||
<link rel="stylesheet" href="css/admin.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brand">巨子测肤<span>后台查看</span></div>
|
||||
<nav class="tabs">
|
||||
<button type="button" class="tab on" data-tab="records">记录列表</button>
|
||||
<button type="button" class="tab" data-tab="fails">失败与异常</button>
|
||||
<button type="button" class="tab" data-tab="stats">统计概览</button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<!-- ─── 记录列表(文档 4.1/4.2) ─── -->
|
||||
<section id="tab-records" class="panel on">
|
||||
<div class="filters">
|
||||
<label>创建时间 <input type="date" id="f-from"> ~ <input type="date" id="f-to"></label>
|
||||
<label>检测状态
|
||||
<select id="f-status">
|
||||
<option value="">全部</option>
|
||||
<option>待检测</option><option>检测中</option><option>成功</option><option>失败</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>失败原因
|
||||
<select id="f-failcode">
|
||||
<option value="">全部</option>
|
||||
<option>NO_FACE_FOUND</option>
|
||||
<option>INVALID_IMAGE_FACE</option>
|
||||
<option>INVALID_FACE_SIZE</option>
|
||||
<option>INVALID_IMAGE_SIZE</option>
|
||||
<option>IMAGE_FILE_TOO_LARGE</option>
|
||||
<option>IMAGE_ERROR_UNSUPPORTED_FORMAT</option>
|
||||
<option>AUTHENTICATION_ERROR</option>
|
||||
<option>AUTHORIZATION_ERROR</option>
|
||||
<option>CONCURRENCY_LIMIT_EXCEEDED</option>
|
||||
<option>INTERNAL_ERROR</option>
|
||||
<option>TIMEOUT</option>
|
||||
<option>NETWORK_ERROR</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>年龄 <input type="number" id="f-agemin" min="1" max="120" placeholder="最小"> ~ <input type="number" id="f-agemax" min="1" max="120" placeholder="最大"></label>
|
||||
<label>产品组别
|
||||
<select id="f-group">
|
||||
<option value="">全部</option>
|
||||
<option>25+组</option><option>35+组</option><option>50+组</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>维度
|
||||
<select id="f-dim">
|
||||
<option value="">不限</option>
|
||||
<option value="firm">紧塑力</option>
|
||||
<option value="vita">活力感</option>
|
||||
<option value="stab">稳定力</option>
|
||||
</select>
|
||||
<select id="f-dimlevel">
|
||||
<option value="">不限档位</option>
|
||||
<option>预警</option><option>改善</option><option>良好</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>记录ID <input type="text" id="f-q" placeholder="精确/前缀匹配"></label>
|
||||
<button type="button" class="btn primary" id="btn-search">查询</button>
|
||||
<button type="button" class="btn" id="btn-reset">重置</button>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table id="tbl-records">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>照片</th><th>记录ID</th><th>年龄/性别/地区/肤质</th><th>拍摄状态</th><th>上传状态</th>
|
||||
<th>检测状态</th><th>总分</th><th>三维度档位</th><th>推荐精华</th><th>耗时</th><th>创建时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pager">
|
||||
<button type="button" class="btn" id="btn-prev">上一页</button>
|
||||
<span id="page-info">-</span>
|
||||
<button type="button" class="btn" id="btn-next">下一页</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ─── 失败与异常(文档 4.4) ─── -->
|
||||
<section id="tab-fails" class="panel">
|
||||
<div id="fail-dist" class="fail-dist"></div>
|
||||
<div class="table-wrap">
|
||||
<table id="tbl-fails">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>记录ID</th><th>失败环节</th><th>失败码</th><th>失败信息</th><th>年龄</th><th>拍摄状态</th><th>创建时间</th><th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ─── 统计概览(文档 4.5) ─── -->
|
||||
<section id="tab-stats" class="panel">
|
||||
<div class="cards" id="stat-cards"></div>
|
||||
<div class="grid2">
|
||||
<div class="block">
|
||||
<h3>三维度档位分布</h3>
|
||||
<div id="dim-dist"></div>
|
||||
</div>
|
||||
<div class="block">
|
||||
<h3>产品组别分布</h3>
|
||||
<div id="group-dist" class="pie-wrap"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="block">
|
||||
<h3>近 14 天每日检测量</h3>
|
||||
<div id="daily-chart" class="daily-chart"></div>
|
||||
</div>
|
||||
<div class="block">
|
||||
<h3>导出 CSV</h3>
|
||||
<div class="export-bar">
|
||||
<label>起始日期 <input type="date" id="exp-from"></label>
|
||||
<label>结束日期 <input type="date" id="exp-to"></label>
|
||||
<button type="button" class="btn primary" id="btn-export">导出 CSV</button>
|
||||
<span class="hint">UTF-8 BOM,Excel 直接打开;不含接口原始 JSON</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- ─── 记录详情(抽屉) ─── -->
|
||||
<div id="drawer-mask" class="drawer-mask hidden"></div>
|
||||
<aside id="drawer" class="drawer hidden">
|
||||
<div class="drawer-head">
|
||||
<h2 id="d-title">记录详情</h2>
|
||||
<div>
|
||||
<button type="button" class="btn primary" id="btn-retry">重试</button>
|
||||
<button type="button" class="btn" id="btn-close">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="drawer-body" id="d-body"></div>
|
||||
</aside>
|
||||
|
||||
<!-- 图片查看 -->
|
||||
<div id="lightbox" class="lightbox hidden">
|
||||
<img id="lightbox-img" alt="">
|
||||
</div>
|
||||
|
||||
<div id="toast" class="toast hidden"></div>
|
||||
|
||||
<script src="js/admin.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,170 @@
|
||||
/* 巨子测肤 · 后台查看页样式(零依赖) */
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
background: #f3f5f8; color: #2b3445; font-size: 14px;
|
||||
}
|
||||
.hidden { display: none !important; }
|
||||
|
||||
/* ─── 顶栏 ─── */
|
||||
.topbar {
|
||||
display: flex; align-items: center; gap: 32px;
|
||||
background: #fff; border-bottom: 1px solid #e4e8ef;
|
||||
padding: 0 24px; height: 56px; position: sticky; top: 0; z-index: 20;
|
||||
}
|
||||
.brand { font-size: 17px; font-weight: 700; color: #1f2733; }
|
||||
.brand span { font-weight: 400; color: #8a94a6; margin-left: 8px; font-size: 13px; }
|
||||
.tabs { display: flex; gap: 4px; height: 56px; }
|
||||
.tab {
|
||||
border: none; background: none; cursor: pointer; padding: 0 18px;
|
||||
font-size: 14px; color: #5a6578; border-bottom: 2px solid transparent;
|
||||
}
|
||||
.tab:hover { color: #2563eb; }
|
||||
.tab.on { color: #2563eb; border-bottom-color: #2563eb; font-weight: 600; }
|
||||
|
||||
main { padding: 20px 24px; }
|
||||
.panel { display: none; }
|
||||
.panel.on { display: block; }
|
||||
|
||||
/* ─── 筛选栏 ─── */
|
||||
.filters {
|
||||
background: #fff; border: 1px solid #e4e8ef; border-radius: 8px;
|
||||
padding: 12px 16px; margin-bottom: 16px;
|
||||
display: flex; flex-wrap: wrap; gap: 10px 16px; align-items: center;
|
||||
}
|
||||
.filters label { color: #5a6578; font-size: 13px; display: inline-flex; align-items: center; gap: 6px; }
|
||||
input, select {
|
||||
border: 1px solid #d4dae3; border-radius: 6px; padding: 6px 8px; font-size: 13px; color: #2b3445;
|
||||
background: #fff; outline: none;
|
||||
}
|
||||
input:focus, select:focus { border-color: #2563eb; }
|
||||
input[type="number"] { width: 64px; }
|
||||
#f-q { width: 170px; }
|
||||
|
||||
.btn {
|
||||
border: 1px solid #d4dae3; background: #fff; border-radius: 6px;
|
||||
padding: 6px 14px; font-size: 13px; cursor: pointer; color: #2b3445;
|
||||
}
|
||||
.btn:hover { border-color: #2563eb; color: #2563eb; }
|
||||
.btn.primary { background: #2563eb; border-color: #2563eb; color: #fff; }
|
||||
.btn.primary:hover { background: #1d4fd7; color: #fff; }
|
||||
.btn.small { padding: 3px 10px; font-size: 12px; }
|
||||
|
||||
/* ─── 表格 ─── */
|
||||
.table-wrap { background: #fff; border: 1px solid #e4e8ef; border-radius: 8px; overflow: auto; }
|
||||
table { width: 100%; border-collapse: collapse; min-width: 1080px; }
|
||||
th, td { padding: 10px 12px; text-align: left; border-bottom: 1px solid #eef1f6; white-space: nowrap; }
|
||||
th { background: #f8fafc; color: #5a6578; font-weight: 600; font-size: 13px; }
|
||||
tbody tr { cursor: pointer; }
|
||||
tbody tr:hover { background: #f5f8ff; }
|
||||
tbody tr:last-child td { border-bottom: none; }
|
||||
.thumb { width: 44px; height: 44px; object-fit: cover; border-radius: 4px; border: 1px solid #e4e8ef; cursor: zoom-in; display: block; }
|
||||
.mono { font-family: ui-monospace, Consolas, monospace; font-size: 12px; color: #4a5568; }
|
||||
.sub { color: #8a94a6; font-size: 12px; }
|
||||
|
||||
/* 档位标签:预警=红 / 改善=橙黄 / 良好=绿 */
|
||||
.lv {
|
||||
display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 12px; margin: 1px 2px;
|
||||
}
|
||||
.lv-bad { background: #fdecec; color: #d93026; }
|
||||
.lv-warn { background: #fdf3e0; color: #d4880f; }
|
||||
.lv-good { background: #e6f6ec; color: #1e9e50; }
|
||||
.st { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 12px; }
|
||||
.st-ok { background: #e6f6ec; color: #1e9e50; }
|
||||
.st-fail { background: #fdecec; color: #d93026; }
|
||||
.st-ing { background: #e8f0fe; color: #2563eb; }
|
||||
.st-wait { background: #f0f2f5; color: #8a94a6; }
|
||||
|
||||
.pager { display: flex; align-items: center; gap: 12px; justify-content: flex-end; margin-top: 12px; color: #5a6578; font-size: 13px; }
|
||||
|
||||
/* ─── 失败分布条 ─── */
|
||||
.fail-dist { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 16px; }
|
||||
.fail-chip {
|
||||
background: #fff; border: 1px solid #e4e8ef; border-radius: 8px;
|
||||
padding: 10px 16px; display: flex; flex-direction: column; gap: 2px; min-width: 150px;
|
||||
}
|
||||
.fail-chip .code { font-weight: 600; color: #d93026; font-size: 13px; }
|
||||
.fail-chip .pct { color: #5a6578; font-size: 12px; }
|
||||
.fail-chip .bar { height: 6px; background: #fdecec; border-radius: 3px; margin-top: 4px; overflow: hidden; }
|
||||
.fail-chip .bar i { display: block; height: 100%; background: #d93026; }
|
||||
|
||||
/* ─── 统计卡片与图表 ─── */
|
||||
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 14px; margin-bottom: 16px; }
|
||||
.card { background: #fff; border: 1px solid #e4e8ef; border-radius: 8px; padding: 16px 18px; }
|
||||
.card .num { font-size: 26px; font-weight: 700; color: #1f2733; margin-top: 4px; }
|
||||
.card .lab { color: #8a94a6; font-size: 13px; }
|
||||
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-bottom: 14px; }
|
||||
@media (max-width: 900px) { .grid2 { grid-template-columns: 1fr; } }
|
||||
.block { background: #fff; border: 1px solid #e4e8ef; border-radius: 8px; padding: 16px 18px; margin-bottom: 14px; }
|
||||
.block h3 { font-size: 14px; color: #1f2733; margin-bottom: 14px; }
|
||||
|
||||
.dim-row { margin-bottom: 12px; }
|
||||
.dim-row .dim-name { font-weight: 600; margin-bottom: 4px; }
|
||||
.dim-bar { display: flex; height: 18px; border-radius: 4px; overflow: hidden; gap: 2px; }
|
||||
.dim-bar i { display: block; height: 100%; }
|
||||
.dim-bar .b-bad { background: #d93026; }
|
||||
.dim-bar .b-warn { background: #f0a020; }
|
||||
.dim-bar .b-good { background: #2fa84f; }
|
||||
.dim-legend { color: #8a94a6; font-size: 12px; margin-top: 4px; }
|
||||
|
||||
.pie-wrap { display: flex; align-items: center; gap: 24px; }
|
||||
.pie { width: 140px; height: 140px; border-radius: 50%; flex: none; }
|
||||
.pie-legend { font-size: 13px; color: #5a6578; }
|
||||
.pie-legend i { display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 6px; }
|
||||
.pie-empty { color: #8a94a6; }
|
||||
|
||||
.daily-chart { display: flex; align-items: flex-end; gap: 6px; height: 160px; padding-top: 8px; }
|
||||
.daily-col { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 4px; height: 100%; justify-content: flex-end; }
|
||||
.daily-bar { width: 70%; background: #2563eb; border-radius: 3px 3px 0 0; position: relative; min-height: 2px; }
|
||||
.daily-bar.ok { background: #2fa84f; position: absolute; bottom: 0; left: 15%; opacity: .55; }
|
||||
.daily-val { font-size: 11px; color: #5a6578; }
|
||||
.daily-date { font-size: 11px; color: #8a94a6; transform: rotate(-38deg); white-space: nowrap; }
|
||||
|
||||
.export-bar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||
.hint { color: #8a94a6; font-size: 12px; }
|
||||
|
||||
/* ─── 详情抽屉 ─── */
|
||||
.drawer-mask { position: fixed; inset: 0; background: rgba(20, 28, 45, .45); z-index: 30; }
|
||||
.drawer {
|
||||
position: fixed; top: 0; right: 0; bottom: 0; width: min(860px, 94vw);
|
||||
background: #fff; z-index: 31; box-shadow: -6px 0 24px rgba(20, 28, 45, .18);
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
.drawer-head {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 14px 20px; border-bottom: 1px solid #e4e8ef; flex: none;
|
||||
}
|
||||
.drawer-head h2 { font-size: 16px; }
|
||||
.drawer-head > div { display: flex; gap: 8px; }
|
||||
.drawer-body { overflow: auto; padding: 18px 20px 40px; }
|
||||
.d-sec { margin-bottom: 20px; }
|
||||
.d-sec h3 { font-size: 14px; color: #1f2733; margin-bottom: 10px; border-left: 3px solid #2563eb; padding-left: 8px; }
|
||||
.d-photos { display: flex; gap: 14px; flex-wrap: wrap; }
|
||||
.d-photo { text-align: center; }
|
||||
.d-photo img { width: 150px; height: 150px; object-fit: cover; border-radius: 6px; border: 1px solid #e4e8ef; cursor: zoom-in; display: block; }
|
||||
.d-photo a { font-size: 12px; color: #2563eb; text-decoration: none; }
|
||||
.d-kv { display: flex; flex-wrap: wrap; gap: 6px 22px; color: #4a5568; font-size: 13px; }
|
||||
.d-kv b { color: #1f2733; }
|
||||
.d-table { width: 100%; min-width: 0; border: 1px solid #e4e8ef; border-radius: 6px; overflow: hidden; }
|
||||
.d-table th, .d-table td { white-space: normal; }
|
||||
.d-copy { background: #f8fafc; border: 1px solid #e4e8ef; border-radius: 6px; padding: 10px 12px; margin-bottom: 8px; font-size: 13px; line-height: 1.7; color: #4a5568; }
|
||||
.d-copy b { color: #1f2733; }
|
||||
.d-notes li { color: #8a6d1a; font-size: 13px; margin: 4px 0 4px 18px; }
|
||||
.d-versions a { color: #2563eb; cursor: pointer; text-decoration: none; margin-right: 14px; font-size: 13px; }
|
||||
details pre {
|
||||
background: #1f2733; color: #d7e1f0; border-radius: 6px; padding: 12px;
|
||||
font-size: 12px; overflow: auto; max-height: 420px; white-space: pre-wrap; word-break: break-all;
|
||||
}
|
||||
summary { cursor: pointer; color: #2563eb; font-size: 13px; margin-bottom: 8px; }
|
||||
|
||||
/* ─── 图片查看 / 提示 ─── */
|
||||
.lightbox {
|
||||
position: fixed; inset: 0; background: rgba(10, 14, 22, .85); z-index: 40;
|
||||
display: flex; align-items: center; justify-content: center; cursor: zoom-out;
|
||||
}
|
||||
.lightbox img { max-width: 92vw; max-height: 92vh; border-radius: 6px; }
|
||||
.toast {
|
||||
position: fixed; top: 70px; left: 50%; transform: translateX(-50%);
|
||||
background: #1f2733; color: #fff; padding: 9px 20px; border-radius: 6px;
|
||||
font-size: 13px; z-index: 50;
|
||||
}
|
||||
+498
@@ -0,0 +1,498 @@
|
||||
/* ═══════════════════════════════════════════════════════════
|
||||
巨子测肤 · 后台查看页脚本(纯原生 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 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 '<span class="sub">-</span>';
|
||||
return '<span class="lv ' + (LV_CLASS[level] || '') + '">' + esc(level) + '</span>';
|
||||
}
|
||||
function dimsHtml(dims) {
|
||||
if (!dims) return '<span class="sub">-</span>';
|
||||
var h = '';
|
||||
for (var k in DIM_NAMES) {
|
||||
var d = dims[k];
|
||||
h += '<span class="lv ' + (d && LV_CLASS[d.level] || '') + '" title="' + DIM_NAMES[k] + '">' +
|
||||
DIM_NAMES[k].slice(0, 1) + (d ? esc(d.level) : '-') + '</span>';
|
||||
}
|
||||
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 = '<tr><td colspan="11" 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" 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) + '</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>' + fmtScore(it.total_score) + '</b></td>' +
|
||||
'<td>' + dimsHtml(it.dims) + '</td>' +
|
||||
'<td>' + esc(it.essence || '-') + '</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">' + 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 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>');
|
||||
|
||||
/* 档案 */
|
||||
h += dSec('档案信息', '<div class="d-kv">' +
|
||||
'<span>年龄 <b>' + (r.real_age != null ? 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>' + fmtScore(r.total_score) + '</b></span>' +
|
||||
'<span>肌肤年龄(接口原始) <b>' + fmtScore(r.skin_age) + '岁</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>');
|
||||
|
||||
/* 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 += '<tr><td>' + esc(catName) + '</td><td>' + fmtScore(cat && cat.P) + '</td><td>' + fmtScore(cat && cat.S) + '</td><td>' + lvHtml(cat && cat.level) + '</td></tr>';
|
||||
}
|
||||
h += dSec('六分类评分', '<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(dim && dim.level) + '</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>');
|
||||
|
||||
/* 文案全文 */
|
||||
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 += '<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('生成文案', copyHtml);
|
||||
|
||||
if (r.careAdvice && r.careAdvice.length) {
|
||||
var advice = '';
|
||||
for (var a = 0; a < r.careAdvice.length; a++) {
|
||||
advice += '<div class="d-copy"><b>[' + esc(r.careAdvice[a].tag) + ']</b>' + esc(r.careAdvice[a].text) + '</div>';
|
||||
}
|
||||
h += dSec('护理建议', advice);
|
||||
}
|
||||
|
||||
h += dSec('推荐精华', '<div class="d-copy"><b>' + esc((r.essence && r.essence.name) || '-') + '</b><br>' +
|
||||
esc((r.essence && r.essence.text) || '') + '</div>' +
|
||||
'<div class="d-kv"><span>触发判据 <b>' + esc(r.trigger || '-') + '</b></span></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 展示 */
|
||||
if (data.raw_json) {
|
||||
h += dSec('接口原始 JSON', '<details><summary>展开/收起(raw_json,' + (data.raw_file || '') + ')</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();
|
||||
+18
-9
@@ -708,8 +708,15 @@
|
||||
photos = photosByView(photos);
|
||||
openOverlay('p-analyzing');
|
||||
voice.play(VOICE.analyzing); // 「正在生成您的肌肤报告」
|
||||
// 档案与检测一起上报(后台记录用):年龄非法值服务端按 null 处理
|
||||
var profile = {
|
||||
age: +($('#f-age').value || 0) || null,
|
||||
gender: $('#f-gender').value || '',
|
||||
region: regionSel.value || '',
|
||||
skinType: SKINS[skinIndex].name
|
||||
};
|
||||
var t0 = Date.now();
|
||||
requestAnalyze(photos, seq, function (errMsg, payload) {
|
||||
requestAnalyze(photos, seq, profile, function (errMsg, payload) {
|
||||
if (seq !== camSeq) return; // 流程已被重置:丢弃
|
||||
var wait = Math.max(0, 1200 - (Date.now() - t0)); // 分析中至少展示 1.2s,避免闪烁
|
||||
setTimeout(function () {
|
||||
@@ -725,12 +732,6 @@
|
||||
return;
|
||||
}
|
||||
var vm = null;
|
||||
var profile = {
|
||||
age: +($('#f-age').value || 0) || null,
|
||||
gender: $('#f-gender').value || '',
|
||||
region: regionSel.value || '',
|
||||
skinType: SKINS[skinIndex].name
|
||||
};
|
||||
try {
|
||||
vm = SkinScore.buildViewModel(payload.result, payload.side, {
|
||||
realAge: profile.age,
|
||||
@@ -766,7 +767,7 @@
|
||||
|
||||
/* ─── 分析请求(mock=1 直返内置样本;有 MOCK_FIXTURE 时用报告页可视化 fixture,便于离线/自动化验证) ─── */
|
||||
var ANALYZE_TIMEOUT_MS = 45000; // 检测服务超时阈值:超时按「开小差」处理回档案页
|
||||
function requestAnalyze(ph, seq, done) {
|
||||
function requestAnalyze(ph, seq, profile, done) {
|
||||
if (IS_MOCK && typeof SkinScore !== 'undefined') {
|
||||
setTimeout(function () {
|
||||
if (seq !== camSeq) return; // mock 同样尊重取消/重置
|
||||
@@ -786,7 +787,15 @@
|
||||
fetch('/api/skin-analyze', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ front_image: ph[0], left_image: ph[1], right_image: ph[2] }),
|
||||
body: JSON.stringify({
|
||||
front_image: ph[0], left_image: ph[1], right_image: ph[2],
|
||||
profile: { // 档案随检测上报,后台记录用(服务端缺省 null)
|
||||
real_age: profile && profile.age,
|
||||
gender: profile && profile.gender,
|
||||
region: profile && profile.region,
|
||||
daily_skin_type: profile && profile.skinType
|
||||
}
|
||||
}),
|
||||
signal: ctrl ? ctrl.signal : undefined // 老内核无 AbortController:仅靠令牌丢弃迟到响应
|
||||
}).then(function (res) { return res.json(); })
|
||||
.then(function (data) {
|
||||
|
||||
+517
-19
@@ -4,6 +4,8 @@
|
||||
2) POST /api/skin-analyze:三视图(正/左/右)中转 Face++ 专业版测肤
|
||||
密钥只存在服务端(SKIN_API_KEY / SKIN_API_SECRET),
|
||||
前端永远接触不到 api_secret。
|
||||
3) 检测记录落库:data/records.jsonl(一行一条 JSON)+ data/photos/<record_id>/
|
||||
原始返回仍落盘 data/results/(复核用)。后台查看页 admin.html + /api/admin/*。
|
||||
启动: node server.js (默认 8080,可用 PORT 环境变量改)
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
'use strict';
|
||||
@@ -13,9 +15,17 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const PORT = Number(process.env.PORT) || 8080;
|
||||
// 只绑回环:公网入口是 nginx(juzi.xiangsilian.com),8080 不再对公网裸奔;需局域网调试时 HOST=0.0.0.0
|
||||
const HOST = process.env.HOST || '127.0.0.1';
|
||||
const ROOT = __dirname;
|
||||
const FACEPP_URL = 'https://api-cn.faceplusplus.com/facepp/v1/skinanalyze_pro';
|
||||
const DATA_DIR = path.join(ROOT, 'data', 'results'); // 每次检测的原始返回落盘(复核用,已在 .gitignore)
|
||||
const DATA_ROOT = path.join(ROOT, 'data');
|
||||
const DATA_DIR = path.join(DATA_ROOT, 'results'); // 每次检测的原始返回落盘(复核用,已在 .gitignore)
|
||||
const PHOTOS_DIR = path.join(DATA_ROOT, 'photos'); // 检测照片:data/photos/<record_id>/{front,left,right}.jpg
|
||||
const RECORDS_FILE = path.join(DATA_ROOT, 'records.jsonl'); // 检测记录(一行一条 JSON)
|
||||
|
||||
/* 服务端评分引擎:与 C 端同一份公式(禁止复制公式,直接复用) */
|
||||
const SkinScore = require('./js/skin-score.js');
|
||||
|
||||
/* ─── 密钥加载:环境变量优先,其次 apikey.bash,最后 reference 下的 credentials.env ─── */
|
||||
function loadCredentials() {
|
||||
@@ -46,7 +56,7 @@ const MIME = {
|
||||
'.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8',
|
||||
'.js': 'application/javascript; charset=utf-8', '.json': 'application/json; charset=utf-8',
|
||||
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.svg': 'image/svg+xml',
|
||||
'.ico': 'image/x-icon', '.webp': 'image/webp', '.woff2': 'font/woff2'
|
||||
'.ico': 'image/x-icon', '.webp': 'image/webp', '.woff2': 'font/woff2', '.csv': 'text/csv; charset=utf-8'
|
||||
};
|
||||
function serveStatic(req, res, pathname) {
|
||||
let p;
|
||||
@@ -57,6 +67,9 @@ function serveStatic(req, res, pathname) {
|
||||
return res.end('Bad Request');
|
||||
}
|
||||
if (p === '/') p = '/index.html';
|
||||
// /data/ 下是检测照片/原始 JSON/记录库(敏感个人信息),禁止静态直出:
|
||||
// 照片只能走 /api/admin/photos(由 nginx Basic Auth 保护),此处一律 403
|
||||
if (p === '/data' || p.startsWith('/data/')) { res.writeHead(403); return res.end('Forbidden'); }
|
||||
const file = path.normalize(path.join(ROOT, p));
|
||||
if (file !== ROOT && !file.startsWith(ROOT + path.sep)) { res.writeHead(403); return res.end('Forbidden'); }
|
||||
fs.readFile(file, (err, data) => {
|
||||
@@ -82,6 +95,7 @@ const ERR_MSG = {
|
||||
'AUTHORIZATION_ERROR': '检测服务额度或权限异常,请联系管理员',
|
||||
'CONCURRENCY_LIMIT_EXCEEDED': '当前检测人数较多,请稍候重试',
|
||||
'INTERNAL_ERROR': '检测服务开小差了,请稍候重试',
|
||||
'TIMEOUT': '检测服务响应超时,请稍候重试',
|
||||
'NETWORK_ERROR': '网络异常,请检查网络后重试'
|
||||
};
|
||||
|
||||
@@ -125,6 +139,170 @@ function isUsableB64(b64) {
|
||||
return Buffer.from(b64, 'base64').length > 500;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════
|
||||
检测记录存储(records.jsonl,一行一条 JSON,零依赖嵌入式存储)
|
||||
字段对照后台产品文档第五章 detect_record。
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
function nowIso() { return new Date().toISOString(); }
|
||||
|
||||
function newRecordId() {
|
||||
const d = new Date();
|
||||
const p = n => String(n).padStart(2, '0');
|
||||
const stamp = `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
|
||||
return `R${stamp}-${Math.random().toString(36).slice(2, 6).toUpperCase()}`;
|
||||
}
|
||||
|
||||
function readAllRecords() {
|
||||
try {
|
||||
return fs.readFileSync(RECORDS_FILE, 'utf8').split('\n')
|
||||
.map(line => { try { return JSON.parse(line); } catch (e) { return null; } })
|
||||
.filter(r => r && r.record_id);
|
||||
} catch (e) { return []; } // 文件不存在视为空库
|
||||
}
|
||||
|
||||
function appendRecord(rec) {
|
||||
try {
|
||||
fs.mkdirSync(DATA_ROOT, { recursive: true });
|
||||
fs.appendFileSync(RECORDS_FILE, JSON.stringify(rec) + '\n');
|
||||
} catch (e) { console.log(`[record] 记录追加失败(忽略): ${e.message}`); }
|
||||
}
|
||||
|
||||
function updateRecord(rec) {
|
||||
try {
|
||||
const all = readAllRecords();
|
||||
const i = all.findIndex(r => r.record_id === rec.record_id);
|
||||
if (i >= 0) all[i] = rec;
|
||||
fs.writeFileSync(RECORDS_FILE, all.map(r => JSON.stringify(r)).join('\n') + '\n');
|
||||
} catch (e) { console.log(`[record] 记录更新失败(忽略): ${e.message}`); }
|
||||
}
|
||||
|
||||
function findRecord(id) {
|
||||
const safe = String(id || '').replace(/[^\w-]/g, '');
|
||||
return readAllRecords().find(r => r.record_id === safe) || null;
|
||||
}
|
||||
|
||||
/* 请求体里的档案信息归一化:年龄非法(非 1–120 整数)按 null,其余空串按 null */
|
||||
function normalizeProfile(raw) {
|
||||
const p = (raw && typeof raw === 'object') ? raw : {};
|
||||
const age = Number(p.real_age);
|
||||
const str = v => (typeof v === 'string' && v.trim()) ? v.trim() : null;
|
||||
return {
|
||||
real_age: Number.isInteger(age) && age >= 1 && age <= 120 ? age : null,
|
||||
gender: str(p.gender),
|
||||
region: str(p.region),
|
||||
daily_skin_type: str(p.daily_skin_type)
|
||||
};
|
||||
}
|
||||
|
||||
/* 照片写盘:data/photos/<record_id>/{front,left,right}.jpg(本地磁盘代替对象存储,库只存相对地址)。
|
||||
写盘失败不影响检测流程,只打日志。返回 {front|left|right: 相对路径} */
|
||||
function writePhotos(recordId, parts) {
|
||||
const out = {};
|
||||
try {
|
||||
const dir = path.join(PHOTOS_DIR, recordId);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
for (const which of ['front', 'left', 'right']) {
|
||||
const b64 = parts[which];
|
||||
if (!b64) continue;
|
||||
fs.writeFileSync(path.join(dir, which + '.jpg'), Buffer.from(b64, 'base64'));
|
||||
out[which] = `data/photos/${recordId}/${which}.jpg`;
|
||||
}
|
||||
} catch (e) { console.log(`[record] 照片写盘失败(忽略): ${e.message}`); }
|
||||
return out;
|
||||
}
|
||||
|
||||
/* 从侧脸结果取下颌线角度:quality≠1 时角度无效,记 null */
|
||||
function jawlineAngle(side, which) {
|
||||
if (!side || typeof side !== 'object') return null;
|
||||
const info = side[which + '_jawline_info'];
|
||||
if (!info || info[which + '_jawline_quality'] !== 1) return null;
|
||||
const a = info[which + '_jawline_angle'];
|
||||
return typeof a === 'number' ? a : null;
|
||||
}
|
||||
|
||||
/* 原始返回完整落盘(含 face_maps base64),供复核/真值比对;写盘失败不影响检测。
|
||||
返回落盘文件名(相对 data/results/),失败返回 null */
|
||||
function dumpRaw(fpJson) {
|
||||
try {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
const reqId = String(fpJson.request_id || 'noreq').replace(/[^\w,-]/g, '').slice(0, 40);
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, '').slice(0, 15);
|
||||
const fname = `${stamp}-${reqId}.json`;
|
||||
fs.writeFile(path.join(DATA_DIR, fname), JSON.stringify(fpJson), () => {});
|
||||
console.log(`[analyze] 原始返回已落盘: data/results/${fname}`);
|
||||
return fname;
|
||||
} catch (e) { console.log(`[analyze] 落盘失败(忽略): ${e.message}`); return null; }
|
||||
}
|
||||
|
||||
/* 评分引擎入参档案:SkinScore 用 realAge/skinType 键 */
|
||||
function scoreProfile(rec) {
|
||||
return { realAge: rec.real_age, skinType: rec.daily_skin_type };
|
||||
}
|
||||
|
||||
/* 检测成功:用 SkinScore.buildViewModel 计算并补全记录字段(复用同一份公式) */
|
||||
function fillSuccess(rec, fpJson) {
|
||||
const vm = SkinScore.buildViewModel(fpJson.result, {
|
||||
left: fpJson.left_side_result || (fpJson.side_result && fpJson.side_result.left_side_result) || null,
|
||||
right: fpJson.right_side_result || (fpJson.side_result && fpJson.side_result.right_side_result) || null
|
||||
}, scoreProfile(rec));
|
||||
rec.detect_status = '成功';
|
||||
rec.fail_stage = null; rec.fail_code = null; rec.fail_msg = null;
|
||||
rec.skin_age = vm.skinAgeRaw;
|
||||
rec.total_score = vm.total;
|
||||
rec.jawline_angle_l = jawlineAngle(fpJson.left_side_result, 'left');
|
||||
rec.jawline_angle_r = jawlineAngle(fpJson.right_side_result, 'right');
|
||||
rec.cats = {}; rec.copy_6 = {};
|
||||
for (const k of Object.keys(vm.cats)) {
|
||||
rec.cats[k] = { P: vm.cats[k].P, S: vm.cats[k].S, level: vm.cats[k].level };
|
||||
rec.copy_6[k] = vm.cats[k].text;
|
||||
}
|
||||
rec.dims = {};
|
||||
for (const k of Object.keys(vm.dims)) rec.dims[k] = { P: vm.dims[k].P, S: vm.dims[k].S, level: vm.dims[k].level };
|
||||
rec.avg_s = vm.avgS;
|
||||
rec.group = vm.group;
|
||||
rec.copy_summary = vm.summary;
|
||||
rec.lifestyle = vm.lifestyle;
|
||||
rec.careAdvice = vm.careAdvice;
|
||||
rec.essence = vm.essence ? { name: vm.essence.name, text: vm.essence.text } : null;
|
||||
rec.essence_line = vm.essence ? vm.essence.text : null;
|
||||
rec.trigger = (vm.essence && vm.essence.triggers) ? vm.essence.triggers.join(',') : null;
|
||||
rec.notes = vm.notes || [];
|
||||
rec.raw_file = dumpRaw(fpJson);
|
||||
}
|
||||
|
||||
/* 检测失败:补失败字段 */
|
||||
function fillFailure(rec, code) {
|
||||
rec.detect_status = '失败';
|
||||
rec.fail_stage = 'detect';
|
||||
rec.fail_code = code;
|
||||
rec.fail_msg = ERR_MSG[code] || '检测失败,请重试';
|
||||
}
|
||||
|
||||
/* 执行一次检测:计时调用 Face++,按结果补全记录(成功/失败都落库)。返回 Face++ 原始返回 */
|
||||
async function runDetect(rec, front, left, right) {
|
||||
rec.detect_status = '检测中';
|
||||
rec.updated_at = nowIso();
|
||||
const t0 = Date.now();
|
||||
const fpJson = await callFacepp(front, left, right);
|
||||
rec.cost_ms = Date.now() - t0;
|
||||
if (fpJson.error_message) {
|
||||
const code = fpJson.error_message.split(':')[0];
|
||||
console.log(`[analyze] Face++ 错误: ${code}`);
|
||||
fillFailure(rec, code);
|
||||
} else if (!fpJson.result) {
|
||||
fillFailure(rec, 'INTERNAL_ERROR');
|
||||
} else {
|
||||
try {
|
||||
fillSuccess(rec, fpJson);
|
||||
} catch (e) { // 评分异常按检测失败落库,但不改写 C 端成功响应(原始结果仍透传)
|
||||
console.log(`[analyze] 评分异常: ${e.message}`);
|
||||
fillFailure(rec, 'INTERNAL_ERROR');
|
||||
}
|
||||
}
|
||||
rec.updated_at = nowIso();
|
||||
return fpJson;
|
||||
}
|
||||
|
||||
/* 调用 Face++ 专业版三视图测肤;
|
||||
* 接口超时/网络错误(fetch 抛错)、HTTP 5xx、CONCURRENCY_LIMIT_EXCEEDED → 等 4s 重试 1 次(V2.0 第七章) */
|
||||
async function callFacepp(front, left, right) {
|
||||
@@ -162,6 +340,7 @@ async function callFacepp(front, left, right) {
|
||||
}
|
||||
|
||||
async function handleAnalyze(req, res) {
|
||||
let rec = null; // 记录:正脸照片校验通过后创建(此前失败无从落库)
|
||||
try {
|
||||
const body = JSON.parse((await readBody(req, 20 * 1024 * 1024)).toString('utf8'));
|
||||
// 三个 image 字段先做类型检查:非字符串视为缺失(左/右图缺失时仅发正脸,接口自动降级为单视图)
|
||||
@@ -177,28 +356,49 @@ async function handleAnalyze(req, res) {
|
||||
return res.end(JSON.stringify({ ok: false, code: 'INVALID_IMAGE_SIZE', message: '照片数据异常,请重试' }));
|
||||
}
|
||||
|
||||
const fpJson = await callFacepp(front, left, right);
|
||||
/* ── 记录落库:生成 record_id → 写照片 → 落一条 detect_status=检测中 ── */
|
||||
const profile = normalizeProfile(body.profile);
|
||||
const recordId = newRecordId();
|
||||
const photos = writePhotos(recordId, { front, left, right });
|
||||
rec = {
|
||||
record_id: recordId,
|
||||
created_at: nowIso(),
|
||||
updated_at: nowIso(),
|
||||
real_age: profile.real_age,
|
||||
gender: profile.gender,
|
||||
region: profile.region,
|
||||
daily_skin_type: profile.daily_skin_type,
|
||||
img_front: photos.front || null,
|
||||
img_left: photos.left || null,
|
||||
img_right: photos.right || null,
|
||||
shoot_status: (photos.left && photos.right) ? '已完成' : '降级',
|
||||
upload_status: photos.front ? '成功' : '失败',
|
||||
detect_status: '检测中',
|
||||
fail_stage: null, fail_code: null, fail_msg: null,
|
||||
skin_age: null, total_score: null,
|
||||
jawline_angle_l: null, jawline_angle_r: null,
|
||||
cats: null, dims: null, avg_s: null, group: null,
|
||||
copy_6: null, copy_summary: null, lifestyle: null, careAdvice: null,
|
||||
essence: null, essence_line: null, trigger: null, notes: null,
|
||||
raw_file: null, cost_ms: null,
|
||||
retry_of: null, retry_count: 0
|
||||
};
|
||||
appendRecord(rec);
|
||||
console.log(`[record] 新检测记录 ${recordId}(${rec.shoot_status},照片${Object.keys(photos).length}/3)`);
|
||||
|
||||
const fpJson = await runDetect(rec, front, left, right);
|
||||
updateRecord(rec);
|
||||
|
||||
if (fpJson.error_message) {
|
||||
const code = fpJson.error_message.split(':')[0];
|
||||
console.log(`[analyze] Face++ 错误: ${code}`);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
||||
return res.end(JSON.stringify({ ok: false, code, message: ERR_MSG[code] || '检测失败,请重试' }));
|
||||
}
|
||||
if (!fpJson.result) {
|
||||
if (!fpJson.result) { // 与改造前一致:仅 result 缺失才走 INTERNAL_ERROR(评分异常不改写 C 端成功响应)
|
||||
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
||||
return res.end(JSON.stringify({ ok: false, code: 'INTERNAL_ERROR', message: ERR_MSG.INTERNAL_ERROR }));
|
||||
}
|
||||
const result = fpJson.result;
|
||||
// 原始返回完整落盘(含 face_maps base64),供复核/真值比对;写盘失败不影响检测响应
|
||||
try {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
const reqId = String(fpJson.request_id || 'noreq').replace(/[^\w,-]/g, '').slice(0, 40);
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, '').slice(0, 15);
|
||||
const fname = `${stamp}-${reqId}.json`;
|
||||
fs.writeFile(path.join(DATA_DIR, fname), JSON.stringify(fpJson), () => {});
|
||||
console.log(`[analyze] 原始返回已落盘: data/results/${fname}`);
|
||||
} catch (e) { console.log(`[analyze] 落盘失败(忽略): ${e.message}`); }
|
||||
// face_maps 是整幅 base64 叠加图,体积很大:只透传报告页所需图层(REPORT_MAPS),
|
||||
// 值补 data: URL 前缀供前端 <img> 直接用;未选中的图层直接删除键;
|
||||
// marks 坐标字段(acne/brown_spot/mole/closed_comedones/wrinkle_mark 等)本就在 result 里,原样透传
|
||||
@@ -223,6 +423,11 @@ async function handleAnalyze(req, res) {
|
||||
}
|
||||
}));
|
||||
} catch (e) {
|
||||
if (rec) { // 记录已创建但流程异常:按检测失败补全,便于后台看到这条调用
|
||||
fillFailure(rec, e.message === 'TOO_LARGE' ? 'IMAGE_FILE_TOO_LARGE' : 'NETWORK_ERROR');
|
||||
rec.updated_at = nowIso();
|
||||
updateRecord(rec);
|
||||
}
|
||||
const tooLarge = e.message === 'TOO_LARGE';
|
||||
const code = tooLarge ? 'IMAGE_FILE_TOO_LARGE' : 'NETWORK_ERROR';
|
||||
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
||||
@@ -231,13 +436,306 @@ async function handleAnalyze(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════
|
||||
后台查看 API(本地查看版,无登录鉴权 —— 上线前需加网关/鉴权)
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
function sendJson(res, status, obj) {
|
||||
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
|
||||
res.end(JSON.stringify(obj));
|
||||
}
|
||||
|
||||
/* 记录里照片的在线 URL(有存才给) */
|
||||
function photoUrls(rec) {
|
||||
const urls = {};
|
||||
if (rec.img_front) urls.front = `/api/admin/photos/${rec.record_id}/front`;
|
||||
if (rec.img_left) urls.left = `/api/admin/photos/${rec.record_id}/left`;
|
||||
if (rec.img_right) urls.right = `/api/admin/photos/${rec.record_id}/right`;
|
||||
return urls;
|
||||
}
|
||||
|
||||
/* 列表用的字段子集(不含 raw_json 与文案全文) */
|
||||
function listItem(rec) {
|
||||
return {
|
||||
record_id: rec.record_id,
|
||||
created_at: rec.created_at,
|
||||
real_age: rec.real_age, gender: rec.gender, region: rec.region, daily_skin_type: rec.daily_skin_type,
|
||||
shoot_status: rec.shoot_status, upload_status: rec.upload_status, detect_status: rec.detect_status,
|
||||
fail_code: rec.fail_code, fail_msg: rec.fail_msg,
|
||||
total_score: rec.total_score, skin_age: rec.skin_age,
|
||||
dims: rec.dims, group: rec.group,
|
||||
essence: rec.essence ? rec.essence.name : null,
|
||||
cost_ms: rec.cost_ms,
|
||||
retry_of: rec.retry_of, retry_count: rec.retry_count || 0,
|
||||
photos: photoUrls(rec)
|
||||
};
|
||||
}
|
||||
|
||||
/* 查询参数过滤。from/to 支持 YYYY-MM-DD 或完整 ISO;纯日期 to 含当天全天 */
|
||||
function matchQuery(rec, q) {
|
||||
if (q.from && rec.created_at < q.from) return false;
|
||||
if (q.to) {
|
||||
const to = q.to.length === 10 ? q.to + 'T23:59:59.999Z' : q.to;
|
||||
if (rec.created_at > to) return false;
|
||||
}
|
||||
if (q.detect_status && rec.detect_status !== q.detect_status) return false;
|
||||
if (q.fail_code && rec.fail_code !== q.fail_code) return false;
|
||||
if (q.group && rec.group !== q.group) return false;
|
||||
if (q.q) {
|
||||
const needle = String(q.q).trim();
|
||||
if (needle && !rec.record_id.startsWith(needle)) return false;
|
||||
}
|
||||
if (q.age_min != null) { if (rec.real_age == null || rec.real_age < q.age_min) return false; }
|
||||
if (q.age_max != null) { if (rec.real_age == null || rec.real_age > q.age_max) return false; }
|
||||
if (q.dim && q.dim_level) {
|
||||
const d = rec.dims && rec.dims[q.dim];
|
||||
if (!d || d.level !== q.dim_level) return false;
|
||||
}
|
||||
if (q.only_fail === '1' && rec.detect_status !== '失败') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleRecordsQuery(req, res, url) {
|
||||
const sp = url.searchParams;
|
||||
const q = {
|
||||
from: sp.get('from') || null,
|
||||
to: sp.get('to') || null,
|
||||
detect_status: sp.get('detect_status') || null,
|
||||
fail_code: sp.get('fail_code') || null,
|
||||
group: sp.get('group') || null,
|
||||
dim: sp.get('dim') || null,
|
||||
dim_level: sp.get('dim_level') || null,
|
||||
q: sp.get('q') || null,
|
||||
only_fail: sp.get('only_fail') || null,
|
||||
age_min: sp.has('age_min') && sp.get('age_min') !== '' ? Number(sp.get('age_min')) : null,
|
||||
age_max: sp.has('age_max') && sp.get('age_max') !== '' ? Number(sp.get('age_max')) : null
|
||||
};
|
||||
if ((q.age_min != null && !Number.isFinite(q.age_min)) || (q.age_max != null && !Number.isFinite(q.age_max))) {
|
||||
return sendJson(res, 400, { ok: false, message: 'age_min/age_max 必须是数字' });
|
||||
}
|
||||
const page = Math.max(1, parseInt(sp.get('page') || '1', 10) || 1);
|
||||
const pageSize = Math.min(100, Math.max(1, parseInt(sp.get('page_size') || '20', 10) || 20));
|
||||
const filtered = readAllRecords().filter(r => matchQuery(r, q))
|
||||
.sort((a, b) => (a.created_at < b.created_at ? 1 : -1)); // created_at 倒序
|
||||
const total = filtered.length;
|
||||
const items = filtered.slice((page - 1) * pageSize, page * pageSize).map(listItem);
|
||||
sendJson(res, 200, { ok: true, total, page, page_size: pageSize, items });
|
||||
}
|
||||
|
||||
function handleRecordDetail(req, res, id) {
|
||||
const rec = findRecord(id);
|
||||
if (!rec) return sendJson(res, 404, { ok: false, message: '记录不存在' });
|
||||
// 原始 JSON 全文:读文件失败给 null
|
||||
let rawJson = null;
|
||||
if (rec.raw_file) {
|
||||
const fname = String(rec.raw_file).replace(/[^\w.,-]/g, '');
|
||||
try { rawJson = JSON.parse(fs.readFileSync(path.join(DATA_DIR, fname), 'utf8')); }
|
||||
catch (e) { rawJson = null; }
|
||||
}
|
||||
// 版本链:它 retry_of 指向的(父)+ retry_of 指向它的(子)
|
||||
const all = readAllRecords();
|
||||
const parent = rec.retry_of ? (all.find(r => r.record_id === rec.retry_of) || null) : null;
|
||||
const children = all.filter(r => r.retry_of === rec.record_id)
|
||||
.map(r => ({ record_id: r.record_id, detect_status: r.detect_status, created_at: r.created_at }));
|
||||
sendJson(res, 200, {
|
||||
ok: true,
|
||||
record: rec,
|
||||
raw_json: rawJson,
|
||||
photos: photoUrls(rec),
|
||||
versions: {
|
||||
parent: parent ? { record_id: parent.record_id, detect_status: parent.detect_status, created_at: parent.created_at } : null,
|
||||
children
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handlePhoto(req, res, id, which) {
|
||||
const safe = String(id || '').replace(/[^\w-]/g, '');
|
||||
const file = path.join(PHOTOS_DIR, safe, which + '.jpg');
|
||||
fs.readFile(file, (err, data) => {
|
||||
if (err) { res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); return res.end('Not Found'); }
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'image/jpeg',
|
||||
'Content-Disposition': `inline; filename="${safe}-${which}.jpg"`
|
||||
});
|
||||
res.end(data);
|
||||
});
|
||||
}
|
||||
|
||||
function handleStats(req, res) {
|
||||
const all = readAllRecords();
|
||||
const today = nowIso().slice(0, 10);
|
||||
const finished = all.filter(r => r.detect_status === '成功' || r.detect_status === '失败');
|
||||
const succeeded = finished.filter(r => r.detect_status === '成功');
|
||||
const failed = finished.filter(r => r.detect_status === '失败');
|
||||
const avgCost = finished.length
|
||||
? Math.round(finished.reduce((s, r) => s + (r.cost_ms || 0), 0) / finished.length)
|
||||
: 0;
|
||||
|
||||
// 失败原因分布
|
||||
const failMap = {};
|
||||
for (const r of failed) failMap[r.fail_code || 'UNKNOWN'] = (failMap[r.fail_code || 'UNKNOWN'] || 0) + 1;
|
||||
const fail_dist = Object.keys(failMap).map(code => ({
|
||||
code, count: failMap[code],
|
||||
pct: failed.length ? Math.round(failMap[code] * 1000 / failed.length) / 10 : 0
|
||||
})).sort((a, b) => b.count - a.count);
|
||||
|
||||
// 三维度档位分布(成功记录)
|
||||
const dim_dist = { firm: { '预警': 0, '改善': 0, '良好': 0 }, vita: { '预警': 0, '改善': 0, '良好': 0 }, stab: { '预警': 0, '改善': 0, '良好': 0 } };
|
||||
const group_dist = {};
|
||||
for (const r of succeeded) {
|
||||
for (const d of ['firm', 'vita', 'stab']) {
|
||||
const lv = r.dims && r.dims[d] && r.dims[d].level;
|
||||
if (lv && dim_dist[d][lv] != null) dim_dist[d][lv] += 1;
|
||||
}
|
||||
const g = r.group || '未分组';
|
||||
group_dist[g] = (group_dist[g] || 0) + 1;
|
||||
}
|
||||
|
||||
// 近 14 天每日检测量/成功数(含今天,今天在后)
|
||||
const daily = [];
|
||||
for (let i = 13; i >= 0; i--) {
|
||||
const d = new Date(Date.now() - i * 86400000);
|
||||
const day = d.toISOString().slice(0, 10);
|
||||
const recs = all.filter(r => (r.created_at || '').slice(0, 10) === day);
|
||||
daily.push({
|
||||
date: day,
|
||||
total: recs.length,
|
||||
success: recs.filter(r => r.detect_status === '成功').length
|
||||
});
|
||||
}
|
||||
|
||||
sendJson(res, 200, {
|
||||
ok: true,
|
||||
today_total: all.filter(r => (r.created_at || '').slice(0, 10) === today).length,
|
||||
total: all.length,
|
||||
finished_total: finished.length,
|
||||
success_total: succeeded.length,
|
||||
fail_total: failed.length,
|
||||
success_rate: finished.length ? Math.round(succeeded.length * 1000 / finished.length) / 10 : 0,
|
||||
avg_cost_ms: avgCost,
|
||||
fail_dist,
|
||||
dim_dist,
|
||||
group_dist,
|
||||
daily
|
||||
});
|
||||
}
|
||||
|
||||
/* 手动重试(文档 4.4):读原记录照片重新检测,生成新记录,原记录 retry_count+1 */
|
||||
async function handleRetry(req, res, id) {
|
||||
const rec = findRecord(id);
|
||||
if (!rec) return sendJson(res, 404, { ok: false, message: '记录不存在' });
|
||||
const readB64 = which => {
|
||||
const file = path.join(PHOTOS_DIR, rec.record_id, which + '.jpg');
|
||||
try { return fs.readFileSync(file).toString('base64'); } catch (e) { return null; }
|
||||
};
|
||||
const front = readB64('front');
|
||||
if (!front) return sendJson(res, 400, { ok: false, message: '该记录没有可用的正脸照片,无法重试' });
|
||||
|
||||
const nid = newRecordId();
|
||||
const photos = writePhotos(nid, { front, left: readB64('left'), right: readB64('right') });
|
||||
const nrec = {
|
||||
record_id: nid,
|
||||
created_at: nowIso(),
|
||||
updated_at: nowIso(),
|
||||
real_age: rec.real_age, gender: rec.gender, region: rec.region, daily_skin_type: rec.daily_skin_type,
|
||||
img_front: photos.front || null,
|
||||
img_left: photos.left || null,
|
||||
img_right: photos.right || null,
|
||||
shoot_status: (photos.left && photos.right) ? '已完成' : '降级',
|
||||
upload_status: photos.front ? '成功' : '失败',
|
||||
detect_status: '检测中',
|
||||
fail_stage: null, fail_code: null, fail_msg: null,
|
||||
skin_age: null, total_score: null,
|
||||
jawline_angle_l: null, jawline_angle_r: null,
|
||||
cats: null, dims: null, avg_s: null, group: null,
|
||||
copy_6: null, copy_summary: null, lifestyle: null, careAdvice: null,
|
||||
essence: null, essence_line: null, trigger: null, notes: null,
|
||||
raw_file: null, cost_ms: null,
|
||||
retry_of: rec.record_id, retry_count: 0
|
||||
};
|
||||
appendRecord(nrec);
|
||||
console.log(`[record] 手动重试 ${rec.record_id} → 新记录 ${nid}`);
|
||||
await runDetect(nrec, front, nrec.img_left ? readB64('left') : null, nrec.img_right ? readB64('right') : null);
|
||||
updateRecord(nrec);
|
||||
rec.retry_count = (rec.retry_count || 0) + 1;
|
||||
rec.updated_at = nowIso();
|
||||
updateRecord(rec);
|
||||
sendJson(res, 200, { ok: true, record: listItem(nrec) });
|
||||
}
|
||||
|
||||
/* CSV 单元格转义;raw_json 等大字段严禁进单元格(Excel 单格 32767 字符截断) */
|
||||
function csvCell(v) {
|
||||
if (v == null) return '';
|
||||
const s = String(v);
|
||||
return /[",\n\r]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
|
||||
}
|
||||
|
||||
function handleExport(req, res, url) {
|
||||
const q = {
|
||||
from: url.searchParams.get('from') || null,
|
||||
to: url.searchParams.get('to') || null
|
||||
};
|
||||
const filtered = readAllRecords().filter(r => matchQuery(r, q))
|
||||
.sort((a, b) => (a.created_at < b.created_at ? 1 : -1));
|
||||
const dimP = (r, d) => (r.dims && r.dims[d]) ? r.dims[d].P : null;
|
||||
const dimS = (r, d) => (r.dims && r.dims[d]) ? r.dims[d].S : null;
|
||||
const dimLv = (r, d) => (r.dims && r.dims[d]) ? r.dims[d].level : null;
|
||||
const header = ['记录ID', '创建时间', '年龄', '性别', '地区', '日常肤质', '拍摄状态', '上传状态', '检测状态',
|
||||
'失败环节', '失败码', '失败信息', '总分', '肌肤年龄',
|
||||
'紧塑力P', '紧塑力S', '紧塑力档', '活力感P', '活力感S', '活力感档', '稳定力P', '稳定力S', '稳定力档',
|
||||
'均分S', '产品组别', '推荐精华', '触发判据', '耗时ms'];
|
||||
const lines = [header.join(',')];
|
||||
for (const r of filtered) {
|
||||
lines.push([
|
||||
r.record_id, r.created_at, r.real_age, r.gender, r.region, r.daily_skin_type,
|
||||
r.shoot_status, r.upload_status, r.detect_status,
|
||||
r.fail_stage, r.fail_code, r.fail_msg, r.total_score, r.skin_age,
|
||||
dimP(r, 'firm'), dimS(r, 'firm'), dimLv(r, 'firm'),
|
||||
dimP(r, 'vita'), dimS(r, 'vita'), dimLv(r, 'vita'),
|
||||
dimP(r, 'stab'), dimS(r, 'stab'), dimLv(r, 'stab'),
|
||||
r.avg_s, r.group, r.essence ? r.essence.name : null, r.trigger, r.cost_ms
|
||||
].map(csvCell).join(','));
|
||||
}
|
||||
const fromTag = (q.from || 'all').replace(/[^\d-]/g, '');
|
||||
const toTag = (q.to || 'all').replace(/[^\d-]/g, '');
|
||||
const fname = `skin-records-${fromTag}_${toTag}.csv`;
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/csv; charset=utf-8',
|
||||
'Content-Disposition': `attachment; filename="${fname}"`
|
||||
});
|
||||
res.end('' + lines.join('\r\n')); // UTF-8 BOM:Excel 直接打开不乱码
|
||||
}
|
||||
|
||||
async function handleAdmin(req, res, pathname, url) {
|
||||
try {
|
||||
const retryM = pathname.match(/^\/api\/admin\/records\/([\w-]+)\/retry$/);
|
||||
if (req.method === 'POST' && retryM) return await handleRetry(req, res, retryM[1]);
|
||||
const detailM = pathname.match(/^\/api\/admin\/records\/([\w-]+)$/);
|
||||
if (req.method === 'GET' && detailM) return handleRecordDetail(req, res, detailM[1]);
|
||||
const photoM = pathname.match(/^\/api\/admin\/photos\/([\w-]+)\/(front|left|right)$/);
|
||||
if (req.method === 'GET' && photoM) return handlePhoto(req, res, photoM[1], photoM[2]);
|
||||
if (req.method === 'GET' && pathname === '/api/admin/records') return handleRecordsQuery(req, res, url);
|
||||
if (req.method === 'GET' && pathname === '/api/admin/stats') return handleStats(req, res);
|
||||
if (req.method === 'GET' && pathname === '/api/admin/export') return handleExport(req, res, url);
|
||||
return sendJson(res, 404, { ok: false, message: '接口不存在' });
|
||||
} catch (e) {
|
||||
console.log(`[admin] ${e.message}`);
|
||||
return sendJson(res, 500, { ok: false, message: '服务内部错误' });
|
||||
}
|
||||
}
|
||||
|
||||
http.createServer((req, res) => {
|
||||
if (req.method === 'POST' && req.url.split('?')[0] === '/api/skin-analyze') return handleAnalyze(req, res);
|
||||
if (req.method === 'GET' && req.url.split('?')[0] === '/api/health') {
|
||||
const pathname = req.url.split('?')[0];
|
||||
if (req.method === 'POST' && pathname === '/api/skin-analyze') return handleAnalyze(req, res);
|
||||
if (req.method === 'GET' && pathname === '/api/health') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
return res.end(JSON.stringify({ ok: true }));
|
||||
}
|
||||
return serveStatic(req, res, req.url.split('?')[0]);
|
||||
}).listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`[server] 巨子测肤 H5 已启动: http://127.0.0.1:${PORT} (静态 + /api/skin-analyze)`);
|
||||
if (pathname.startsWith('/api/admin/')) {
|
||||
return handleAdmin(req, res, pathname, new URL(req.url, 'http://localhost'));
|
||||
}
|
||||
return serveStatic(req, res, pathname);
|
||||
}).listen(PORT, HOST, () => {
|
||||
console.log(`[server] 巨子测肤 H5 已启动: http://${HOST}:${PORT} (静态 + /api/skin-analyze + /api/admin/*)`);
|
||||
console.log(`[server] 后台查看页: http://${HOST}:${PORT}/admin.html`);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user