diff --git a/miniprogram/i18n/locales/lightInfo.js b/miniprogram/i18n/locales/lightInfo.js index f4d0aa3..2e2338b 100644 --- a/miniprogram/i18n/locales/lightInfo.js +++ b/miniprogram/i18n/locales/lightInfo.js @@ -1,6 +1,95 @@ // Strings for the light-therapy efficacy intro page (pages/light-info). // Owned by the light-info feature. Keep zh/en keys in sync. +// Lifestyle / non-medical wording on purpose (广告法 / 微信审核 compliance). module.exports = { - zh: {}, - en: {} + zh: { + navTitle: '光疗功效介绍', + headerSubtitle: '认识四种光与护理模式', + lightsSectionTitle: '四种光', + suitsLabel: '适合', + lights: { + red: { + name: '红光', + wavelength: '630–640nm', + effect: '嫩肤焕亮', + desc: '改善暗沉、细纹与松弛感,帮助肌肤紧致提亮。', + suits: '初老、暗沉肌' + }, + uv: { + name: '紫光', + wavelength: '415–425nm', + effect: '净痘控油', + desc: '改善痘痘肌、油光与粗大毛孔,帮助肌肤清爽。', + suits: '油痘肌' + }, + yellow: { + name: '黄光', + wavelength: '585–595nm', + effect: '匀净提亮', + desc: '改善暗黄、色素不均与泛红,帮助肤色更均匀。', + suits: '暗黄、敏感泛红肌' + }, + infrared: { + name: '红外光', + wavelength: '830–840nm', + effect: '深层焕活', + desc: '促进循环、舒缓疲劳感,帮助肌肤放松焕活。', + suits: '疲劳、需深层舒缓的肌肤' + } + }, + modesSectionTitle: '护理模式', + modes: [ + { name: '嫩肤焕亮', desc: '红光护理' }, + { name: '净痘控油', desc: '紫光护理' }, + { name: '匀净提亮', desc: '黄光护理' }, + { name: '深层焕活', desc: '红外护理' }, + { name: '舒缓修护', desc: '红+黄复合光,温和护理敏感肌' } + ], + disclaimer: '本页内容为光护理科普,不构成医疗建议。' + }, + en: { + navTitle: 'Light Care Guide', + headerSubtitle: 'Meet the four lights and the care modes', + lightsSectionTitle: 'Four Lights', + suitsLabel: 'Best for', + lights: { + red: { + name: 'Red', + wavelength: '630–640nm', + effect: 'Glow & Firm', + desc: 'Softens dullness, fine lines and looseness for firmer, brighter-looking skin.', + suits: 'Early-aging, dull skin' + }, + uv: { + name: 'Violet', + wavelength: '415–425nm', + effect: 'Clear & Balance', + desc: 'Helps blemish-prone, oily skin and visible pores feel fresher.', + suits: 'Oily, blemish-prone skin' + }, + yellow: { + name: 'Yellow', + wavelength: '585–595nm', + effect: 'Even & Brighten', + desc: 'Eases sallowness, uneven tone and redness for a more even look.', + suits: 'Sallow, sensitive, easily-red skin' + }, + infrared: { + name: 'Infrared', + wavelength: '830–840nm', + effect: 'Deep Revive', + desc: 'Supports circulation and soothes tired-feeling skin for a relaxed, revived look.', + suits: 'Tired skin needing deep comfort' + } + }, + modesSectionTitle: 'Care Modes', + modes: [ + { name: 'Glow & Firm', desc: 'Red light care' }, + { name: 'Clear & Balance', desc: 'Violet light care' }, + { name: 'Even & Brighten', desc: 'Yellow light care' }, + { name: 'Deep Revive', desc: 'Infrared care' }, + { name: 'Soothe & Repair', desc: 'Red + yellow blend, gentle care for sensitive skin' } + ], + disclaimer: 'This guide is for light-care education only and is not medical advice.' + } } diff --git a/miniprogram/i18n/locales/manual.js b/miniprogram/i18n/locales/manual.js index 411d3c4..7836e46 100644 --- a/miniprogram/i18n/locales/manual.js +++ b/miniprogram/i18n/locales/manual.js @@ -1,6 +1,42 @@ // Strings for the manual treatment (direct wavelength/function select) page // (pages/manual-treatment). Owned by the manual-mode feature. Keep zh/en in sync. module.exports = { - zh: {}, - en: {} + zh: { + navTitle: '手动护理', + navSubtitle: '直接选择护理功能,快速开始', + functionTitle: '选择护理功能', + functions: { + rejuvenate: { name: '嫩肤焕亮', benefit: '红光温和促进胶原,改善暗沉' }, + acne: { name: '净痘控油', benefit: '紫光清洁抑菌,平衡油脂' }, + eventone: { name: '匀净提亮', benefit: '黄光舒缓肌肤,提亮肤色' }, + revitalize: { name: '深层焕活', benefit: '红外深层导入,焕活紧致' } + }, + regionTitle: '选择区域', + durationTitle: '选择时长', + durationUnit: '{min} 分钟', + safetyTip: '单次护理最长 10 分钟,亮度已设为护理中档,请佩戴护目镜', + start: '开始护理', + starting: '启动中...', + noRegionSelected: '请至少选择一个区域', + startFailed: '启动失败' + }, + en: { + navTitle: 'Manual Care', + navSubtitle: 'Pick a care function and start right away', + functionTitle: 'Select Care Function', + functions: { + rejuvenate: { name: 'Rejuvenate & Brighten', benefit: 'Red light boosts collagen, eases dullness' }, + acne: { name: 'Clear & Oil Control', benefit: 'UV light cleanses and balances oil' }, + eventone: { name: 'Even & Brighten', benefit: 'Yellow light soothes and brightens tone' }, + revitalize: { name: 'Deep Revitalize', benefit: 'Infrared penetrates deep to firm skin' } + }, + regionTitle: 'Select Regions', + durationTitle: 'Select Duration', + durationUnit: '{min} min', + safetyTip: 'Sessions last up to 10 min at a safe medium brightness. Wear eye protection.', + start: 'Start Care', + starting: 'Starting...', + noRegionSelected: 'Please select at least one region', + startFailed: 'Failed to start' + } } diff --git a/miniprogram/pages/auto-scan/auto-scan.js b/miniprogram/pages/auto-scan/auto-scan.js index de3f39a..b6ce938 100644 --- a/miniprogram/pages/auto-scan/auto-scan.js +++ b/miniprogram/pages/auto-scan/auto-scan.js @@ -1,4 +1,6 @@ var ble = require('../../services/ble') +var api = require('../../utils/api') +var diagnosis = require('../../services/diagnosis') var SCAN_TIMEOUT = 180000 Page({ @@ -158,39 +160,49 @@ Page({ getApp().globalData.lastScanPdAvg = avg console.log('[SCAN] PD平均值:', avg, '帧数:', count) - this._startTreatment() + this._buildAndGoReport(avg) }, - _startTreatment: function () { + // Scan finished -> run the (placeholder) diagnosis, store + persist a report, and + // navigate to the report page so the user sees their skin tendencies and chooses + // care. This replaces the previous auto-start-treatment behavior. + // + // NOTE: today only single-wavelength red PD is collected, and the thresholds + + // PD->region mapping in services/diagnosis.js are placeholders pending calibration. + // Report + persistence work end to end regardless; the numbers get real after + // 4-wavelength scan collection and calibration land. + _buildAndGoReport: function (avg) { var self = this + var app = getApp() var mask = self.data.regions || 0x1F - var avg = self.data.pdAvg - var params = self._calculateTreatParams(mask, avg) + var deviceId = (app.globalData.connectedDevice && app.globalData.connectedDevice.device_id) || null + var scannedAt = Date.now() - console.log('[SCAN] 自动发送治疗命令:', JSON.stringify(params)) - ble.setParams(params).then(function () { - return ble.startTreatment(mask) - }).then(function () { - wx.redirectTo({ - url: '/pages/treating/treating?regions=' + mask + - '&wavelength=2&duration=600000&mode=1' - }) - }).catch(function (err) { - self.setData({ error: err.error_msg || '启动治疗失败' }) - }) - }, + function proceed(config) { + var scanData = diagnosis.fromRedScan(avg, mask, deviceId, scannedAt) + var report = diagnosis.analyze(scanData, config || diagnosis.DEFAULT_CONFIG) + app.globalData.lastScanReport = report - _calculateTreatParams: function (mask, pdAvg) { - // TODO: 接入完整诊断算法(NR 归一化吸收率 → 匹配光谱+强度) - // 需要 PD→区域映射确认后实现 - return { - region_mask: mask, - wavelength: 2, - brightness: 200, - duration_ms: 600000, - mode: 1, - control: 0x02 + // Best-effort persist (also seeds the calibration dataset via raw_pd); never blocks navigation. + api.saveReport({ + device_id: report.device_id, + scanned_at: report.scanned_at, + regions: report.regions, + overall: report.overall, + recommend_mask: report.recommend_mask, + recommend_plan: report.recommend_plan, + raw_pd: report.raw_pd, + calibrated: report.calibrated + }).catch(function () {}) + + wx.redirectTo({ url: '/pages/scan-report/scan-report' }) } + + api.getDiagnosisConfig().then(function (cfg) { + proceed(cfg && cfg.levels ? cfg : diagnosis.DEFAULT_CONFIG) + }).catch(function () { + proceed(diagnosis.DEFAULT_CONFIG) + }) }, onUnload: function () { diff --git a/miniprogram/pages/light-info/light-info.js b/miniprogram/pages/light-info/light-info.js new file mode 100644 index 0000000..bb98c3e --- /dev/null +++ b/miniprogram/pages/light-info/light-info.js @@ -0,0 +1,34 @@ +var i18n = require('../../i18n/index') + +Page({ + data: { + statusBarHeight: 44, + // Ordered list of lights; strings live in i18n under lightInfo.lights[key], + // colors are presentation-only and stay in JS. + lights: [ + { key: 'red', color: '#E8503A' }, + { key: 'uv', color: '#7C4DFF' }, + { key: 'yellow', color: '#F5B841' }, + { key: 'infrared', color: '#8B2E3C' } + ], + // Parallel to i18n.lightInfo.modes (matched by index). + modeColors: ['#E8503A', '#7C4DFF', '#F5B841', '#8B2E3C', '#F08AA8'] + }, + + onLoad: function () { + var app = getApp() + this.setData({ statusBarHeight: app.globalData.statusBarHeight }) + }, + + onShow: function () { + i18n.bind(this) + }, + + onBack: function () { + wx.navigateBack({ + fail: function () { + wx.switchTab({ url: '/pages/profile/profile' }) + } + }) + } +}) diff --git a/miniprogram/pages/light-info/light-info.json b/miniprogram/pages/light-info/light-info.json new file mode 100644 index 0000000..226039d --- /dev/null +++ b/miniprogram/pages/light-info/light-info.json @@ -0,0 +1,4 @@ +{ + "navigationStyle": "custom", + "navigationBarTitleText": "光疗功效介绍" +} diff --git a/miniprogram/pages/light-info/light-info.wxml b/miniprogram/pages/light-info/light-info.wxml new file mode 100644 index 0000000..bb33004 --- /dev/null +++ b/miniprogram/pages/light-info/light-info.wxml @@ -0,0 +1,40 @@ + + + ‹ {{i18n.common.back}} + {{i18n.lightInfo.navTitle}} + {{i18n.lightInfo.headerSubtitle}} + + + + + {{i18n.lightInfo.lightsSectionTitle}} + + + + + {{i18n.lightInfo.lights[item.key].name}} + {{i18n.lightInfo.lights[item.key].wavelength}} + + + {{i18n.lightInfo.lights[item.key].effect}} + {{i18n.lightInfo.lights[item.key].desc}} + + {{i18n.lightInfo.suitsLabel}} + {{i18n.lightInfo.lights[item.key].suits}} + + + + + {{i18n.lightInfo.modesSectionTitle}} + + + + {{mode.name}} + {{mode.desc}} + + + + + {{i18n.lightInfo.disclaimer}} + + diff --git a/miniprogram/pages/light-info/light-info.wxss b/miniprogram/pages/light-info/light-info.wxss new file mode 100644 index 0000000..af9b6f7 --- /dev/null +++ b/miniprogram/pages/light-info/light-info.wxss @@ -0,0 +1,125 @@ +.section-title { + font-size: 30rpx; + font-weight: 600; + color: #333333; + margin-bottom: 24rpx; +} + +.section-title-gap { + margin-top: 44rpx; +} + +/* 光卡片 */ +.light-card { + background: #ffffff; + border-radius: 24rpx; + padding: 32rpx; + margin-bottom: 24rpx; + box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.04); +} + +.light-card-top { + display: flex; + align-items: center; + gap: 24rpx; + margin-bottom: 20rpx; +} + +.swatch { + width: 72rpx; + height: 72rpx; + border-radius: 50%; + flex-shrink: 0; + box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.12); +} + +.light-head { + display: flex; + flex-direction: column; + gap: 6rpx; +} + +.light-name { + font-size: 32rpx; + font-weight: 600; + color: #333333; +} + +.light-wave { + font-size: 24rpx; + color: #999999; +} + +.light-effect { + font-size: 28rpx; + font-weight: 600; + margin-bottom: 12rpx; +} + +.light-desc { + font-size: 26rpx; + color: #666666; + line-height: 1.6; + margin-bottom: 16rpx; +} + +.light-suits { + font-size: 24rpx; + line-height: 1.5; + padding-top: 16rpx; + border-top: 2rpx solid #f2f2f2; +} + +.suits-label { + color: #999999; + margin-right: 12rpx; +} + +.suits-text { + color: #555555; +} + +/* 护理模式 */ +.mode-grid { + display: flex; + flex-wrap: wrap; + gap: 20rpx; +} + +.mode-card { + width: calc(50% - 10rpx); + box-sizing: border-box; + background: #ffffff; + border-radius: 20rpx; + padding: 28rpx 24rpx; + box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.04); +} + +.mode-dot { + width: 40rpx; + height: 40rpx; + border-radius: 50%; + margin-bottom: 16rpx; +} + +.mode-name { + font-size: 28rpx; + font-weight: 600; + color: #333333; + margin-bottom: 8rpx; +} + +.mode-desc { + font-size: 24rpx; + color: #888888; + line-height: 1.5; +} + +/* 免责声明 */ +.disclaimer { + margin-top: 44rpx; + font-size: 22rpx; + color: #aaaaaa; + line-height: 1.6; + text-align: center; +} diff --git a/miniprogram/pages/manual-treatment/manual-treatment.js b/miniprogram/pages/manual-treatment/manual-treatment.js new file mode 100644 index 0000000..6a7176d --- /dev/null +++ b/miniprogram/pages/manual-treatment/manual-treatment.js @@ -0,0 +1,143 @@ +var ble = require('../../services/ble') +var i18n = require('../../i18n/index') + +// Manual care page: user directly picks a care function (-> single light +// wavelength), regions and duration, then starts — skipping the smart scan. +// +// Care function -> wavelength (protocol WAVELENGTH: IR=1, R=2, UV=3, Y=4). +// SINGLE light per session: buildVendorCommand only lights one wavelength. +// FUTURE EXTENSION: a composite/multi-light care mode would require the vendor +// command builder to accept several wavelengths at once — not implemented here. +Page({ + data: { + statusBarHeight: 44, + // fixed safe brightness for care sessions: 140 ≈ 55% (护理中档位). + // Do NOT expose a brightness slider (safety); the hardware also enforces + // its own current/thermal limits regardless of this value. + brightness: 140, + functions: [ + { id: 'rejuvenate', wavelength: 2, wl: 'red', color: '#E8503A' }, + { id: 'acne', wavelength: 3, wl: 'uv', color: '#7C4DFF' }, + { id: 'eventone', wavelength: 4, wl: 'yellow', color: '#F5B841' }, + { id: 'revitalize', wavelength: 1, wl: 'infrared', color: '#8B2E3C' } + ], + selectedFunctionId: 'rejuvenate', + selectedWavelength: 2, + // region mask mapping identical to treatment-setup: 右=0x01 左=0x02 上=0x04 中=0x08 下=0x10 + regions: [ + { key: 'right', mask: 0x01, checked: true }, + { key: 'left', mask: 0x02, checked: true }, + { key: 'top', mask: 0x04, checked: true }, + { key: 'middle', mask: 0x08, checked: true }, + { key: 'bottom', mask: 0x10, checked: true } + ], + // safety: a single session must NOT exceed 10 minutes (hardware caps at 10). + durations: [5, 10], + selectedMinutes: 10, + durationOptions: [], + submitting: false, + error: '' + }, + + onLoad: function () { + var app = getApp() + if (app && app.globalData && app.globalData.statusBarHeight) { + this.setData({ statusBarHeight: app.globalData.statusBarHeight }) + } + }, + + onShow: function () { + i18n.bind(this) + this.refreshDurationOptions() + }, + + // Duration labels carry a dynamic number, which WXML cannot substitute, so + // build the final strings in JS. Re-run on every onShow to follow locale. + refreshDurationOptions: function () { + var options = this.data.durations.map(function (min) { + return { min: min, label: i18n.t('manual.durationUnit', { min: min }) } + }) + this.setData({ durationOptions: options }) + }, + + onBack: function () { + wx.navigateBack({ + fail: function () { + wx.reLaunch({ url: '/pages/index/index' }) + } + }) + }, + + onSelectFunction: function (e) { + var id = e.currentTarget.dataset.id + var funcs = this.data.functions + var wavelength = this.data.selectedWavelength + for (var i = 0; i < funcs.length; i++) { + if (funcs[i].id === id) { + wavelength = funcs[i].wavelength + break + } + } + this.setData({ selectedFunctionId: id, selectedWavelength: wavelength }) + }, + + onToggleRegion: function (e) { + var idx = e.currentTarget.dataset.idx + var regions = this.data.regions + regions[idx].checked = !regions[idx].checked + this.setData({ regions: regions }) + }, + + onSelectDuration: function (e) { + var min = parseInt(e.currentTarget.dataset.min, 10) + this.setData({ selectedMinutes: min }) + }, + + onStart: function () { + var self = this + + if (!ble.isConnected()) { + self.setData({ error: i18n.t('common.deviceNotConnected') }) + return + } + + var mask = 0 + self.data.regions.forEach(function (r) { + if (r.checked) mask |= r.mask + }) + + if (mask === 0) { + wx.showToast({ title: i18n.t('manual.noRegionSelected'), icon: 'none' }) + return + } + + var wavelength = self.data.selectedWavelength + var durationMs = self.data.selectedMinutes * 60000 + + self.setData({ submitting: true, error: '' }) + + ble.setParams({ + region_mask: mask, + wavelength: wavelength, + brightness: self.data.brightness, + duration_ms: durationMs, + mode: 0, + control: 0x02 + }).then(function () { + return ble.startTreatment(mask) + }).then(function () { + self.setData({ submitting: false }) + wx.redirectTo({ + url: '/pages/treating/treating?regions=' + mask + + '&wavelength=' + wavelength + + '&duration=' + durationMs + + '&mode=0' + }) + }).catch(function (err) { + self.setData({ + submitting: false, + error: (err && err.error_msg) || i18n.t('manual.startFailed') + }) + }) + } +}) diff --git a/miniprogram/pages/manual-treatment/manual-treatment.json b/miniprogram/pages/manual-treatment/manual-treatment.json new file mode 100644 index 0000000..ba6be1a --- /dev/null +++ b/miniprogram/pages/manual-treatment/manual-treatment.json @@ -0,0 +1,4 @@ +{ + "navigationStyle": "custom", + "navigationBarTitleText": "手动护理" +} diff --git a/miniprogram/pages/manual-treatment/manual-treatment.wxml b/miniprogram/pages/manual-treatment/manual-treatment.wxml new file mode 100644 index 0000000..64eaa68 --- /dev/null +++ b/miniprogram/pages/manual-treatment/manual-treatment.wxml @@ -0,0 +1,58 @@ + + + ‹ {{i18n.common.back}} + {{i18n.manual.navTitle}} + {{i18n.manual.navSubtitle}} + + + + {{i18n.manual.functionTitle}} + + + + + {{i18n.manual.functions[item.id].name}} + {{i18n.common.wavelengths[item.wl]}} + {{i18n.manual.functions[item.id].benefit}} + + + + {{i18n.manual.regionTitle}} + + + {{i18n.common.regions[item.key]}} + + + + {{i18n.manual.durationTitle}} + + + {{item.label}} + + + + {{i18n.manual.safetyTip}} + + + + {{error}} + + diff --git a/miniprogram/pages/manual-treatment/manual-treatment.wxss b/miniprogram/pages/manual-treatment/manual-treatment.wxss new file mode 100644 index 0000000..6fe6c2f --- /dev/null +++ b/miniprogram/pages/manual-treatment/manual-treatment.wxss @@ -0,0 +1,125 @@ +.nav-back { + position: absolute; + left: 32rpx; + font-size: 30rpx; + color: #ffffff; +} + +.section-title { + font-size: 28rpx; + margin-top: 40rpx; +} + +/* care function cards */ +.func-grid { + display: flex; + flex-wrap: wrap; + gap: 20rpx; +} + +.func-card { + width: calc(50% - 10rpx); + box-sizing: border-box; + padding: 28rpx 24rpx; + background: #ffffff; + border: 4rpx solid #e5e5e5; + border-radius: 24rpx; +} + +.func-card.active { + background: #fdf2f8; +} + +.func-dot { + width: 40rpx; + height: 40rpx; + border-radius: 50%; + margin-bottom: 16rpx; +} + +.func-name { + font-size: 30rpx; + font-weight: 600; + color: #333333; + margin-bottom: 6rpx; +} + +.func-wl { + font-size: 24rpx; + margin-bottom: 10rpx; +} + +.func-benefit { + font-size: 22rpx; + color: #999999; + line-height: 1.5; +} + +/* region checkbox grid */ +.region-grid { + display: flex; + flex-wrap: wrap; + gap: 20rpx; + justify-content: center; +} + +.region-chip { + min-width: 140rpx; + padding: 20rpx 0; + text-align: center; + border: 4rpx solid #e5e5e5; + border-radius: 16rpx; + font-size: 26rpx; + color: #999999; + flex: 1 1 26%; +} + +.region-chip.active { + border-color: #E6508C; + background: rgba(230, 80, 140, 0.12); + color: #E6508C; +} + +/* duration segmented control */ +.segmented { + display: flex; + gap: 20rpx; +} + +.segment { + flex: 1; + padding: 24rpx 0; + text-align: center; + border: 4rpx solid #e5e5e5; + border-radius: 16rpx; + font-size: 28rpx; + color: #666666; +} + +.segment.active { + border-color: #E6508C; + background: #fdf2f8; + color: #E6508C; + font-weight: 600; +} + +.safety-tip { + margin: 32rpx 0 24rpx; + padding: 20rpx 24rpx; + background: #fff7e6; + border-radius: 16rpx; + font-size: 24rpx; + color: #ad6800; + line-height: 1.6; +} + +.error-text { + text-align: center; + color: #ff4d4f; + font-size: 26rpx; + margin-top: 16rpx; +} + +.btn-primary::after { + border: none; +} diff --git a/miniprogram/pages/scan-report/scan-report.js b/miniprogram/pages/scan-report/scan-report.js new file mode 100644 index 0000000..c6a50cf --- /dev/null +++ b/miniprogram/pages/scan-report/scan-report.js @@ -0,0 +1,184 @@ +var api = require('../../utils/api') +var ble = require('../../services/ble') +var diagnosis = require('../../services/diagnosis') +var i18n = require('../../i18n/index') + +var WAVE_CODE_KEY = { 1: 'infrared', 2: 'red', 3: 'uv', 4: 'yellow' } + +function pad2(n) { return (n < 10 ? '0' : '') + n } + +function formatTime(ms) { + if (!ms) ms = Date.now() + var d = new Date(ms) + return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate()) + + ' ' + pad2(d.getHours()) + ':' + pad2(d.getMinutes()) +} + +Page({ + data: { + statusBarHeight: 44, + loading: true, + calibrated: true, + hasProblem: false, + scannedAtText: '', + overallList: [], + regionList: [], + recommendList: [], + starting: false, + error: '' + }, + + onLoad: function (options) { + var app = getApp() + this.setData({ statusBarHeight: app.globalData.statusBarHeight }) + this._options = options || {} + }, + + onShow: function () { + i18n.bind(this) + this._loadReport() + }, + + onBack: function () { + wx.navigateBack({ fail: function () { wx.reLaunch({ url: '/pages/index/index' }) } }) + }, + + _loadReport: function () { + var self = this + var app = getApp() + var opts = self._options || {} + + if (opts.report_id) { + self.setData({ loading: true }) + api.getReport(opts.report_id).then(function (rep) { + self._render(self._normalize(rep)) + }).catch(function () { self._fallback() }) + return + } + if (app.globalData.lastScanReport) { + self._render(app.globalData.lastScanReport) + return + } + self.setData({ loading: true }) + api.getLatestReport().then(function (rep) { + // Backend ok() coerces null -> {}, so treat an empty payload as "no report". + if (self._hasContent(rep)) self._render(self._normalize(rep)) + else self._fallback() + }).catch(function () { self._fallback() }) + }, + + _hasContent: function (rep) { + if (!rep) return false + return !!((rep.regions && rep.regions.length) || + (rep.overall && rep.overall.length) || + (rep.recommend_plan && rep.recommend_plan.length)) + }, + + _fallback: function () { + // Preview/demo when no device data is available yet. + this._render(diagnosis.mockReport()) + }, + + _normalize: function (rep) { + if (!rep) return diagnosis.mockReport() + var scannedAt = 0 + if (typeof rep.scanned_at === 'number') scannedAt = rep.scanned_at + else if (rep.scanned_at) scannedAt = Date.parse(rep.scanned_at) || 0 + return { + device_id: rep.device_id || null, + scanned_at: scannedAt, + regions: rep.regions || [], + overall: rep.overall || [], + recommend_mask: rep.recommend_mask || 0, + recommend_plan: rep.recommend_plan || [], + raw_pd: rep.raw_pd || {}, + calibrated: !!rep.calibrated + } + }, + + _render: function (report) { + var d = i18n.getDict().report + var common = i18n.getDict().common + + function levelLabel(l) { + if (l >= 3) return d.level.high + if (l === 2) return d.level.mid + if (l === 1) return d.level.low + return d.noProblem + } + + var overallList = (report.overall || []).map(function (o) { + return { label: d.problems[o.type] || o.type, level: o.level, levelLabel: levelLabel(o.level) } + }) + + var regionList = (report.regions || []).map(function (r) { + return { + name: d.regionNames[r.region] || r.region, + level: r.level, + levelLabel: levelLabel(r.level), + problemLabel: r.top_problem ? (d.problems[r.top_problem] || r.top_problem) : d.noProblem, + hasProblem: !!r.top_problem + } + }) + + var defs = diagnosis.REGION_DEFS + var recommendList = (report.recommend_plan || []).map(function (p) { + var names = [] + for (var i = 0; i < defs.length; i++) { + if (p.mask & defs[i].mask) names.push(d.regionNames[defs[i].key] || defs[i].key) + } + var waveKey = WAVE_CODE_KEY[p.wavelength] + return { + label: d.problems[p.label_key] || '', + wavelengthLabel: (waveKey && common.wavelengths[waveKey]) || '', + regionsLabel: names.join('、'), + wavelength: p.wavelength, + mask: p.mask + } + }) + + this._report = report + this.setData({ + loading: false, + calibrated: !!report.calibrated, + hasProblem: overallList.length > 0, + scannedAtText: formatTime(report.scanned_at), + overallList: overallList, + regionList: regionList, + recommendList: recommendList, + error: '' + }) + }, + + onStartRecommend: function () { + var self = this + var report = self._report || {} + if (!ble.isConnected()) { + self.setData({ error: i18n.t('common.deviceNotConnected') }) + return + } + var plan = (report.recommend_plan && report.recommend_plan[0]) || null + var mask = plan ? plan.mask : (report.recommend_mask || 0x1F) + var wl = plan ? plan.wavelength : 2 + if (!mask) mask = 0x1F + + self.setData({ starting: true, error: '' }) + ble.setParams({ + region_mask: mask, wavelength: wl, brightness: 140, + duration_ms: 600000, mode: 1, control: 0x02 + }).then(function () { + return ble.startTreatment(mask) + }).then(function () { + wx.redirectTo({ + url: '/pages/treating/treating?regions=' + mask + + '&wavelength=' + wl + '&duration=600000&mode=1' + }) + }).catch(function (err) { + self.setData({ starting: false, error: (err && err.error_msg) || i18n.t('common.networkError') }) + }) + }, + + onManual: function () { + wx.redirectTo({ url: '/pages/manual-treatment/manual-treatment' }) + } +}) diff --git a/miniprogram/pages/scan-report/scan-report.json b/miniprogram/pages/scan-report/scan-report.json new file mode 100644 index 0000000..eb957e0 --- /dev/null +++ b/miniprogram/pages/scan-report/scan-report.json @@ -0,0 +1,4 @@ +{ + "navigationStyle": "custom", + "navigationBarTitleText": "扫描报告" +} diff --git a/miniprogram/pages/scan-report/scan-report.wxml b/miniprogram/pages/scan-report/scan-report.wxml new file mode 100644 index 0000000..46fe595 --- /dev/null +++ b/miniprogram/pages/scan-report/scan-report.wxml @@ -0,0 +1,66 @@ + + + ‹ {{i18n.common.back}} + {{i18n.report.title}} + {{i18n.report.subtitle}} + + + + + {{i18n.common.loading}} + + + + + + {{i18n.report.conceptNotice}} + + + {{i18n.report.scannedAt}}:{{scannedAtText}} + + + + {{i18n.report.overallTitle}} + + + {{item.label}} + {{item.levelLabel}} + + + {{i18n.report.noProblem}} + + + + + {{i18n.report.regionTitle}} + + {{item.name}} + + {{item.problemLabel}} + {{item.levelLabel}} + + + + + + + {{i18n.report.recommendTitle}} + + + + {{item.label}} + {{item.wavelengthLabel}} · {{item.regionsLabel}} + + + + + {{error}} + + + + + + + {{i18n.report.disclaimer}} + + diff --git a/miniprogram/pages/scan-report/scan-report.wxss b/miniprogram/pages/scan-report/scan-report.wxss new file mode 100644 index 0000000..25323cb --- /dev/null +++ b/miniprogram/pages/scan-report/scan-report.wxss @@ -0,0 +1,100 @@ +.page { + min-height: 100vh; + background: #f5f5f5; +} + +.loading-container { + display: flex; + flex-direction: column; + align-items: center; + padding-top: 120rpx; +} +.loading-spinner { + width: 56rpx; + height: 56rpx; + border: 6rpx solid #eee; + border-top-color: #E8503A; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} +.loading-text { margin-top: 20rpx; color: #999; font-size: 26rpx; } +@keyframes spin { to { transform: rotate(360deg); } } + +.concept-banner { + display: flex; + align-items: center; + background: #FFF7E6; + border: 1rpx solid #FFE1A8; + border-radius: 14rpx; + padding: 18rpx 22rpx; + margin-bottom: 20rpx; +} +.concept-icon { color: #E8971A; margin-right: 12rpx; font-size: 30rpx; } +.concept-text { color: #A66908; font-size: 24rpx; line-height: 1.4; flex: 1; } + +.scan-time { color: #999; font-size: 24rpx; margin: 0 6rpx 20rpx; } + +.card { + background: #fff; + border-radius: 18rpx; + padding: 28rpx 26rpx; + margin-bottom: 22rpx; + box-shadow: 0 4rpx 18rpx rgba(0,0,0,0.04); +} +.card-title { font-size: 30rpx; font-weight: 600; color: #333; margin-bottom: 20rpx; } + +.chip-row { display: flex; flex-wrap: wrap; } +.chip { + display: flex; + align-items: center; + border-radius: 999rpx; + padding: 10rpx 22rpx; + margin: 0 16rpx 16rpx 0; + background: #F3F4F6; +} +.chip-name { font-size: 26rpx; color: #333; } +.chip-lv { font-size: 22rpx; color: #fff; margin-left: 12rpx; padding: 2rpx 14rpx; border-radius: 999rpx; background: #bbb; } +.chip-lv1 .chip-lv { background: #6BBF59; } +.chip-lv2 .chip-lv { background: #F5B841; } +.chip-lv3 .chip-lv { background: #E8503A; } + +.empty-hint { color: #999; font-size: 26rpx; } + +.region-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 20rpx 0; + border-bottom: 1rpx solid #f0f0f0; +} +.region-row:last-child { border-bottom: none; } +.region-name { font-size: 28rpx; color: #333; } +.region-right { display: flex; align-items: center; } +.region-problem { font-size: 26rpx; color: #555; margin-right: 14rpx; } +.region-problem-none { color: #aaa; } +.region-lv { font-size: 22rpx; color: #fff; padding: 2rpx 14rpx; border-radius: 999rpx; background: #bbb; } +.region-lv1 { background: #6BBF59; } +.region-lv2 { background: #F5B841; } +.region-lv3 { background: #E8503A; } + +.rec-row { display: flex; align-items: flex-start; padding: 16rpx 0; } +.rec-dot { + width: 14rpx; height: 14rpx; border-radius: 50%; + background: #E8503A; margin-top: 12rpx; margin-right: 18rpx; flex-shrink: 0; +} +.rec-body { display: flex; flex-direction: column; } +.rec-label { font-size: 28rpx; color: #333; font-weight: 500; } +.rec-meta { font-size: 24rpx; color: #999; margin-top: 6rpx; } + +.error-text { color: #E8503A; font-size: 26rpx; text-align: center; margin: 10rpx 0; } + +.actions { margin-top: 12rpx; } +.actions .btn-primary, .actions .btn-secondary { margin-bottom: 20rpx; } + +.disclaimer { + color: #b0b0b0; + font-size: 22rpx; + line-height: 1.5; + text-align: center; + margin: 20rpx 10rpx 40rpx; +} diff --git a/miniprogram/pages/treatment-setup/treatment-setup.js b/miniprogram/pages/treatment-setup/treatment-setup.js index 9596e8a..ba01633 100644 --- a/miniprogram/pages/treatment-setup/treatment-setup.js +++ b/miniprogram/pages/treatment-setup/treatment-setup.js @@ -25,6 +25,10 @@ Page({ }) }, + onManual: function () { + wx.navigateTo({ url: '/pages/manual-treatment/manual-treatment' }) + }, + onLoad: function () { var app = getApp() this.setData({ statusBarHeight: app.globalData.statusBarHeight }) diff --git a/miniprogram/pages/treatment-setup/treatment-setup.wxml b/miniprogram/pages/treatment-setup/treatment-setup.wxml index 3282103..1a94989 100644 --- a/miniprogram/pages/treatment-setup/treatment-setup.wxml +++ b/miniprogram/pages/treatment-setup/treatment-setup.wxml @@ -37,6 +37,8 @@ 开始使用 + 手动选择波长护理 › + {{error}} diff --git a/miniprogram/services/diagnosis.js b/miniprogram/services/diagnosis.js new file mode 100644 index 0000000..48c4a50 --- /dev/null +++ b/miniprogram/services/diagnosis.js @@ -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 +} diff --git a/miniprogram/utils/api.js b/miniprogram/utils/api.js index a368155..2b82572 100644 --- a/miniprogram/utils/api.js +++ b/miniprogram/utils/api.js @@ -30,6 +30,12 @@ module.exports = { syncTreatment: function (data) { return http.post('/api/v1/treatment/sync', data) }, getRecordDetail: function (id) { return http.get('/api/v1/treatment/' + id) }, + // Scan report + diagnosis config (backend contract; see server scan-report routes) + saveReport: function (data) { return http.post('/api/v1/treatment/report', data) }, + getLatestReport: function (deviceId) { return http.get('/api/v1/treatment/report/latest', deviceId ? { device_id: deviceId } : {}) }, + getReport: function (id) { return http.get('/api/v1/treatment/report/' + id) }, + getDiagnosisConfig: function () { return http.get('/api/v1/treatment/diagnosis-config') }, + // Device commands getPendingCommands: function (deviceId) { return http.get('/api/v1/device/command/pending', { device_id: deviceId }) }, reportCommandResult: function (data) { return http.post('/api/v1/device/command/result', data) }