feat: 扫描报告页+手动选波长护理+光疗功效介绍页+诊断框架(占位阈值)
- services/diagnosis.js: 诊断流水线框架,阈值/PD映射为占位待标定,支持单波长降级 - auto-scan: 扫描完成改为出报告让用户选择护理(替代原自动开始治疗) - 新增 scan-report/manual-treatment/light-info 三页,全部接入 i18n - api.js: 新增 report/diagnosis-config 接口(对接后端契约)
这个提交包含在:
@@ -0,0 +1,204 @@
|
||||
// Skin-tendency diagnosis — FRAMEWORK / placeholder implementation.
|
||||
//
|
||||
// STATUS: the diagnosis 依据 is NOT yet calibrated (客户也认为"还只是概念").
|
||||
// This module defines the full data pipeline so the report UI + storage work end
|
||||
// to end today, but the thresholds and the PD->region mapping are PLACEHOLDERS to
|
||||
// be replaced after real-device calibration. It degrades gracefully when only
|
||||
// partial scan data (currently single-wavelength red) is available.
|
||||
//
|
||||
// Two things here need hardware/calibration confirmation before this is "real":
|
||||
// 1) PD_REGION_MAP — which of the 7 PDs feed which facial region (docs disagree).
|
||||
// 2) levels thresholds — normalized-absorption cutoffs per severity level.
|
||||
// Both can be overridden at runtime by the server-side diagnosis_config, so tuning
|
||||
// after calibration does NOT require an app release.
|
||||
|
||||
// Problem type -> recommended care wavelength (IR=1, R=2, UV=3, Y=4).
|
||||
var PROBLEM_WAVELENGTH = { aging: 2, acne: 3, pigment: 4, deep: 1 }
|
||||
// Scanned wavelength code -> the problem it reveals (inverse of the above).
|
||||
var WAVELENGTH_PROBLEM = { 1: 'deep', 2: 'aging', 3: 'acne', 4: 'pigment' }
|
||||
// Dictionary wave-key -> wavelength code.
|
||||
var WAVE_KEY_CODE = { red: 2, uv: 3, yellow: 4, ir: 1 }
|
||||
|
||||
// 5 regions in the app's neutral convention (right/left/top/middle/bottom),
|
||||
// matching treatment-setup.js masks.
|
||||
var REGION_DEFS = [
|
||||
{ key: 'right', mask: 0x01 },
|
||||
{ key: 'left', mask: 0x02 },
|
||||
{ key: 'top', mask: 0x04 },
|
||||
{ key: 'middle', mask: 0x08 },
|
||||
{ key: 'bottom', mask: 0x10 }
|
||||
]
|
||||
|
||||
// PLACEHOLDER PD->region map. Flow doc: 7 PDs = 1 special region (3 PDs) + 4 regions
|
||||
// (1 PD each). The exact assignment is UNCONFIRMED — replace after hardware confirms
|
||||
// the PD physical layout. Each region lists the PD index(es) that feed its signal.
|
||||
var PD_REGION_MAP = {
|
||||
right: [0], left: [1], top: [2], middle: [3, 5, 6], bottom: [4]
|
||||
}
|
||||
|
||||
var MAX_ADC = 4095
|
||||
|
||||
var DEFAULT_CONFIG = {
|
||||
calibrated: false,
|
||||
levels: { low: 0.15, mid: 0.35, high: 0.55 },
|
||||
problem_wavelength: PROBLEM_WAVELENGTH
|
||||
}
|
||||
|
||||
function _avg(arr) {
|
||||
if (!arr || !arr.length) return 0
|
||||
var s = 0
|
||||
for (var i = 0; i < arr.length; i++) s += arr[i]
|
||||
return s / arr.length
|
||||
}
|
||||
|
||||
// Normalized absorption in [0,1]: the more the light is absorbed (lower reflected PD
|
||||
// relative to the底噪 baseline), the stronger the tendency signal.
|
||||
function _absorption(pdValue, baseline) {
|
||||
if (!baseline || baseline <= 0) return 0
|
||||
var a = (baseline - pdValue) / baseline
|
||||
if (a < 0) a = 0
|
||||
if (a > 1) a = 1
|
||||
return a
|
||||
}
|
||||
|
||||
function _levelOf(score, levels) {
|
||||
if (score >= levels.high) return 3
|
||||
if (score >= levels.mid) return 2
|
||||
if (score >= levels.low) return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
// scanData: {
|
||||
// device_id, scanned_at,
|
||||
// region_mask, // which regions were scanned (default 0x1F)
|
||||
// noise: [7], // per-PD 底噪 baseline (optional; defaults MAX_ADC)
|
||||
// waves: { red:[7], uv:[7], yellow:[7], ir:[7] } // any subset; each = 7 PD values
|
||||
// }
|
||||
function analyze(scanData, config) {
|
||||
config = config || DEFAULT_CONFIG
|
||||
var levels = config.levels || DEFAULT_CONFIG.levels
|
||||
var problemWave = config.problem_wavelength || PROBLEM_WAVELENGTH
|
||||
scanData = scanData || {}
|
||||
var waves = scanData.waves || {}
|
||||
var noise = scanData.noise
|
||||
var scannedMask = scanData.region_mask || 0x1F
|
||||
|
||||
var regions = []
|
||||
var overallAcc = {}
|
||||
|
||||
for (var r = 0; r < REGION_DEFS.length; r++) {
|
||||
var def = REGION_DEFS[r]
|
||||
if ((scannedMask & def.mask) === 0) continue
|
||||
var pdIdx = PD_REGION_MAP[def.key] || []
|
||||
var problems = []
|
||||
|
||||
for (var wk in waves) {
|
||||
if (!waves.hasOwnProperty(wk)) continue
|
||||
var code = WAVE_KEY_CODE[wk]
|
||||
var problemType = WAVELENGTH_PROBLEM[code]
|
||||
if (!problemType) continue
|
||||
|
||||
var pdVals = []
|
||||
var baseVals = []
|
||||
for (var k = 0; k < pdIdx.length; k++) {
|
||||
var idx = pdIdx[k]
|
||||
if (waves[wk][idx] !== undefined) pdVals.push(waves[wk][idx])
|
||||
baseVals.push(noise && noise[idx] !== undefined ? noise[idx] : MAX_ADC)
|
||||
}
|
||||
if (!pdVals.length) continue
|
||||
|
||||
var score = _absorption(_avg(pdVals), _avg(baseVals))
|
||||
var level = _levelOf(score, levels)
|
||||
if (level > 0) {
|
||||
problems.push({ type: problemType, level: level, score: Math.round(score * 100) / 100 })
|
||||
if (!overallAcc[problemType] || level > overallAcc[problemType]) {
|
||||
overallAcc[problemType] = level
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
problems.sort(function (a, b) { return (b.level - a.level) || (b.score - a.score) })
|
||||
var top = problems.length ? problems[0] : null
|
||||
regions.push({
|
||||
region: def.key,
|
||||
mask: def.mask,
|
||||
top_problem: top ? top.type : null,
|
||||
level: top ? top.level : 0,
|
||||
problems: problems
|
||||
})
|
||||
}
|
||||
|
||||
var overall = []
|
||||
for (var pt in overallAcc) {
|
||||
if (overallAcc.hasOwnProperty(pt)) overall.push({ type: pt, level: overallAcc[pt] })
|
||||
}
|
||||
overall.sort(function (a, b) { return b.level - a.level })
|
||||
|
||||
// Recommendation: care each region-with-a-top-problem using that problem's wavelength.
|
||||
// One BLE command carries one wavelength, so plan entries are grouped by wavelength.
|
||||
var planByWave = {}
|
||||
var recommendMask = 0
|
||||
for (var i = 0; i < regions.length; i++) {
|
||||
var reg = regions[i]
|
||||
if (!reg.top_problem) continue
|
||||
var wl = problemWave[reg.top_problem]
|
||||
if (!wl) continue
|
||||
planByWave[wl] = (planByWave[wl] || 0) | reg.mask
|
||||
recommendMask |= reg.mask
|
||||
}
|
||||
var recommendPlan = []
|
||||
for (var w in planByWave) {
|
||||
if (!planByWave.hasOwnProperty(w)) continue
|
||||
var code2 = parseInt(w, 10)
|
||||
recommendPlan.push({ wavelength: code2, mask: planByWave[w], label_key: WAVELENGTH_PROBLEM[code2] })
|
||||
}
|
||||
|
||||
return {
|
||||
device_id: scanData.device_id || null,
|
||||
scanned_at: scanData.scanned_at || 0,
|
||||
regions: regions,
|
||||
overall: overall,
|
||||
recommend_mask: recommendMask,
|
||||
recommend_plan: recommendPlan,
|
||||
raw_pd: waves,
|
||||
calibrated: !!config.calibrated
|
||||
}
|
||||
}
|
||||
|
||||
// Adapt the current (single-wavelength red) auto-scan result into scanData, so the
|
||||
// pipeline works today. pdAvg = 7 averaged PD values under red light.
|
||||
function fromRedScan(pdAvg, regionMask, deviceId, scannedAt, noise) {
|
||||
return {
|
||||
device_id: deviceId || null,
|
||||
scanned_at: scannedAt || 0,
|
||||
region_mask: regionMask || 0x1F,
|
||||
noise: noise || null,
|
||||
waves: { red: (pdAvg || []).slice(0, 7) }
|
||||
}
|
||||
}
|
||||
|
||||
// Deterministic mock report for UI development / demo when there is no device data.
|
||||
function mockReport() {
|
||||
return analyze({
|
||||
device_id: 'MOCK',
|
||||
scanned_at: 0,
|
||||
region_mask: 0x1F,
|
||||
noise: [4000, 4000, 4000, 4000, 4000, 4000, 4000],
|
||||
waves: {
|
||||
red: [3500, 3600, 1900, 3400, 3450, 3500, 3500],
|
||||
uv: [3700, 3750, 3600, 1700, 3700, 1800, 1900],
|
||||
yellow: [3500, 3550, 3400, 3450, 2300, 3500, 3520],
|
||||
ir: [3600, 3600, 3550, 3580, 3560, 3600, 3600]
|
||||
}
|
||||
}, DEFAULT_CONFIG)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_CONFIG: DEFAULT_CONFIG,
|
||||
PROBLEM_WAVELENGTH: PROBLEM_WAVELENGTH,
|
||||
WAVELENGTH_PROBLEM: WAVELENGTH_PROBLEM,
|
||||
REGION_DEFS: REGION_DEFS,
|
||||
analyze: analyze,
|
||||
fromRedScan: fromRedScan,
|
||||
mockReport: mockReport
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户