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.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.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.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) }