比较提交

..
39 次代码提交
作者 SHA1 备注 提交日期
Guoguo a9a1899b49 fix(ble): treat Byte17 != 0 as fitting-ok (matches firmware semantics)
Firmware reports fitting via 'Byte17 != 0', not a dedicated bit — during scan
Byte17 carries the wave marker (0x20/0x40/0x60/0x80), all non-zero, so it also
signals fitting-ok. The old 'byte17 & 0x1F == 1' test never matched (low 5 bits
are 0 when the wave marker occupies the high 3), so auto-scan waited forever on
fitting==1 and then mis-fired '未贴合失败'. Verified against real-device frames:
scan now collects and buckets all four wavelengths.
2026-07-28 18:43:01 -07:00
Guoguo edf15eb32a docs: fix stale region-layout comments to match measured mapping 2026-07-28 07:26:29 -07:00
Guoguo 21dccd2274 fix(mapping): correct region↔IO mapping per on-device measurement
Measured on real hardware: IO1=right IO2=left IO3=top IO4=middle IO5=bottom
(0x01=right 0x02=left 0x04=top 0x08=middle 0x10=bottom). The earlier verbal
'left=IO1/upper=IO2...' spec was wrong; this reverts to the original Phase-1
mapping, which the measurement confirms.

- protocol.js REGION constants + getRegionName + REGION_NAMES
- CRITICAL: buildVendorCommand ioMasks was built from REGION.* constants whose
  values just flipped, scrambling IO slot order; pin to literal [0x01..0x10]
- diagnosis.js REGION_DEFS key↔mask; pd_region_map values unchanged (PD is
  keyed by orientation, which is stable) — right=PD5 left=PD1 top=PD2
  middle=PD3,6,7 bottom=PD4
- common.js region labels back to right/left/top/middle/bottom (上/中/下)
- treatment-setup / manual-treatment / treating / scan-report / ble-debug
  face-map bindings and REGION_MAP realigned
- verified: mock pipeline lands left→IO2, middle→IO4 correctly
2026-07-28 07:22:36 -07:00
Guoguo 74b0c5fa86 feat(debug): temporary BLE debug page for on-device verification
- entry in profile menu, visible only while a device is connected
- live FFE4 readout: 7x PD, VBAT, fitting, scan wave marker, run state
- tap a face-map region to fire a 2s single-region treat command
  (verifies region-to-IO mapping on the physical mask)
- wavelength selector (red/ir/uv/yellow) to verify color byte order
- scan command / full-face 2s / stop buttons, hex log of every write
- intentionally not i18n'd; remove page before release
2026-07-28 06:58:03 -07:00
Guoguo c0de0b03a2 fix(ui): show manual care entry only when smart mode is selected 2026-07-28 06:47:36 -07:00
Guoguo 431d4058a8 feat(ui): use face-map layout for report zones and manual region picker; restrict manual care to smart mode
- scan-report: replace zone card grid with the same oval face-map layout as
  treatment-setup, zones tinted by recommended light with mode label; restore
  the per-light legend under the map
- manual-treatment: region picker now uses the same face-map layout
- manual care mode selection is smart-mode only: entry link hidden for
  non-subscribers and the page itself checks subscription (free mode passes
  since the backend reports it as active)
2026-07-28 06:44:34 -07:00
Guoguo 39177e5ff4 ui: 报告分区/手动区域选择改用「使用设置」同款卡片栅格布局(弃面罩大矩形);修复使用设置脸型图label-idx错位 2026-07-28 06:35:37 -07:00
Guoguo 9745b6d7b8 chore: 版本号 v0.2.0 → v0.2.1 2026-07-28 06:24:21 -07:00
Guoguo 88ca7dec6b feat: 治疗亮度全路径统一204(80%)——手动模式140与普通模式200一并对齐 2026-07-28 05:01:05 -07:00
Guoguo dff82a6a82 feat(diagnosis): 回填固件确认的PD区域映射(左PD1/中上PD2/中PD3,6,7/中下PD4/右PD5) 2026-07-28 04:58:48 -07:00
Guoguo a67a7357cf chore(sql): 增量迁移脚本 scan_reports建表+diagnosis_config新种子(幂等) 2026-07-28 04:48:24 -07:00
Guoguo 6b91ebe35d feat(diagnosis): 按新协议实现多光谱诊断算法+分区差异化下发+报告面罩图
- 新协议《协议简述-添加扫描时光色》落地: FFE4 Byte17 高3位=扫描光色(1红/2红外/3紫外/4暖黄),
  低5位保留贴合状态; Byte18=0xFF 故障; auto-scan 按光色分桶采集(旧固件无标识归 red 桶降级)
- diagnosis.js 重写: PD极性(值大=吸收多,固件已确认) NR=本区/本色5区平均-1;
  每区4色argmax>0.10判定; 3路PD区几何平均+MAD剔奇异(3σ对n=3失效,审计实证);
  严重度0.05/0.15/0.30仅展示; 全参数走 diagnosis_config 热调(极性/阈值/PD映射/亮度)
- buildVendorCommand 支持 plan 多区多色单命令下发(每IO独立 红/红外/紫外/暖黄);
  builder层安全硬上限 治疗亮度≤204(80%) 时长≤600s
- 区域映射修正: 掩码=IO纵列 左0x01/中上0x02/中0x04/中下0x08/右0x10,
  修复 treatment-setup/manual/treating/getRegionName/i18n 共7处标签错位, FULL_FACE 0x7F→0x1F
- 扫描报告页: 新增分区面罩示意图(5区按推荐光着色+模式名+严重度,点击跳光疗介绍),
  推荐护理改一条命令多色下发(亮度204); 扫描中设备故障即时中止
- schema.sql diagnosis_config 种子更新(新结构); i18n 清理死键,中英对齐25/24键
2026-07-28 04:36:51 -07:00
Guoguo b5416f56af chore: 版本号 v0.1.8 → v0.2.0 2026-07-19 06:12:35 -07:00
Guoguo 4466c53c7b feat(i18n): 剩余15页全量接入中英(登录/注册/扫码/绑定/佩戴/扫描/治疗/订阅/帮助等)
- 3 个并行 agent 各管 5 页,互不重叠命名空间
- 全 App 18 页均 i18n.bind,22 命名空间/400 键中英对齐
- 动态文案(时长/天数/价格/区域)JS 内 i18n.t 计算;区域标签复用 common.regions
2026-07-19 06:00:04 -07:00
Guoguo f5eaf05067 chore(i18n): 为剩余15页预建命名空间 stub 并注册(并行 retrofit 脚手架) 2026-07-19 05:45:09 -07:00
Guoguo 28c711cd00 fix: onPageNotFound 兜底防空页崩溃 + 首页/记录页接入 i18n(语言切换覆盖全部3个tab) 2026-07-19 05:31:19 -07:00
Guoguo 8e11b2da10 fix: 报告 device_id 用 ble.getDeviceId()(修 P2 空归属)+手动入口接入 i18n(P3) 2026-07-18 20:25:57 -07:00
Guoguo a7299708e2 feat(server): 扫描报告存储(scan_reports)+诊断阈值配置(diagnosis_config)+admin 编辑
- scan_reports 表+dao+3个报告接口(POST/latest/:id,requireUser)
- GET /treatment/diagnosis-config 下发实时阈值(免发版调参)
- admin SettingsView 加 diagnosis_config JSON 编辑器
- 注意: schema.sql 变更需手动 TencentDB 迁移
2026-07-18 20:17:09 -07:00
Guoguo f2ee086cc8 feat: 扫描报告页+手动选波长护理+光疗功效介绍页+诊断框架(占位阈值)
- services/diagnosis.js: 诊断流水线框架,阈值/PD映射为占位待标定,支持单波长降级
- auto-scan: 扫描完成改为出报告让用户选择护理(替代原自动开始治疗)
- 新增 scan-report/manual-treatment/light-info 三页,全部接入 i18n
- api.js: 新增 report/diagnosis-config 接口(对接后端契约)
2026-07-18 20:17:09 -07:00
Guoguo d8eb6677c6 feat(i18n): 中英文框架(命名空间字典+语言切换)+profile 接入参考+注册3个新页面 2026-07-18 20:07:13 -07:00
Guoguo cc5159ebb7 feat: hide trial/subscription text on bind-success when smart_mode_free 2026-07-02 06:43:09 -07:00
Guoguo cdc6348ac4 feat: hide subscription card and menu on profile page when smart_mode_free 2026-07-02 06:42:12 -07:00
Guoguo a53e6c521c feat: hide subscription entry on index page when smart_mode_free 2026-07-02 06:41:39 -07:00
Guoguo 5775568cd6 feat: add loading animation on reconnect button, 5s timeout 2026-07-02 06:06:11 -07:00
Guoguo dae50a74fd fix: remove scan duration hint text from auto-scan page 2026-07-02 06:05:29 -07:00
Guoguo 0341e62726 feat: add smart_mode_free toggle — all users get smart mode when enabled
Server: subscription API returns active when smart_mode_free is true.
Admin console: add toggle switch in settings page under feature flags.
Default: true (free for all users during promotion period).
2026-07-02 06:05:04 -07:00
Guoguo 94c9af146a chore: bump version to v0.1.8 2026-07-01 05:43:36 -07:00
Guoguo 784af52b7a fix: require seenRunState1 before judging fitting failure, prevent initial frame false trigger 2026-07-01 05:34:05 -07:00
Guoguo 06e8c9831c fix: skip fitting/run_state monitoring in normal mode (mode=0) 2026-07-01 05:29:22 -07:00
Guoguo 515153f134 feat: auto-scan 3-phase state machine with proper PD collection timing
- Phase 1 (waiting_fit): after sending control=0x01, wait for fitting=1
- Phase 2 (collecting): once fitting=1 AND run_state=1, start PD accumulation
- Phase 3 (done): run_state 1→0, calculate averages, auto-send treatment
- Check start-collecting in BOTH fitting and run_state handlers
- fitting=0 + run_state 1→0 = fitting failure, prompt retry
- Timeout 3 minutes (was 30s), auto-start treatment on completion
2026-06-29 05:36:12 -07:00
Guoguo b7b76a675d chore: remove dead regions param from wear-check 2026-06-29 05:29:51 -07:00
Guoguo f85925ecd1 fix: correct page flow — wear-check before mode select, smart mode to auto-scan
- wear-check onNext → treatment-setup (was auto-scan)
- treatment-setup smart mode → auto-scan (was wear-check)
Flow: index → wear-check → treatment-setup → normal: treat / smart: auto-scan → treat
2026-06-29 05:28:05 -07:00
Guoguo 74591d4da2 fix: audit fixes — dedupe run_state logs, stop device on scan exit
- Only log run_state when value changes (avoid flooding console)
- Send stopTreatment on unload if scan is in progress (stop device LEDs)
2026-06-29 04:02:16 -07:00
Guoguo f5cd882986 feat: redesign auto-scan as detection page with manual trigger and PD averaging
- Add "start detection" button (not auto-start on page load)
- Send control=0x01 only when user clicks button
- Accumulate PD values across all ADC frames during scan
- Monitor run_state: wait for 0x01→0x00 transition as completion signal
- Calculate PD averages on completion
- Send treatment command (control=0x02) on "next" with calculated params
- TODO: plug in full NR diagnosis algorithm when PD→region mapping confirmed
2026-06-29 03:58:40 -07:00
Guoguo 2995b0ee02 fix: smart mode goes to wear-check first instead of directly to auto-scan 2026-06-29 03:57:31 -07:00
Guoguo f9141b25d0 refactor: wear-check is now pure manual confirm, no BLE commands
Remove all fitting detection logic (setParams, fitting event listener,
timeout). User manually confirms wearing, then proceeds to detection
page. No BLE commands sent on this page.
2026-06-29 03:57:05 -07:00
Guoguo 52f3ef971b fix: auto-scan onNext goes directly to treating instead of looping back to setup 2026-06-24 19:38:16 -07:00
Guoguo c5afce59d5 fix: restore fitting check control to 0x01 (scan/detect mode) 2026-06-24 18:55:27 -07:00
Guoguo 5cd3dc1e5d fix: fitting check uses control=IDLE to avoid triggering scan sequence 2026-06-24 18:49:17 -07:00
修改 90 个文件,包含 3672 行新增540 行删除
+50 -3
查看文件
@@ -105,6 +105,15 @@
<view class="toggle-dot"></view>
</view>
</view>
<view class="toggle-item">
<view class="toggle-info">
<text class="toggle-label">智能模式免费</text>
<text class="toggle-desc">开启后所有用户可免费使用智能模式</text>
</view>
<view class="toggle-switch" :class="{ on: settings.smart_mode_free }" @click="settings.smart_mode_free = !settings.smart_mode_free">
<view class="toggle-dot"></view>
</view>
</view>
<view class="toggle-item">
<view class="toggle-info">
<text class="toggle-label">维护模式</text>
@@ -116,6 +125,12 @@
</view>
</view>
</view>
<view class="section-card">
<view class="section-title">智能诊断阈值diagnosis_config</view>
<view class="config-desc">下发给小程序的实时诊断阈值改此处即可调参无需发版levels 为归一化吸收阈值problem_wavelength 将问题类型映射到波长码 IR=1,R=2,UV=3,Y=4保存前会校验 JSON 格式</view>
<textarea class="config-editor" v-model="diagnosisConfigText" placeholder='{"calibrated": false, ...}' />
</view>
</view>
<view class="save-bar">
@@ -142,10 +157,12 @@ export default {
enable_register: true,
enable_binding: true,
enable_free_mode: true,
smart_mode_free: true,
maintenance_mode: false
},
timezoneOptions: ['Asia/Shanghai', 'Asia/Tokyo', 'America/New_York', 'Europe/London'],
passwordForm: { old_password: '', new_password: '', confirm_password: '' }
passwordForm: { old_password: '', new_password: '', confirm_password: '' },
diagnosisConfigText: ''
}
},
mounted() {
@@ -156,15 +173,25 @@ export default {
try {
const data = await get('/api/v1/admin/settings')
if (data) {
Object.assign(this.settings, data)
const { diagnosis_config, ...rest } = data
Object.assign(this.settings, rest)
this.diagnosisConfigText = JSON.stringify(diagnosis_config || {}, null, 2)
}
} catch (e) {
uni.showToast({ title: '加载失败', icon: 'none' })
}
},
async onSave() {
let diagnosisConfig
try {
await post('/api/v1/admin/settings', this.settings)
diagnosisConfig = JSON.parse(this.diagnosisConfigText || '{}')
} catch (e) {
uni.showToast({ title: '诊断阈值 JSON 格式错误', icon: 'none' })
return
}
try {
const payload = { ...this.settings, diagnosis_config: diagnosisConfig }
await post('/api/v1/admin/settings', payload)
uni.showToast({ title: '保存成功', icon: 'success' })
} catch (e) {
uni.showToast({ title: '保存失败', icon: 'none' })
@@ -293,6 +320,26 @@ export default {
font-size: 16px;
}
.config-desc {
font-size: 12px;
color: #999;
line-height: 1.6;
margin-bottom: 12px;
}
.config-editor {
width: 100%;
min-height: 200px;
border: 1px solid #d9d9d9;
border-radius: 6px;
padding: 12px;
font-size: 13px;
font-family: 'Menlo', 'Consolas', monospace;
color: #333;
box-sizing: border-box;
line-height: 1.5;
}
.toggle-list {
display: flex;
flex-direction: column;
二进制文件未显示。
+12
查看文件
@@ -1,4 +1,5 @@
var http = require('./utils/request')
var i18n = require('./i18n/index')
App({
globalData: {
@@ -12,9 +13,20 @@ App({
onLaunch: function () {
var sysInfo = wx.getSystemInfoSync()
this.globalData.statusBarHeight = sysInfo.statusBarHeight || 44
i18n.getLocale()
i18n.applyTabBar()
this.checkLogin()
},
// Safety net: if the framework ever tries to open an unknown/empty route
// (e.g. a stale page stack after the pages list changed), fall back to home
// instead of surfacing a hard "page not found" crash.
onPageNotFound: function (res) {
if (res && res.isEntryPage) {
wx.reLaunch({ url: '/pages/index/index', fail: function () {} })
}
},
checkLogin: function () {
var token = wx.getStorageSync('token')
if (!token) {
+6 -2
查看文件
@@ -17,7 +17,11 @@
"pages/profile/profile",
"pages/history/history",
"pages/help/help",
"pages/contact/contact"
"pages/contact/contact",
"pages/light-info/light-info",
"pages/manual-treatment/manual-treatment",
"pages/scan-report/scan-report",
"pages/ble-debug/ble-debug"
],
"window": {
"navigationBarBackgroundColor": "#ffffff",
@@ -62,4 +66,4 @@
],
"style": "v2",
"sitemapLocation": "sitemap.json"
}
}
+120
查看文件
@@ -0,0 +1,120 @@
// Lightweight i18n for WeChat Mini Program (no official i18n API).
//
// Usage in a page:
// var i18n = require('../../i18n/index')
// onShow: function () { i18n.bind(this) } // injects `i18n` + `locale` into page data
// WXML: {{i18n.common.loading}} / {{i18n.profile.title}}
// JS strings: i18n.t('common.loading')
//
// Locale is persisted in storage; switching locale notifies listeners and the
// convention is that every page re-runs i18n.bind(this) in onShow, so tab pages
// pick up the new language the next time they are shown.
var locales = require('./locales/index')
var STORAGE_KEY = 'app_locale'
var DEFAULT_LOCALE = 'zh'
var SUPPORTED = ['zh', 'en']
var LOCALE_NAMES = { zh: '中文', en: 'English' }
var _listeners = []
var _current = null
function getSystemLocale() {
try {
var sys = wx.getSystemInfoSync()
var lang = (sys.language || '').toLowerCase()
return lang.indexOf('en') === 0 ? 'en' : 'zh'
} catch (e) {
return DEFAULT_LOCALE
}
}
function getLocale() {
if (_current) return _current
var saved = ''
try { saved = wx.getStorageSync(STORAGE_KEY) } catch (e) {}
_current = SUPPORTED.indexOf(saved) !== -1 ? saved : getSystemLocale()
return _current
}
function setLocale(locale) {
if (SUPPORTED.indexOf(locale) === -1 || locale === _current) return
_current = locale
try { wx.setStorageSync(STORAGE_KEY, locale) } catch (e) {}
applyTabBar()
for (var i = 0; i < _listeners.length; i++) {
try { _listeners[i](locale) } catch (e) {}
}
}
function onChange(fn) { if (typeof fn === 'function') _listeners.push(fn) }
function offChange(fn) {
_listeners = _listeners.filter(function (f) { return f !== fn })
}
function getDict() {
var loc = getLocale()
return locales[loc] || locales[DEFAULT_LOCALE]
}
function _lookup(dict, key) {
if (!dict) return undefined
var parts = key.split('.')
var cur = dict
for (var i = 0; i < parts.length; i++) {
if (cur == null) return undefined
cur = cur[parts[i]]
}
return cur
}
// t('profile.title', {name: 'x'}) — falls back to zh, then to the raw key.
function t(key, params) {
var loc = getLocale()
var val = _lookup(locales[loc], key)
if (val === undefined) val = _lookup(locales[DEFAULT_LOCALE], key)
if (typeof val !== 'string') return val === undefined ? key : val
if (params) {
val = val.replace(/\{(\w+)\}/g, function (m, k) {
return params[k] !== undefined ? params[k] : m
})
}
return val
}
// Inject the full dictionary + current locale into a page/component's data.
function bind(page) {
if (page && typeof page.setData === 'function') {
page.setData({ i18n: getDict(), locale: getLocale() })
}
}
// Refresh the bottom tabBar labels to the current locale.
function applyTabBar() {
var d = getDict()
var tab = (d && d.common && d.common.tab) || {}
var items = [
{ index: 0, text: tab.home },
{ index: 1, text: tab.records },
{ index: 2, text: tab.mine }
]
items.forEach(function (it) {
if (!it.text) return
wx.setTabBarItem({ index: it.index, text: it.text, fail: function () {} })
})
}
module.exports = {
SUPPORTED: SUPPORTED,
LOCALE_NAMES: LOCALE_NAMES,
getLocale: getLocale,
setLocale: setLocale,
getSystemLocale: getSystemLocale,
onChange: onChange,
offChange: offChange,
getDict: getDict,
bind: bind,
applyTabBar: applyTabBar,
t: t
}
+42
查看文件
@@ -0,0 +1,42 @@
// Strings for the auto-scan (smart scan) page. Keep zh/en with identical key structure.
// Pre/Post pairs are concatenated around a frame count in WXML (WXML cannot do {param}).
module.exports = {
zh: {
title: '智能检测',
subReady: '准备就绪',
subWaiting: '等待佩戴贴合...',
subCollectingPre: '采集数据中 (',
subCollectingPost: '帧)...',
subDone: '检测完成,正在启动...',
readyHint: '设备已佩戴,点击下方按钮开始智能检测',
startBtn: '开始检测',
waitingStatus: '正在检测面膜贴合状态,请确保设备正确佩戴...',
collectingStatusPre: '已贴合,正在采集光谱数据... 已收到 ',
collectingStatusPost: ' 帧',
doneStatusPre: '检测完成(共 ',
doneStatusPost: ' 帧),正在启动治疗...',
notWorn: '面膜未佩戴好,请重新佩戴后再试',
timeout: '检测超时,未收到数据,请重试',
deviceFault: '设备异常,扫描已停止,请重启设备后再试',
cmdFailed: '检测命令发送失败'
},
en: {
title: 'Smart Scan',
subReady: 'Ready',
subWaiting: 'Waiting for fit...',
subCollectingPre: 'Collecting data (',
subCollectingPost: ' frames)...',
subDone: 'Scan complete, starting...',
readyHint: 'Device is on. Tap the button below to start the smart scan',
startBtn: 'Start Scan',
waitingStatus: 'Checking mask fit, please make sure the device is worn correctly...',
collectingStatusPre: 'Fitted. Collecting spectral data... received ',
collectingStatusPost: ' frames',
doneStatusPre: 'Scan complete (',
doneStatusPost: ' frames), starting treatment...',
notWorn: 'The mask is not worn properly. Please re-fit and try again',
timeout: 'Scan timed out with no data. Please try again',
deviceFault: 'Device error — scan stopped. Please restart the device and try again',
cmdFailed: 'Failed to send scan command'
}
}
+23
查看文件
@@ -0,0 +1,23 @@
// Strings for the bindSuccess page. Fill zh/en with identical key structure.
module.exports = {
zh: {
title: '绑定成功',
subtitle: '设备已成功绑定',
successTitle: '绑定成功!',
freeModeOpen: '智能模式已开放',
trialGranted: '恭喜获得{days}天智能模式试用',
trialSub: '体验结束后可订阅继续使用',
startUsing: '开始使用',
goHome: '返回首页'
},
en: {
title: 'Bound Successfully',
subtitle: 'Device bound successfully',
successTitle: 'Bound Successfully!',
freeModeOpen: 'Smart mode unlocked',
trialGranted: 'You got a {days}-day Smart mode trial',
trialSub: 'Subscribe to keep using it after the trial ends',
startUsing: 'Get Started',
goHome: 'Back to Home'
}
}
+33
查看文件
@@ -0,0 +1,33 @@
// Strings for the bleConnect page. Fill zh/en with identical key structure.
module.exports = {
zh: {
title: '蓝牙连接',
searchingSub: '正在搜索设备...',
connectingSub: '正在连接设备...',
bindingSub: '正在绑定...',
failedSub: '连接失败',
bluetoothTip: '请确保手机蓝牙已开启',
searching: '搜索中...',
pairing: '正在配对...',
failed: '连接失败',
scanTimeout: '搜索超时,请确认设备已开机并在附近',
confirmFailed: '后台确认绑定失败',
bindFailed: '设备绑定失败',
bindCmdFailed: '绑定命令失败'
},
en: {
title: 'Bluetooth Connection',
searchingSub: 'Searching for device...',
connectingSub: 'Connecting to device...',
bindingSub: 'Binding...',
failedSub: 'Connection failed',
bluetoothTip: 'Please make sure Bluetooth is enabled on your phone',
searching: 'Searching...',
pairing: 'Pairing...',
failed: 'Connection failed',
scanTimeout: 'Search timed out. Please make sure the device is powered on and nearby',
confirmFailed: 'Failed to confirm binding on the server',
bindFailed: 'Device binding failed',
bindCmdFailed: 'Binding command failed'
}
}
+69
查看文件
@@ -0,0 +1,69 @@
// Shared strings used across many pages.
module.exports = {
zh: {
tab: { home: '首页', records: '记录', mine: '我的' },
loading: '加载中...',
confirm: '确定',
cancel: '取消',
save: '保存',
saving: '保存中...',
back: '返回',
retry: '重试',
close: '关闭',
done: '完成',
start: '开始',
stop: '停止',
unknown: '未知',
saveSuccess: '保存成功',
saveFailed: '保存失败',
networkError: '网络异常,请重试',
deviceNotConnected: '设备未连接',
// 区域 = IO↔物理位置(实测):right=IO1(0x01) left=IO2(0x02) top=IO3(0x04)
// middle=IO4(0x08) bottom=IO5(0x10)。语义以掩码为准。
regions: {
right: '右区',
left: '左区',
top: '上区',
middle: '中区',
bottom: '下区'
},
wavelengths: {
red: '红光',
infrared: '红外光',
uv: '紫外光',
yellow: '黄光'
}
},
en: {
tab: { home: 'Home', records: 'Records', mine: 'Me' },
loading: 'Loading...',
confirm: 'OK',
cancel: 'Cancel',
save: 'Save',
saving: 'Saving...',
back: 'Back',
retry: 'Retry',
close: 'Close',
done: 'Done',
start: 'Start',
stop: 'Stop',
unknown: 'Unknown',
saveSuccess: 'Saved',
saveFailed: 'Save failed',
networkError: 'Network error, please retry',
deviceNotConnected: 'Device not connected',
regions: {
right: 'Right',
left: 'Left',
top: 'Top',
middle: 'Center',
bottom: 'Bottom'
},
wavelengths: {
red: 'Red',
infrared: 'Infrared',
uv: 'UV',
yellow: 'Yellow'
}
}
}
+13
查看文件
@@ -0,0 +1,13 @@
// Strings for the contact page. Fill zh/en with identical key structure.
module.exports = {
zh: {
title: '联系我们',
placeholderText: '内容建设中',
placeholderSub: '联系方式正在整理,敬请期待'
},
en: {
title: 'Contact Us',
placeholderText: 'Coming soon',
placeholderSub: 'Contact details are being prepared. Stay tuned.'
}
}
+13
查看文件
@@ -0,0 +1,13 @@
// Strings for the help page. Fill zh/en with identical key structure.
module.exports = {
zh: {
title: '使用帮助',
placeholderText: '内容建设中',
placeholderSub: '帮助文档正在编写,敬请期待'
},
en: {
title: 'Help',
placeholderText: 'Coming soon',
placeholderSub: 'The help guide is being written. Stay tuned.'
}
}
+31
查看文件
@@ -0,0 +1,31 @@
// "记录" (history) page strings.
module.exports = {
zh: {
statTotal: '累计次数',
statHours: '累计时长',
statMonth: '本月次数',
recentTitle: '最近记录',
smart: '✨ 智能',
normal: '🔄 普通',
empty: '暂无使用记录',
loadingMore: '加载更多...',
durationMin: '{min}分钟',
today: '今天',
yesterday: '昨天',
dateMd: '{m}月{d}日'
},
en: {
statTotal: 'Total Sessions',
statHours: 'Total Hours',
statMonth: 'This Month',
recentTitle: 'Recent Records',
smart: '✨ Smart',
normal: '🔄 Normal',
empty: 'No records yet',
loadingMore: 'Loading more...',
durationMin: '{min} min',
today: 'Today',
yesterday: 'Yesterday',
dateMd: '{m}/{d}'
}
}
+43
查看文件
@@ -0,0 +1,43 @@
// "首页" (index) page strings.
module.exports = {
zh: {
noDevice: '还没有绑定设备',
addDevice: '扫码添加设备',
myDevice: '我的设备',
connected: '已连接',
disconnected: '未连接',
reconnect: '重新连接',
smartRemain: '智能模式 · 剩余{days}天',
notSubscribed: '未订阅智能模式',
smartOpen: '智能模式已开放',
start: '开始使用',
deviceManage: '设备管理',
connectFailed: '连接失败',
unbindAction: '解绑当前设备',
unbindConfirmTitle: '确认解绑',
unbindConfirmText: '解绑后将无法使用该设备,确定要解绑吗?',
unbindLoading: '解绑中...',
unbindDone: '已解绑',
unbindFailed: '解绑失败'
},
en: {
noDevice: 'No device bound yet',
addDevice: 'Scan to add device',
myDevice: 'My Device',
connected: 'Connected',
disconnected: 'Disconnected',
reconnect: 'Reconnect',
smartRemain: 'Smart Mode · {days} days left',
notSubscribed: 'Smart Mode not subscribed',
smartOpen: 'Smart Mode available',
start: 'Start',
deviceManage: 'Manage Device',
connectFailed: 'Connection failed',
unbindAction: 'Unbind current device',
unbindConfirmTitle: 'Confirm Unbind',
unbindConfirmText: 'You will not be able to use this device after unbinding. Continue?',
unbindLoading: 'Unbinding...',
unbindDone: 'Unbound',
unbindFailed: 'Unbind failed'
}
}
+61
查看文件
@@ -0,0 +1,61 @@
// Merges per-namespace dictionaries into { zh: {...}, en: {...} }.
// Each namespace file exports { zh: {...}, en: {...} } and owns its own strings,
// so parallel work on different features never touches the same file.
var common = require('./common')
var profile = require('./profile')
var lightInfo = require('./lightInfo')
var manual = require('./manual')
var report = require('./report')
var home = require('./home')
var history = require('./history')
var login = require('./login')
var register = require('./register')
var scan = require('./scan')
var bleConnect = require('./bleConnect')
var bindSuccess = require('./bindSuccess')
var wearCheck = require('./wearCheck')
var autoScan = require('./autoScan')
var setup = require('./setup')
var treating = require('./treating')
var treatmentDone = require('./treatmentDone')
var subPrompt = require('./subPrompt')
var subPlans = require('./subPlans')
var subSuccess = require('./subSuccess')
var help = require('./help')
var contact = require('./contact')
var namespaces = {
common: common,
profile: profile,
lightInfo: lightInfo,
manual: manual,
report: report,
home: home,
history: history,
login: login,
register: register,
scan: scan,
bleConnect: bleConnect,
bindSuccess: bindSuccess,
wearCheck: wearCheck,
autoScan: autoScan,
setup: setup,
treating: treating,
treatmentDone: treatmentDone,
subPrompt: subPrompt,
subPlans: subPlans,
subSuccess: subSuccess,
help: help,
contact: contact
}
var zh = {}
var en = {}
for (var ns in namespaces) {
if (!namespaces.hasOwnProperty(ns)) continue
zh[ns] = namespaces[ns].zh || {}
en[ns] = namespaces[ns].en || {}
}
module.exports = { zh: zh, en: en }
+95
查看文件
@@ -0,0 +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: {
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.'
}
}
+21
查看文件
@@ -0,0 +1,21 @@
// Strings for the login page. Fill zh/en with identical key structure.
module.exports = {
zh: {
wechatLogin: '微信登录',
welcome: '欢迎使用LumiFlow',
desc: '登录后即可使用全部功能',
agreement: '登录即表示同意《用户协议》和《隐私政策》',
loginFailed: '登录失败',
agreementTitle: '提示',
agreementContent: '用户协议和隐私政策内容建设中'
},
en: {
wechatLogin: 'WeChat Login',
welcome: 'Welcome to LumiFlow',
desc: 'Log in to unlock all features',
agreement: 'By logging in, you agree to the Terms of Service and Privacy Policy',
loginFailed: 'Login failed',
agreementTitle: 'Notice',
agreementContent: 'Terms of Service and Privacy Policy are coming soon'
}
}
+46
查看文件
@@ -0,0 +1,46 @@
// 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: {
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: '启动失败',
entryLink: '手动选择波长护理 ',
smartOnly: '手动选择护理模式为智能模式专属,开通订阅后即可使用'
},
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',
entryLink: 'Manual wavelength care ',
smartOnly: 'Choosing a care mode manually is a Smart Mode feature. Subscribe to unlock it.'
}
}
+69
查看文件
@@ -0,0 +1,69 @@
// "我的" page strings + language switcher.
module.exports = {
zh: {
notLoggedIn: '未登录',
editProfile: '编辑资料',
editProfileTitle: '编辑资料',
changeAvatar: '点击更换头像',
nickname: '昵称',
nicknamePlaceholder: '请输入昵称',
nicknameRequired: '昵称不能为空',
smartMode: '智能模式',
smartModeOpen: '已开放',
free: '免费',
inUse: '使用中',
remainDays: '剩余 {days}天',
notSubscribed: '未订阅',
goSubscribe: '去订阅 ',
menuDevice: '我的设备',
bound: '已绑定',
unbound: '未绑定',
menuHistory: '使用记录',
menuSubscription: '订阅管理',
menuHelp: '使用帮助',
menuContact: '联系我们',
menuLightInfo: '光疗功效介绍',
language: '语言 / Language',
logout: '退出登录',
logoutConfirmTitle: '退出登录',
logoutConfirmText: '确定要退出登录吗?',
unbindConfirmTitle: '确认解绑',
unbindConfirmText: '解绑后将无法使用该设备,确定要解绑吗?',
unbindAction: '解绑当前设备',
unbindDone: '已解绑',
unbindFailed: '解绑失败'
},
en: {
notLoggedIn: 'Not logged in',
editProfile: 'Edit',
editProfileTitle: 'Edit Profile',
changeAvatar: 'Tap to change avatar',
nickname: 'Nickname',
nicknamePlaceholder: 'Enter nickname',
nicknameRequired: 'Nickname is required',
smartMode: 'Smart Mode',
smartModeOpen: 'Available',
free: 'Free',
inUse: 'Active',
remainDays: '{days} days left',
notSubscribed: 'Not subscribed',
goSubscribe: 'Subscribe ',
menuDevice: 'My Device',
bound: 'Bound',
unbound: 'Not bound',
menuHistory: 'Usage Records',
menuSubscription: 'Subscription',
menuHelp: 'Help',
menuContact: 'Contact Us',
menuLightInfo: 'Light Therapy Guide',
language: '语言 / Language',
logout: 'Log Out',
logoutConfirmTitle: 'Log Out',
logoutConfirmText: 'Are you sure you want to log out?',
unbindConfirmTitle: 'Confirm Unbind',
unbindConfirmText: 'You will not be able to use this device after unbinding. Continue?',
unbindAction: 'Unbind current device',
unbindDone: 'Unbound',
unbindFailed: 'Unbind failed'
}
}
+45
查看文件
@@ -0,0 +1,45 @@
// Strings for the register page. Fill zh/en with identical key structure.
module.exports = {
zh: {
subtitle: '完善个人资料',
avatarSection: '设置头像',
changeAvatar: '点击更换',
nicknameSection: '设置昵称',
nicknamePlaceholder: '请输入昵称',
authorizePhone: '授权手机号',
phoneHint: '手机号用于接收服务通知,必须授权',
submit: '完成注册',
agreement: '注册即表示同意《用户协议》和《隐私政策》',
nicknamePrefix: '用户',
phoneRequired: '需要授权手机号才能完成注册',
phoneAuthSuccess: '手机号授权成功',
phoneAuthFailed: '手机号授权失败',
authorized: '已授权',
uploadFailed: '上传失败',
phoneFirst: '请先授权手机号',
nicknameRequired: '请输入昵称',
agreementTitle: '提示',
agreementContent: '用户协议和隐私政策内容建设中'
},
en: {
subtitle: 'Complete Your Profile',
avatarSection: 'Set Avatar',
changeAvatar: 'Tap to change',
nicknameSection: 'Set Nickname',
nicknamePlaceholder: 'Enter a nickname',
authorizePhone: 'Authorize Phone Number',
phoneHint: 'Your phone number is used to receive service notifications and is required',
submit: 'Complete Registration',
agreement: 'By registering, you agree to the Terms of Service and Privacy Policy',
nicknamePrefix: 'User',
phoneRequired: 'Phone number authorization is required to complete registration',
phoneAuthSuccess: 'Phone number authorized',
phoneAuthFailed: 'Phone authorization failed',
authorized: 'Authorized',
uploadFailed: 'Upload failed',
phoneFirst: 'Please authorize your phone number first',
nicknameRequired: 'Please enter a nickname',
agreementTitle: 'Notice',
agreementContent: 'Terms of Service and Privacy Policy are coming soon'
}
}
+61
查看文件
@@ -0,0 +1,61 @@
// Strings for the scan report page (pages/scan-report).
module.exports = {
zh: {
title: '肌肤扫描报告',
subtitle: '基于多光谱反射的肤质倾向参考',
scannedAt: '扫描时间',
overallTitle: '整体倾向',
faceTitle: '分区光疗方案',
faceTapHint: '点击分区可查看该光的功效介绍',
allNormalHint: '未发现明显问题区域,可进行日常舒缓养护',
regionTitle: '分区分析',
recommendTitle: '推荐护理',
noProblem: '未见明显倾向',
startTreatment: '开始推荐护理',
manualInstead: '我自己选',
disclaimer: '本报告依据光反射信号给出肤质倾向参考,不构成医疗诊断。实际护理请结合自身情况。',
conceptNotice: '当前诊断依据尚在标定中,结果仅供参考',
level: { low: '轻度', mid: '中度', high: '明显' },
problems: {
aging: '光老化 / 暗沉',
acne: '痘痘 / 出油',
pigment: '色素 / 暗黄',
deep: '深层循环 / 疲劳'
},
modeNames: {
red: '嫩肤焕亮',
ir: '深层焕活',
uv: '净痘控油',
yellow: '匀净提亮'
}
},
en: {
title: 'Skin Scan Report',
subtitle: 'Skin-tendency reference from multispectral reflection',
scannedAt: 'Scanned at',
overallTitle: 'Overall Tendency',
faceTitle: 'Zone Care Map',
faceTapHint: 'Tap a zone to learn about its light',
allNormalHint: 'No notable concerns found — a gentle daily care session works well',
regionTitle: 'Zone Analysis',
recommendTitle: 'Recommended Care',
noProblem: 'No notable tendency',
startTreatment: 'Start Recommended Care',
manualInstead: 'Choose Myself',
disclaimer: 'This report gives a skin-tendency reference from light-reflection signals and is not a medical diagnosis. Please consider your own condition.',
conceptNotice: 'Diagnosis thresholds are still being calibrated; results are for reference only',
level: { low: 'Mild', mid: 'Moderate', high: 'Notable' },
problems: {
aging: 'Photoaging / Dullness',
acne: 'Acne / Oiliness',
pigment: 'Pigment / Sallowness',
deep: 'Deep circulation / Fatigue'
},
modeNames: {
red: 'Glow & Firm',
ir: 'Deep Revive',
uv: 'Clear & Balance',
yellow: 'Even & Brighten'
}
}
}
+29
查看文件
@@ -0,0 +1,29 @@
// Strings for the scan page. Fill zh/en with identical key structure.
module.exports = {
zh: {
title: '扫码绑定',
subtitle: '扫描设备底部二维码',
scanText: '扫描设备二维码',
scan: '扫码',
scanning: '扫描中...',
manualInput: '手动输入设备号',
manualPlaceholder: '请输入设备ID,如 672B6D5A 4DCD861',
invalidQr: '无效的设备二维码',
alreadyBound: '已绑定设备',
bindFailed: '绑定失败',
invalidFormat: '设备号格式不正确'
},
en: {
title: 'Scan to Bind',
subtitle: 'Scan the QR code on the bottom of the device',
scanText: 'Scan device QR code',
scan: 'Scan',
scanning: 'Scanning...',
manualInput: 'Enter Device ID Manually',
manualPlaceholder: 'Enter device ID, e.g. 672B6D5A 4DCD861',
invalidQr: 'Invalid device QR code',
alreadyBound: 'Device already bound',
bindFailed: 'Binding failed',
invalidFormat: 'Invalid device ID format'
}
}
+33
查看文件
@@ -0,0 +1,33 @@
// Strings for the treatment-setup page. Keep zh/en with identical key structure.
module.exports = {
zh: {
title: '使用设置',
subtitle: '选择模式和区域',
selectMode: '选择模式',
normalMode: '普通模式',
normalModeSub: '6色循环',
smartMode: '智能模式',
remainDays: '剩余{days}天',
subscribeUnlock: '订阅解锁',
selectRegion: '选择区域',
normalTip: '普通模式固定 10 分钟,使用区域默认全区域,不可调整',
startBtn: '开始使用',
noRegion: '请至少选择一个区域',
startFailed: '启动失败'
},
en: {
title: 'Session Setup',
subtitle: 'Choose mode and regions',
selectMode: 'Select Mode',
normalMode: 'Normal Mode',
normalModeSub: '6-color cycle',
smartMode: 'Smart Mode',
remainDays: '{days} days left',
subscribeUnlock: 'Subscribe to unlock',
selectRegion: 'Select Regions',
normalTip: 'Normal mode runs a fixed 10 minutes over all regions and cannot be adjusted',
startBtn: 'Start',
noRegion: 'Please select at least one region',
startFailed: 'Failed to start'
}
}
+87
查看文件
@@ -0,0 +1,87 @@
// Strings for the subPlans page. Fill zh/en with identical key structure.
module.exports = {
zh: {
title: '订阅服务',
subtitle: '解锁智能模式',
currentPlanLabel: '当前套餐',
remainDaysLabel: '剩余天数',
daysValue: '{days}天',
choosePlan: '选择订阅套餐',
loadingPlans: '加载套餐中...',
smartMode: '智能模式',
smartModeDesc: '智能自动调节',
renew: '续费',
confirmPay: '确认支付',
agreement: '支付即表示同意《订阅协议》',
// plan names (also used as fallback plan labels)
planTrial: '试用',
planMonthly: '月卡',
planYearly: '年卡',
// computed plan descriptions
free: '免费',
descTrial: '{days}天免费体验',
descSave: '省¥{amount}',
descDaily: '约{price}元/天',
tagRecommend: '推荐',
tagUsed: '已使用',
// modals / toasts
tipTitle: '提示',
agreementBuilding: '订阅协议内容建设中',
activateTrialTitle: '激活试用',
activateTrialConfirm: '免费试用 {desc}?',
confirmRenewTitle: '确认续费',
confirmPayTitle: '确认支付',
renewContent: '在现有订阅基础上延长{days}天,支付 ¥{price}',
payContent: '支付 ¥{price}',
trialActivated: '试用已激活',
activateFailed: '激活失败',
payUnavailable: '支付服务暂不可用',
payCancelled: '已取消支付',
payFailed: '支付失败',
createOrderFailed: '创建订单失败',
paySuccess: '支付成功',
payProcessing: '支付处理中,请稍后在订阅页查看'
},
en: {
title: 'Subscription',
subtitle: 'Unlock Smart Mode',
currentPlanLabel: 'Current plan',
remainDaysLabel: 'Days remaining',
daysValue: '{days} days',
choosePlan: 'Choose a plan',
loadingPlans: 'Loading plans...',
smartMode: 'Smart Mode',
smartModeDesc: 'Automatic smart adjustment',
renew: 'Renew',
confirmPay: 'Pay Now',
agreement: 'By paying you agree to the Subscription Agreement',
// plan names (also used as fallback plan labels)
planTrial: 'Trial',
planMonthly: 'Monthly',
planYearly: 'Yearly',
// computed plan descriptions
free: 'Free',
descTrial: '{days}-day free trial',
descSave: 'Save ¥{amount}',
descDaily: 'About ¥{price}/day',
tagRecommend: 'Best value',
tagUsed: 'Used',
// modals / toasts
tipTitle: 'Notice',
agreementBuilding: 'The subscription agreement is being prepared',
activateTrialTitle: 'Activate trial',
activateTrialConfirm: 'Start free trial: {desc}?',
confirmRenewTitle: 'Confirm renewal',
confirmPayTitle: 'Confirm payment',
renewContent: 'Extend your current subscription by {days} days for ¥{price}',
payContent: 'Pay ¥{price}',
trialActivated: 'Trial activated',
activateFailed: 'Activation failed',
payUnavailable: 'Payment service is unavailable',
payCancelled: 'Payment cancelled',
payFailed: 'Payment failed',
createOrderFailed: 'Failed to create order',
paySuccess: 'Payment successful',
payProcessing: 'Payment is processing. Please check the subscription page later.'
}
}
+29
查看文件
@@ -0,0 +1,29 @@
// Strings for the subPrompt page. Fill zh/en with identical key structure.
module.exports = {
zh: {
title: '使用设置',
subtitle: '试用期已结束',
selectMode: '选择模式',
normalMode: '普通模式',
normalModeSub: '6色循环',
smartMode: '智能模式',
smartModeSub: '订阅解锁',
trialEndedTitle: '试用已结束',
trialEndedSub: '订阅后继续使用智能模式',
subscribeNow: '立即订阅',
useNormalFirst: '先使用普通模式'
},
en: {
title: 'Settings',
subtitle: 'Your free trial has ended',
selectMode: 'Choose a mode',
normalMode: 'Standard Mode',
normalModeSub: '6-color cycle',
smartMode: 'Smart Mode',
smartModeSub: 'Unlock with subscription',
trialEndedTitle: 'Trial ended',
trialEndedSub: 'Subscribe to keep using Smart Mode',
subscribeNow: 'Subscribe Now',
useNormalFirst: 'Use Standard Mode for now'
}
}
+29
查看文件
@@ -0,0 +1,29 @@
// Strings for the subSuccess page. Fill zh/en with identical key structure.
module.exports = {
zh: {
title: '订阅成功',
subtitle: '欢迎使用智能模式',
successTitle: '订阅成功!',
subscribed: '已订阅',
validUntil: '有效期至:',
startSmart: '立即体验智能模式',
backHome: '返回首页',
planTrial: '试用会员',
planMonthly: '月卡会员',
planYearly: '年卡会员',
planDefault: '会员'
},
en: {
title: 'Subscription Successful',
subtitle: 'Welcome to Smart Mode',
successTitle: 'Subscription successful!',
subscribed: 'Subscribed',
validUntil: 'Valid until: ',
startSmart: 'Try Smart Mode now',
backHome: 'Back to Home',
planTrial: 'Trial Member',
planMonthly: 'Monthly Member',
planYearly: 'Yearly Member',
planDefault: 'Member'
}
}
+55
查看文件
@@ -0,0 +1,55 @@
// Strings for the treating (live treatment) page. Keep zh/en with identical key structure.
module.exports = {
zh: {
title: '使用中',
subtitle: '正在使用...',
remainingLabel: '剩余时间',
smartMode: '✨ 智能模式',
normalMode: '🔄 普通模式',
allRegions: '全区域',
relax1: '请放松心情',
relax2: '享受美好时光',
stopBtn: '停止使用',
fittingLost: '检测到离肤,请重新佩戴设备',
fittingRestored: '已重新贴合,继续使用',
faultTitle: '设备故障',
faultContent: '设备上报故障,已自动停止。',
lowBatteryTitle: '电量过低',
lowBatteryContent: '设备电量不足5%,已自动停止。请及时充电。',
lowBatteryWarn: '电量低,请及时充电',
exceptionTitle: '设备异常',
exceptionContent: '使用过程中出现异常',
disconnected: '设备连接断开,正在重连...',
reconnected: '设备已重新连接',
reconnectFailedTitle: '连接丢失',
reconnectFailedContent: '设备蓝牙连接已断开,无法恢复。本次使用数据将保存。',
stopTitle: '结束使用',
stopContent: '确定要提前结束本次使用吗?'
},
en: {
title: 'In Progress',
subtitle: 'Treatment in progress...',
remainingLabel: 'Time Left',
smartMode: '✨ Smart Mode',
normalMode: '🔄 Normal Mode',
allRegions: 'All regions',
relax1: 'Relax and unwind',
relax2: 'Enjoy your moment',
stopBtn: 'Stop',
fittingLost: 'Device lifted off skin. Please put it back on',
fittingRestored: 'Re-fitted, continuing',
faultTitle: 'Device Fault',
faultContent: 'The device reported a fault and stopped automatically.',
lowBatteryTitle: 'Battery Too Low',
lowBatteryContent: 'Battery is below 5%. Stopped automatically. Please charge soon.',
lowBatteryWarn: 'Low battery, please charge soon',
exceptionTitle: 'Device Error',
exceptionContent: 'Something went wrong during the session',
disconnected: 'Device disconnected, reconnecting...',
reconnected: 'Device reconnected',
reconnectFailedTitle: 'Connection Lost',
reconnectFailedContent: 'The Bluetooth connection was lost and cannot be restored. This session will be saved.',
stopTitle: 'End Session',
stopContent: 'Are you sure you want to end this session early?'
}
}
+25
查看文件
@@ -0,0 +1,25 @@
// Strings for the treatment-done page. Keep zh/en with identical key structure.
module.exports = {
zh: {
title: '使用完成',
headerSub: '本次使用已结束',
resultTitle: '使用完成!',
durationLabel: '使用时长',
regionLabel: '使用区域',
recorded: '本次使用已记录',
backHome: '返回首页',
durationMin: '{min}分钟',
durationSec: '{sec}秒'
},
en: {
title: 'Complete',
headerSub: 'This session has ended',
resultTitle: 'Session Complete!',
durationLabel: 'Duration',
regionLabel: 'Regions',
recorded: 'This session has been recorded',
backHome: 'Back to Home',
durationMin: '{min} min',
durationSec: '{sec} s'
}
}
+39
查看文件
@@ -0,0 +1,39 @@
// Strings for the wear-check page. Keep zh/en with identical key structure.
module.exports = {
zh: {
title: '确认佩戴',
subtitle: '请确保设备正确佩戴',
connected: '已连接',
disconnected: '未连接',
battery: '电量',
guideTitle: '佩戴指引',
guideImg: '设备佩戴示意图',
step1: '1. 取出设备,展开弹性绑带',
step2: '2. 将设备正确佩戴',
step3: '3. 调节绑带至舒适位置',
confirmBtn: '确认已佩戴,开始检测',
reconnect: '重新连接',
connecting: '正在连接...',
connectSuccess: '连接成功',
connectFailed: '连接失败',
searchTimeout: '搜索超时'
},
en: {
title: 'Confirm Fit',
subtitle: 'Make sure the device is worn correctly',
connected: 'Connected',
disconnected: 'Not connected',
battery: 'Battery',
guideTitle: 'How to Wear',
guideImg: 'Wearing diagram',
step1: '1. Take out the device and unfold the strap',
step2: '2. Put the device on correctly',
step3: '3. Adjust the strap to a comfortable fit',
confirmBtn: 'Confirm Fit & Continue',
reconnect: 'Reconnect',
connecting: 'Connecting...',
connectSuccess: 'Connected',
connectFailed: 'Connection failed',
searchTimeout: 'Search timed out'
}
}
+173 -91
查看文件
@@ -1,16 +1,16 @@
var ble = require('../../services/ble')
var SCAN_TIMEOUT = 15000
var SPECTRUM_NAMES = ['红光', '紫外光', '黄光', '红外光']
var SPECTRUM_COUNT = 4
var api = require('../../utils/api')
var diagnosis = require('../../services/diagnosis')
var i18n = require('../../i18n/index')
var SCAN_TIMEOUT = 180000
Page({
data: {
statusBarHeight: 44,
scanning: true,
scanProgress: 0,
regions: 0x1F,
spectrumIndex: 0,
spectrumData: [],
phase: 'ready',
frameCount: 0,
pdAvg: [],
vbat: 0,
battery: 0,
error: ''
@@ -30,83 +30,140 @@ Page({
statusBarHeight: app.globalData.statusBarHeight,
regions: parseInt(options.regions) || 0x1F
})
this.startScan()
},
onShow: function () {
i18n.bind(this)
},
_cleanup: function () {
if (this._progressTimer) { clearInterval(this._progressTimer); this._progressTimer = null }
if (this._timeoutTimer) { clearTimeout(this._timeoutTimer); this._timeoutTimer = null }
if (this._onAdc) { ble.off('adc', this._onAdc); this._onAdc = null }
if (this._onFitting) { ble.off('fitting', this._onFitting); this._onFitting = null }
if (this._onRunState) { ble.off('run_state', this._onRunState); this._onRunState = null }
},
startScan: function () {
onStartScan: function () {
var self = this
self._scanResults = []
self._completed = false
if (!ble.isConnected()) {
self.setData({ scanning: false, error: '设备未连接,请先连接设备' })
self.setData({ error: i18n.t('common.deviceNotConnected') })
return
}
self.setData({ scanning: true, scanProgress: 0, error: '', spectrumData: [], spectrumIndex: 0 })
// 按扫描光色分桶累加(FFE4 Byte17 高三位,新协议)。旧固件无标识 → 全部归 red 桶。
self._buckets = {}
self._frameCount = 0
self._completed = false
self._collecting = false
self._seenRunState1 = false
self._lastFitting = -1
self._lastRunState = -1
self.setData({ phase: 'waiting_fit', frameCount: 0, pdAvg: [], error: '' })
this._progressTimer = setInterval(function () {
var target = ((self._scanResults.length + 1) / SPECTRUM_COUNT) * 90
var p = self.data.scanProgress
if (p < target) p += 1
if (p > 90) p = 90
self.setData({ scanProgress: p })
}, 100)
self._onFitting = function (data) {
if (self._completed) return
if (data.fitting !== self._lastFitting) {
console.log('[SCAN] fitting:', data.fitting)
self._lastFitting = data.fitting
}
if (data.fitting === 1 && self._lastRunState === 1 && !self._collecting) {
console.log('[SCAN] 贴合确认,开始采集PD数据')
self._collecting = true
self.setData({ phase: 'collecting' })
}
}
ble.on('fitting', self._onFitting)
this._onAdc = function (data) {
if (!self.data.scanning) return
var idx = self._scanResults.length
if (idx >= SPECTRUM_COUNT) return
self._onRunState = function (data) {
if (self._completed) return
if (data.run_state !== self._lastRunState) {
console.log('[SCAN] run_state:', data.run_state)
self._lastRunState = data.run_state
}
console.log('[SCAN] 收到第' + (idx + 1) + '组ADC (' + SPECTRUM_NAMES[idx] + '):', data.pd)
self._scanResults.push({
spectrum: SPECTRUM_NAMES[idx],
pd: data.pd.slice(0),
vbat: data.vbat,
battery: data.battery
// 设备故障(Byte18=0xFF):立即中止,不等 3 分钟超时
if (data.run_state === 0xFF) {
console.error('[SCAN] 设备故障,中止扫描')
self._completed = true
self._cleanup()
self.setData({ phase: 'ready', error: i18n.t('autoScan.deviceFault') })
return
}
if (data.run_state === 1) {
self._seenRunState1 = true
}
if (self._lastFitting === 1 && data.run_state === 1 && !self._collecting) {
console.log('[SCAN] 贴合确认,开始采集PD数据')
self._collecting = true
self.setData({ phase: 'collecting' })
}
if (self._seenRunState1 && self._lastFitting === 0 && data.run_state === 0 && self.data.phase === 'waiting_fit') {
console.log('[SCAN] 未贴合,检测失败')
self._completed = true
self._cleanup()
self.setData({ phase: 'ready', error: i18n.t('autoScan.notWorn') })
return
}
if (data.run_state === 0 && self._collecting) {
console.log('[SCAN] 扫描完成,共', self._frameCount, '帧')
self._scanComplete()
}
}
ble.on('run_state', self._onRunState)
self._onAdc = function (data) {
if (self._completed || !self._collecting) return
if (!data || !data.pd || data.pd.length < 7) return
var wave = data.scan_wave || 'red' // 旧固件帧无光色标识,按单波长红光兼容
var bucket = self._buckets[wave]
if (!bucket) {
bucket = { sum: [0, 0, 0, 0, 0, 0, 0], n: 0 }
self._buckets[wave] = bucket
}
bucket.n++
for (var i = 0; i < 7; i++) {
bucket.sum[i] += data.pd[i]
}
self._frameCount++
self.setData({
frameCount: self._frameCount,
vbat: data.vbat || self.data.vbat,
battery: data.battery || self.data.battery
})
self.setData({ spectrumIndex: idx + 1 })
if (self._scanResults.length >= SPECTRUM_COUNT) {
self._scanComplete()
}
}
ble.on('adc', this._onAdc)
ble.on('adc', self._onAdc)
this._onRunState = function (data) {
if (data.run_state === 0x00 && self._scanResults.length > 0 && self.data.scanning) {
console.log('[SCAN] 设备进入IDLE,扫描结束')
self._scanComplete()
}
}
ble.on('run_state', this._onRunState)
console.log('[SCAN] 下发扫描指令,等待固件序贯扫描 R→UV→Y→IR')
console.log('[SCAN] 发送检测命令 control=0x01')
ble.setParams({
region_mask: self.data.regions,
wavelength: 2,
brightness: 128,
hold_time: 10,
hold_time: 0,
control: 0x01
}).then(function () {
console.log('[SCAN] 扫描指令发送成功')
console.log('[SCAN] 检测命令发送成功,等待贴合...')
}).catch(function (err) {
console.error('[SCAN] 扫描指令发送失败:', err)
console.error('[SCAN] 检测命令发送失败:', err)
self._cleanup()
self.setData({ scanning: false, scanProgress: 0, error: '扫描指令发送失败' })
self.setData({ phase: 'ready', error: i18n.t('autoScan.cmdFailed') })
})
this._timeoutTimer = setTimeout(function () {
if (!self.data.scanning) return
console.log('[SCAN] 超时,已收到' + self._scanResults.length + '组数据')
self._scanComplete()
self._timeoutTimer = setTimeout(function () {
if (self._completed) return
console.log('[SCAN] 超时3分钟),已收到', self._frameCount, '帧')
if (self._frameCount > 0) {
self._scanComplete()
} else {
self._completed = true
self._cleanup()
self.setData({ phase: 'ready', error: i18n.t('autoScan.timeout') })
}
}, SCAN_TIMEOUT)
},
@@ -114,51 +171,76 @@ Page({
if (this._completed) return
this._completed = true
this._cleanup()
this.setData({ scanning: false, scanProgress: 100 })
this._buildDisplay()
// 每个光色桶各自求平均 → waves: { red:[7], ir:[7], uv:[7], yellow:[7] }(有几色算几色)
var waves = {}
var firstAvg = []
for (var wave in this._buckets) {
if (!this._buckets.hasOwnProperty(wave)) continue
var b = this._buckets[wave]
if (!b.n) continue
var avg = []
for (var i = 0; i < 7; i++) avg.push(Math.round(b.sum[i] / b.n))
waves[wave] = avg
if (!firstAvg.length) firstAvg = avg
}
this.setData({ phase: 'done', pdAvg: firstAvg, frameCount: this._frameCount })
getApp().globalData.lastScanPdAvg = firstAvg
console.log('[SCAN] 分桶结果:', JSON.stringify(Object.keys(waves).map(function (k) { return k })), '总帧数:', this._frameCount)
this._buildAndGoReport(waves)
},
_buildDisplay: function () {
var results = this._scanResults || []
var spectrumData = []
// 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: thresholds + PD->region mapping in services/diagnosis.js are placeholders
// pending hardware confirmation / calibration (tunable via server diagnosis_config).
// Old firmware sends no wave marker -> everything lands in the red bucket and the
// pipeline degrades to single-wavelength gracefully.
_buildAndGoReport: function (waves) {
var self = this
var app = getApp()
var mask = self.data.regions || 0x1F
// Same device-id source the treating page uses (services/ble getDeviceId + currentDevice fallback).
var deviceId = ble.getDeviceId() || (app.globalData.currentDevice && app.globalData.currentDevice.device_id) || null
var scannedAt = Date.now()
for (var s = 0; s < results.length; s++) {
var r = results[s]
var items = []
for (var i = 0; i < 7; i++) {
var val = r.pd[i] || 0
items.push({
label: 'PD' + (i + 1),
value: val,
barWidth: Math.min(100, Math.round(val / 40.95))
})
}
spectrumData.push({ name: r.spectrum, items: items })
function proceed(config) {
var scanData = diagnosis.buildScanData(waves, mask, deviceId, scannedAt)
var report = diagnosis.analyze(scanData, config || diagnosis.DEFAULT_CONFIG)
app.globalData.lastScanReport = report
// 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' })
}
if (results.length > 0) {
var last = results[results.length - 1]
this.setData({
vbat: last.vbat || 0,
battery: last.battery || 0
})
}
getApp().globalData.lastScanData = results
this.setData({ spectrumData: spectrumData })
api.getDiagnosisConfig().then(function (cfg) {
// 非空对象即采用——缺失字段由 analyze 逐项回退 DEFAULT;只发部分字段(如仅调 pd_region_map)也生效
var usable = cfg && typeof cfg === 'object' && Object.keys(cfg).length > 0
proceed(usable ? cfg : diagnosis.DEFAULT_CONFIG)
}).catch(function () {
proceed(diagnosis.DEFAULT_CONFIG)
})
},
onUnload: function () {
if (!this._completed && (this.data.phase === 'waiting_fit' || this.data.phase === 'collecting')) {
ble.stopTreatment().catch(function () {})
}
this._cleanup()
ble.stopTreatment().catch(function () {})
},
onNext: function () {
var mask = this.data.regions || 0x1F
var scanData = this._scanResults || []
var param = scanData.length > 0 ? '&scan=' + encodeURIComponent(JSON.stringify(scanData)) : ''
wx.redirectTo({
url: '/pages/treatment-setup/treatment-setup?regions=' + mask + param
})
}
})
+26 -29
查看文件
@@ -1,45 +1,42 @@
<view class="page">
<view class="page-header page-header-pink" style="padding-top: {{statusBarHeight + 24}}px;">
<view class="nav-back" bindtap="onBack"> 返回</view>
<view class="page-header-title">多光谱扫描</view>
<view class="page-header-subtitle">{{scanning ? '正在采集数据 (' + spectrumIndex + '/4)...' : '扫描完成'}}</view>
<view class="nav-back" bindtap="onBack"> {{i18n.common.back}}</view>
<view class="page-header-title">{{i18n.autoScan.title}}</view>
<view class="page-header-subtitle">{{phase === 'ready' ? i18n.autoScan.subReady : phase === 'waiting_fit' ? i18n.autoScan.subWaiting : phase === 'collecting' ? i18n.autoScan.subCollectingPre + frameCount + i18n.autoScan.subCollectingPost : i18n.autoScan.subDone}}</view>
</view>
<view class="page-content">
<view class="scan-container" wx:if="{{scanning}}">
<view class="face-outline">
<view class="scan-line"></view>
</view>
<view class="scan-progress-bar">
<view class="scan-progress-fill" style="width: {{scanProgress}}%"></view>
</view>
<view class="scan-status">固件序贯扫描中:红光 → 紫外光 → 黄光 → 红外光</view>
<!-- 待开始 -->
<view wx:if="{{phase === 'ready'}}">
<view class="scan-hint">{{i18n.autoScan.readyHint}}</view>
<button class="btn-primary mt-30" bindtap="onStartScan">{{i18n.autoScan.startBtn}}</button>
</view>
<view class="scan-result" wx:if="{{!scanning && spectrumData.length > 0}}">
<view class="spectrum-group" wx:for="{{spectrumData}}" wx:key="name">
<view class="spectrum-title">{{item.name}} (第{{index + 1}}组)</view>
<view class="pd-list">
<view class="pd-item" wx:for="{{item.items}}" wx:for-item="pd" wx:key="label">
<view class="pd-region">{{pd.label}}</view>
<view class="pd-value">{{pd.value}}</view>
<view class="pd-bar-bg" wx:if="{{pd.value > 0}}">
<view class="pd-bar-fill" style="width: {{pd.barWidth}}%"></view>
</view>
</view>
<!-- 等待贴合 -->
<view wx:if="{{phase === 'waiting_fit'}}">
<view class="scan-container">
<view class="face-outline">
<view class="scan-line"></view>
</view>
</view>
<view class="vbat-info" wx:if="{{vbat > 0}}">
电池电压: {{vbat}}mV ({{battery}}%)
<view class="scan-status">{{i18n.autoScan.waitingStatus}}</view>
</view>
<!-- 采集中 -->
<view wx:if="{{phase === 'collecting'}}">
<view class="scan-container">
<view class="face-outline">
<view class="scan-line"></view>
</view>
</view>
<view class="scan-status">{{i18n.autoScan.collectingStatusPre}}{{frameCount}}{{i18n.autoScan.collectingStatusPost}}</view>
</view>
<view class="scan-result" wx:if="{{!scanning && spectrumData.length === 0 && !error}}">
<view class="empty-hint">未收到扫描数据</view>
<!-- 完成,自动跳转 -->
<view wx:if="{{phase === 'done'}}">
<view class="scan-status">{{i18n.autoScan.doneStatusPre}}{{frameCount}}{{i18n.autoScan.doneStatusPost}}</view>
</view>
<view class="scan-error" wx:if="{{!scanning && error}}">{{error}}</view>
<button class="btn-primary mt-30" wx:if="{{!scanning}}" bindtap="onNext">下一步</button>
<view class="scan-error" wx:if="{{error}}">{{error}}</view>
</view>
</view>
+26 -1
查看文件
@@ -1,8 +1,24 @@
var i18n = require('../../i18n/index')
Page({
data: {
statusBarHeight: 44,
deviceId: '',
trialDays: 7
trialDays: 7,
isFreeMode: false,
tipTitle: ''
},
onShow: function () {
i18n.bind(this)
},
updateTip: function () {
this.setData({
tipTitle: this.data.isFreeMode
? i18n.t('bindSuccess.freeModeOpen')
: i18n.t('bindSuccess.trialGranted', { days: this.data.trialDays })
})
},
onBack: function () {
@@ -17,6 +33,15 @@ Page({
var app = getApp()
this.setData({ statusBarHeight: app.globalData.statusBarHeight })
this.setData({ deviceId: options.device_id || '' })
this.updateTip()
var self = this
var http = require('../../utils/request')
http.get('/api/v1/subscription').then(function (sub) {
if (sub && sub.plan === 'free') {
self.setData({ isFreeMode: true })
self.updateTip()
}
}).catch(function () {})
},
onStart: function () {
@@ -1,25 +1,25 @@
<view class="page">
<view class="page-header page-header-green" style="padding-top: {{statusBarHeight + 24}}px;">
<view class="nav-back" bindtap="onBack"> 返回</view>
<view class="page-header-title">绑定成功</view>
<view class="page-header-subtitle">设备已成功绑定</view>
<view class="nav-back" bindtap="onBack"> {{i18n.common.back}}</view>
<view class="page-header-title">{{i18n.bindSuccess.title}}</view>
<view class="page-header-subtitle">{{i18n.bindSuccess.subtitle}}</view>
</view>
<view class="page-content">
<view class="success-area">
<view class="success-icon">✓</view>
<view class="success-title">绑定成功!</view>
<view class="success-title">{{i18n.bindSuccess.successTitle}}</view>
</view>
<view class="tip-card">
<text class="tip-card-icon">🎁</text>
<view class="tip-card-body">
<view class="tip-card-title">恭喜获得7天智能模式试用</view>
<view class="tip-card-sub">体验结束后可订阅继续使用</view>
<view class="tip-card-title">{{tipTitle}}</view>
<view class="tip-card-sub" wx:if="{{!isFreeMode}}">{{i18n.bindSuccess.trialSub}}</view>
</view>
</view>
<button class="btn-primary" bindtap="onStart">开始使用</button>
<button class="btn-secondary" bindtap="onGoHome">返回首页</button>
<button class="btn-primary" bindtap="onStart">{{i18n.bindSuccess.startUsing}}</button>
<button class="btn-secondary" bindtap="onGoHome">{{i18n.bindSuccess.goHome}}</button>
</view>
</view>
+10 -5
查看文件
@@ -1,5 +1,6 @@
var ble = require('../../services/ble')
var http = require('../../utils/request')
var i18n = require('../../i18n/index')
var app = getApp()
var SCAN_TIMEOUT = 15000
@@ -12,6 +13,10 @@ Page({
error: '',
},
onShow: function () {
i18n.bind(this)
},
onBack: function () {
wx.navigateBack({
fail: function () {
@@ -48,7 +53,7 @@ Page({
self._scanTimer = setTimeout(function () {
ble.stopScan()
self.setData({ state: 'error', error: '搜索超时,请确认设备已开机并在附近' })
self.setData({ state: 'error', error: i18n.t('bleConnect.scanTimeout') })
}, SCAN_TIMEOUT)
ble.startScan({
@@ -64,7 +69,7 @@ Page({
},
onError: function (err) {
clearTimeout(self._scanTimer)
self.setData({ state: 'error', error: err.msg || '连接失败' })
self.setData({ state: 'error', error: err.msg || i18n.t('bleConnect.failed') })
}
})
},
@@ -83,16 +88,16 @@ Page({
url: '/pages/bind-success/bind-success?device_id=' + self.data.deviceId
})
}).catch(function (err) {
self.setData({ state: 'error', error: err.message || '后台确认绑定失败' })
self.setData({ state: 'error', error: err.message || i18n.t('bleConnect.confirmFailed') })
})
} else {
self.setData({ state: 'error', error: '设备绑定失败' })
self.setData({ state: 'error', error: i18n.t('bleConnect.bindFailed') })
}
}
ble.on('bind_result', this._onBindResult)
ble.bindDevice(userId, self.data.bindToken).catch(function (err) {
self.setData({ state: 'error', error: err.error_msg || '绑定命令失败' })
self.setData({ state: 'error', error: err.error_msg || i18n.t('bleConnect.bindCmdFailed') })
})
},
@@ -1,31 +1,31 @@
<view class="page">
<view class="page-header page-header-pink" style="padding-top: {{statusBarHeight + 24}}px;">
<view class="nav-back" bindtap="onBack"> 返回</view>
<view class="page-header-title">蓝牙连接</view>
<view class="page-header-subtitle">{{state === 'scanning' ? '正在搜索设备...' : state === 'connecting' ? '正在连接设备...' : state === 'binding' ? '正在绑定...' : '连接失败'}}</view>
<view class="nav-back" bindtap="onBack"> {{i18n.common.back}}</view>
<view class="page-header-title">{{i18n.bleConnect.title}}</view>
<view class="page-header-subtitle">{{state === 'scanning' ? i18n.bleConnect.searchingSub : state === 'connecting' ? i18n.bleConnect.connectingSub : state === 'binding' ? i18n.bleConnect.bindingSub : i18n.bleConnect.failedSub}}</view>
</view>
<view class="page-content">
<view class="info-tip">
<text>📡</text>
<text>请确保手机蓝牙已开启</text>
<text>{{i18n.bleConnect.bluetoothTip}}</text>
</view>
<view class="search-card" wx:if="{{state === 'scanning' || state === 'connecting'}}">
<view class="search-icon pulsing">⏳</view>
<view class="search-text">搜索中...</view>
<view class="search-text">{{i18n.bleConnect.searching}}</view>
</view>
<view class="found-card" wx:if="{{state === 'binding'}}">
<view class="found-icon">💆</view>
<view class="found-name">LumiFlow-{{deviceId}}</view>
<text class="status-badge status-badge-green">正在配对...</text>
<text class="status-badge status-badge-green">{{i18n.bleConnect.pairing}}</text>
</view>
<view class="found-card" wx:if="{{state === 'error'}}">
<view class="search-icon">⚠️</view>
<view class="found-name">{{error || '连接失败'}}</view>
<button class="retry-btn" bindtap="onRetry">重试</button>
<view class="found-name">{{error || i18n.bleConnect.failed}}</view>
<button class="retry-btn" bindtap="onRetry">{{i18n.common.retry}}</button>
</view>
</view>
+130
查看文件
@@ -0,0 +1,130 @@
// TEMPORARY debug page for on-device verification (region↔IO mapping, color byte
// order, scan wave bucketing, PD polarity). Guoguo-only tooling — intentionally
// NOT i18n'd; remove this page before any release build.
var ble = require('../../services/ble')
var protocol = require('../../services/ble/protocol')
var WAVES = [
{ key: 'red', code: 2, label: '红光' },
{ key: 'ir', code: 1, label: '红外' },
{ key: 'uv', code: 3, label: '紫外' },
{ key: 'yellow', code: 4, label: '暖黄' }
]
var REGION_LABELS = { 1: '右区/IO1', 2: '左区/IO2', 4: '上区/IO3', 8: '中区/IO4', 16: '下区/IO5' }
var RUN_STATES = { 0: 'IDLE', 1: '检测', 2: '治疗', 255: '故障(0xFF)' }
function hex(bytes) {
return bytes.map(function (b) { return ('0' + b.toString(16)).slice(-2).toUpperCase() }).join(' ')
}
Page({
data: {
statusBarHeight: 44,
connected: false,
waves: WAVES,
selectedWave: 'red',
// live FFE4 status
pd: [], vbat: 0, battery: 0, fitting: -1, scanWave: '-', runState: '-', frames: 0,
logs: []
},
onLoad: function () {
this.setData({ statusBarHeight: getApp().globalData.statusBarHeight })
},
onShow: function () {
var self = this
self.setData({ connected: ble.isConnected() })
self._onAdc = function (d) {
self.setData({
pd: d.pd || [],
vbat: d.vbat || 0,
battery: d.battery || 0,
fitting: d.fitting,
scanWave: d.scan_wave || '-',
runState: RUN_STATES[d.run_state] !== undefined ? RUN_STATES[d.run_state] : String(d.run_state),
frames: self.data.frames + 1
})
}
ble.on('adc', self._onAdc)
},
onHide: function () { this._cleanup() },
onUnload: function () { this._cleanup() },
_cleanup: function () {
if (this._onAdc) { ble.off('adc', this._onAdc); this._onAdc = null }
},
onBack: function () {
wx.navigateBack({ fail: function () { wx.switchTab({ url: '/pages/profile/profile' }) } })
},
_log: function (msg) {
var logs = this.data.logs
logs.unshift(new Date().toTimeString().slice(0, 8) + ' ' + msg)
if (logs.length > 20) logs.pop()
this.setData({ logs: logs })
},
_send: function (label, options) {
var self = this
if (!ble.isConnected()) {
self.setData({ connected: false })
wx.showToast({ title: '设备未连接', icon: 'none' })
return
}
var bytes = protocol.buildVendorCommand(options)
self._log(label)
self._log('→ ' + hex(bytes))
ble.setParams(options).then(function () {
self._log('✓ 写入成功')
}).catch(function (err) {
self._log('✗ 写入失败: ' + ((err && err.msg) || JSON.stringify(err)))
})
},
onSelectWave: function (e) {
this.setData({ selectedWave: e.currentTarget.dataset.key })
},
_waveCode: function () {
var key = this.data.selectedWave
for (var i = 0; i < WAVES.length; i++) if (WAVES[i].key === key) return WAVES[i].code
return 2
},
// 点脸型图分区:该区 2 秒治疗(验证物理位置对不对)
onTapRegion: function (e) {
var mask = parseInt(e.currentTarget.dataset.mask)
this._send('治疗 2s ' + REGION_LABELS[mask] + ' ' + this.data.selectedWave, {
region_mask: mask, wavelength: this._waveCode(), brightness: 204,
hold_time: 2, control: 0x02
})
},
// 检测指令(与 auto-scan 同参:红光 128 全脸,设备自跑序贯扫描)
onScanCmd: function () {
this._send('检测指令 control=0x01', {
region_mask: 0x1F, wavelength: 2, brightness: 128,
hold_time: 0, control: 0x01
})
},
// 全脸当前波长 2 秒
onTreatAll: function () {
this._send('治疗 2s 全脸 ' + this.data.selectedWave, {
region_mask: 0x1F, wavelength: this._waveCode(), brightness: 204,
hold_time: 2, control: 0x02
})
},
// 停止 / IDLE
onStop: function () {
var self = this
self._log('停止 → IDLE 全零帧')
ble.stopTreatment().then(function () { self._log('✓ 已停止') })
.catch(function (err) { self._log('✗ 停止失败: ' + ((err && err.msg) || '')) })
},
onClearLog: function () { this.setData({ logs: [], frames: 0 }) }
})
@@ -0,0 +1,4 @@
{
"navigationStyle": "custom",
"navigationBarTitleText": "调试工具"
}
@@ -0,0 +1,65 @@
<!-- 临时调试页:真机联调用(区域/波长/极性/分桶验证),上线前移除,不走 i18n -->
<view class="page">
<view class="page-header page-header-gradient" style="padding-top: {{statusBarHeight + 8}}px;">
<view class="nav-back nav-back-light" bindtap="onBack"> 返回</view>
<view class="page-header-title">调试工具(临时)</view>
<view class="page-header-subtitle">{{connected ? '设备已连接' : '设备未连接'}}</view>
</view>
<view class="page-content">
<!-- 实时状态 -->
<view class="card">
<view class="card-title">FFE4 实时状态 <text class="frames">({{frames}} 帧)</text></view>
<view class="pd-row">
<view class="pd-cell" wx:for="{{pd}}" wx:key="*this">
<view class="pd-idx">PD{{index + 1}}</view>
<view class="pd-val">{{item}}</view>
</view>
</view>
<view class="status-row">
<text>VBAT {{vbat}}mV ({{battery}}%)</text>
<text>贴合 {{fitting}}</text>
<text>光色 {{scanWave}}</text>
<text>状态 {{runState}}</text>
</view>
</view>
<!-- 波长选择 -->
<view class="card">
<view class="card-title">波长(点分区/全脸时使用)</view>
<view class="wave-row">
<view class="wave-chip {{selectedWave === item.key ? 'active' : ''}}" wx:for="{{waves}}" wx:key="key"
bindtap="onSelectWave" data-key="{{item.key}}">{{item.label}}</view>
</view>
</view>
<!-- 脸型图:点区发 2 秒治疗 -->
<view class="card">
<view class="card-title">点分区 → 该区 2 秒治疗(对物理位置)</view>
<view class="face-map">
<view class="face-region forehead" bindtap="onTapRegion" data-mask="4">上\nIO3</view>
<view class="face-region left-cheek" bindtap="onTapRegion" data-mask="2">左\nIO2</view>
<view class="face-region right-cheek" bindtap="onTapRegion" data-mask="1">右\nIO1</view>
<view class="face-region nose" bindtap="onTapRegion" data-mask="8">中\nIO4</view>
<view class="face-region chin" bindtap="onTapRegion" data-mask="16">下\nIO5</view>
</view>
</view>
<!-- 指令按钮 -->
<view class="card">
<view class="card-title">指令</view>
<view class="btn-row">
<button class="dbg-btn" bindtap="onScanCmd">检测指令</button>
<button class="dbg-btn" bindtap="onTreatAll">全脸 2s</button>
<button class="dbg-btn dbg-btn-stop" bindtap="onStop">停止</button>
</view>
</view>
<!-- 日志 -->
<view class="card">
<view class="card-title">发送日志 <text class="log-clear" bindtap="onClearLog">清空</text></view>
<view class="log-line" wx:for="{{logs}}" wx:key="*this">{{item}}</view>
<view class="log-empty" wx:if="{{logs.length === 0}}">暂无</view>
</view>
</view>
</view>
@@ -0,0 +1,39 @@
/* 临时调试页样式(上线前随页面一并移除) */
.page { min-height: 100vh; background: #f5f6f8; }
.page-content { padding: 24rpx; }
.card { background: #fff; border-radius: 24rpx; padding: 24rpx; margin-bottom: 20rpx; }
.card-title { font-size: 28rpx; font-weight: 600; color: #333; margin-bottom: 16rpx; }
.frames { font-size: 22rpx; color: #999; font-weight: 400; }
.pd-row { display: flex; flex-wrap: wrap; gap: 12rpx; }
.pd-cell { flex: 0 0 calc((100% - 72rpx) / 7); text-align: center; background: #FAFAFA; border-radius: 12rpx; padding: 10rpx 0; }
.pd-idx { font-size: 20rpx; color: #999; }
.pd-val { font-size: 24rpx; color: #333; font-weight: 600; }
.status-row { display: flex; flex-wrap: wrap; gap: 20rpx; margin-top: 16rpx; font-size: 24rpx; color: #666; }
.wave-row { display: flex; gap: 16rpx; }
.wave-chip { flex: 1; text-align: center; padding: 18rpx 0; border: 4rpx solid #e5e5e5; border-radius: 20rpx; font-size: 26rpx; color: #999; }
.wave-chip.active { border-color: #E6508C; background: rgba(230, 80, 140, 0.12); color: #E6508C; }
.face-map { width: 280rpx; height: 340rpx; margin: 0 auto; position: relative; }
.face-region {
position: absolute; border: 4rpx solid #d5d5d5; border-radius: 50%;
display: flex; align-items: center; justify-content: center; text-align: center;
font-size: 22rpx; color: #666; background: #FAFAFA; white-space: pre-line; line-height: 1.3;
}
.face-region:active { background: rgba(230, 80, 140, 0.25); border-color: #E6508C; color: #E6508C; }
.face-region.forehead { top: 5%; left: 25%; width: 50%; height: 15%; border-radius: 40rpx 40rpx 50% 50%; }
.face-region.left-cheek { top: 25%; left: 5%; width: 30%; height: 25%; }
.face-region.right-cheek { top: 25%; right: 5%; width: 30%; height: 25%; }
.face-region.nose { top: 45%; left: 35%; width: 30%; height: 18%; border-radius: 40%; }
.face-region.chin { top: 68%; left: 25%; width: 50%; height: 20%; border-radius: 0 0 50% 50%; }
.btn-row { display: flex; gap: 16rpx; }
.dbg-btn { flex: 1; font-size: 26rpx; padding: 0; line-height: 76rpx; border-radius: 20rpx; background: #4A90D9; color: #fff; }
.dbg-btn::after { border: none; }
.dbg-btn-stop { background: #E8503A; }
.log-clear { float: right; font-size: 22rpx; color: #4A90D9; font-weight: 400; }
.log-line { font-family: monospace; font-size: 20rpx; color: #555; padding: 4rpx 0; border-bottom: 1rpx solid #f2f2f2; word-break: break-all; }
.log-empty { font-size: 22rpx; color: #bbb; }
+6
查看文件
@@ -1,8 +1,14 @@
var i18n = require('../../i18n/index')
Page({
data: {
statusBarHeight: 44
},
onShow: function () {
i18n.bind(this)
},
onLoad: function () {
var app = getApp()
this.setData({ statusBarHeight: app.globalData.statusBarHeight })
+4 -4
查看文件
@@ -1,13 +1,13 @@
<view class="page">
<view class="page-header page-header-pink" style="padding-top: {{statusBarHeight + 24}}px;">
<view class="nav-back" bindtap="onBack"> 返回</view>
<view class="page-header-title">联系我们</view>
<view class="nav-back" bindtap="onBack"> {{i18n.common.back}}</view>
<view class="page-header-title">{{i18n.contact.title}}</view>
</view>
<view class="page-content">
<view class="placeholder-container">
<view class="placeholder-icon">📬</view>
<view class="placeholder-text">内容建设中</view>
<view class="placeholder-sub">联系方式正在整理,敬请期待</view>
<view class="placeholder-text">{{i18n.contact.placeholderText}}</view>
<view class="placeholder-sub">{{i18n.contact.placeholderSub}}</view>
</view>
</view>
</view>
+6
查看文件
@@ -1,8 +1,14 @@
var i18n = require('../../i18n/index')
Page({
data: {
statusBarHeight: 44
},
onShow: function () {
i18n.bind(this)
},
onLoad: function () {
var app = getApp()
this.setData({ statusBarHeight: app.globalData.statusBarHeight })
+4 -4
查看文件
@@ -1,13 +1,13 @@
<view class="page">
<view class="page-header page-header-pink" style="padding-top: {{statusBarHeight + 24}}px;">
<view class="nav-back" bindtap="onBack"> 返回</view>
<view class="page-header-title">使用帮助</view>
<view class="nav-back" bindtap="onBack"> {{i18n.common.back}}</view>
<view class="page-header-title">{{i18n.help.title}}</view>
</view>
<view class="page-content">
<view class="placeholder-container">
<view class="placeholder-icon">📖</view>
<view class="placeholder-text">内容建设中</view>
<view class="placeholder-sub">帮助文档正在编写,敬请期待</view>
<view class="placeholder-text">{{i18n.help.placeholderText}}</view>
<view class="placeholder-sub">{{i18n.help.placeholderSub}}</view>
</view>
</view>
</view>
+6 -4
查看文件
@@ -1,5 +1,6 @@
var http = require('../../utils/request')
var ble = require('../../services/ble')
var i18n = require('../../i18n/index')
Page({
data: {
@@ -15,6 +16,7 @@ Page({
},
onShow: function () {
i18n.bind(this)
this.loadRecords(true)
},
@@ -52,7 +54,7 @@ Page({
var records = (data.records || []).map(function (r) {
var durationMin = Math.floor((r.total_duration_ms || 0) / 60000)
pageMs += (r.total_duration_ms || 0)
r.duration_text = durationMin + '分钟'
r.duration_text = i18n.t('history.durationMin', { min: durationMin })
r.region_names = ble.getRegionName(r.regions || 0).join('、')
r.wavelength_name = ble.getWavelengthName(r.wavelength || 2)
r.date_text = self.formatDate(r.start_time || r.created_at)
@@ -89,8 +91,8 @@ Page({
var h = ('0' + d.getHours()).slice(-2)
var m = ('0' + d.getMinutes()).slice(-2)
if (d >= today) return '今天 ' + h + ':' + m
if (d >= yesterday) return '昨天 ' + h + ':' + m
return (d.getMonth() + 1) + '月' + d.getDate() + ' ' + h + ':' + m
if (d >= today) return i18n.t('history.today') + ' ' + h + ':' + m
if (d >= yesterday) return i18n.t('history.yesterday') + ' ' + h + ':' + m
return i18n.t('history.dateMd', { m: d.getMonth() + 1, d: d.getDate() }) + ' ' + h + ':' + m
}
})
+7 -7
查看文件
@@ -3,31 +3,31 @@
<view class="stats-row">
<view class="stat-card">
<view class="stat-value">{{total}}</view>
<view class="stat-label">累计次数</view>
<view class="stat-label">{{i18n.history.statTotal}}</view>
</view>
<view class="stat-card">
<view class="stat-value">{{totalHours}}h</view>
<view class="stat-label">累计时长</view>
<view class="stat-label">{{i18n.history.statHours}}</view>
</view>
<view class="stat-card">
<view class="stat-value">{{monthCount}}</view>
<view class="stat-label">本月次数</view>
<view class="stat-label">{{i18n.history.statMonth}}</view>
</view>
</view>
<view class="section-title">最近记录</view>
<view class="section-title">{{i18n.history.recentTitle}}</view>
<view class="record-item" wx:for="{{records}}" wx:key="session_id">
<view class="record-date">{{item.date_text}}</view>
<text class="record-mode {{item.mode === 1 ? 'smart' : 'normal'}}">{{item.mode === 1 ? '✨ 智能' : '🔄 普通'}}</text>
<text class="record-mode {{item.mode === 1 ? 'smart' : 'normal'}}">{{item.mode === 1 ? i18n.history.smart : i18n.history.normal}}</text>
<view class="record-details">{{item.duration_text}}</view>
</view>
<view class="empty-state" wx:if="{{!loading && records.length === 0}}">
<text class="empty-icon">📃</text>
<view class="empty-text">暂无使用记录</view>
<view class="empty-text">{{i18n.history.empty}}</view>
</view>
<view class="loading-more" wx:if="{{loadingMore}}">加载更多...</view>
<view class="loading-more" wx:if="{{loadingMore}}">{{i18n.history.loadingMore}}</view>
</view>
</view>
+12 -8
查看文件
@@ -1,5 +1,6 @@
var ble = require('../../services/ble')
var api = require('../../utils/api')
var i18n = require('../../i18n/index')
var app = getApp()
Page({
@@ -17,6 +18,7 @@ Page({
},
onShow: function () {
i18n.bind(this)
this.checkState()
this._onStatus = this.onBleStatus.bind(this)
this._onBattery = this.onBleBattery.bind(this)
@@ -69,9 +71,11 @@ Page({
})
var p2 = api.getSubscription().then(function (sub) {
var remaining = sub.remaining_days || 0
self.setData({
subscription: sub,
subRemaining: sub.remaining_days || 0
subRemaining: remaining,
subRemainText: i18n.t('home.smartRemain', { days: remaining })
})
}).catch(function (err) {
console.error('getSubscription failed', err)
@@ -111,7 +115,7 @@ Page({
},
onError: function (err) {
self.setData({ connected: false, bleState: 'error' })
wx.showToast({ title: err.msg || '连接失败', icon: 'none' })
wx.showToast({ title: err.msg || i18n.t('home.connectFailed'), icon: 'none' })
}
})
},
@@ -123,12 +127,12 @@ Page({
onManageDevice: function () {
var self = this
wx.showActionSheet({
itemList: ['解绑当前设备'],
itemList: [i18n.t('home.unbindAction')],
success: function (res) {
if (res.tapIndex === 0) {
wx.showModal({
title: '确认解绑',
content: '解绑后将无法使用该设备,确定要解绑吗?',
title: i18n.t('home.unbindConfirmTitle'),
content: i18n.t('home.unbindConfirmText'),
success: function (modalRes) {
if (modalRes.confirm) {
self.doUnbind()
@@ -142,7 +146,7 @@ Page({
doUnbind: function () {
var self = this
wx.showLoading({ title: '解绑中...' })
wx.showLoading({ title: i18n.t('home.unbindLoading') })
ble.disconnect()
api.unbindDevice().then(function () {
wx.hideLoading()
@@ -152,10 +156,10 @@ Page({
deviceInfo: null,
connected: false
})
wx.showToast({ title: '已解绑', icon: 'success' })
wx.showToast({ title: i18n.t('home.unbindDone'), icon: 'success' })
}).catch(function (err) {
wx.hideLoading()
wx.showToast({ title: err.message || '解绑失败', icon: 'none' })
wx.showToast({ title: err.message || i18n.t('home.unbindFailed'), icon: 'none' })
})
},
+18 -12
查看文件
@@ -1,41 +1,47 @@
<view class="page">
<view class="loading-container" wx:if="{{loading}}">
<view class="loading-spinner"></view>
<text class="loading-text">加载中...</text>
<text class="loading-text">{{i18n.common.loading}}</text>
</view>
<view class="page-content" wx:if="{{!loading && !hasDevice}}">
<view class="empty-device">
<text class="empty-icon">📱</text>
<view class="empty-text">还没有绑定设备</view>
<button class="btn-primary mt-30" bindtap="onAddDevice">扫码添加设备</button>
<view class="empty-text">{{i18n.home.noDevice}}</view>
<button class="btn-primary mt-30" bindtap="onAddDevice">{{i18n.home.addDevice}}</button>
</view>
</view>
<view class="page-content" wx:if="{{!loading && hasDevice}}">
<view class="device-card">
<view class="device-card-icon">💆</view>
<view class="device-card-name">{{deviceName || '我的设备'}}</view>
<view class="device-card-name">{{deviceName || i18n.home.myDevice}}</view>
<view class="device-card-status">
<text class="status-badge status-badge-green" wx:if="{{connected}}">已连接</text>
<text class="status-badge" style="background:#ccc;" wx:else>未连接</text>
<text class="status-badge status-badge-green" wx:if="{{connected}}">{{i18n.home.connected}}</text>
<text class="status-badge" style="background:#ccc;" wx:else>{{i18n.home.disconnected}}</text>
<text class="device-card-battery" wx:if="{{connected}}">🔋 {{battery}}%</text>
</view>
<button class="btn-reconnect" bindtap="onConnectBle" wx:if="{{!connected}}">重新连接</button>
<button class="btn-reconnect" bindtap="onConnectBle" wx:if="{{!connected}}">{{i18n.home.reconnect}}</button>
</view>
<view class="sub-info" bindtap="onViewSubscription">
<view class="sub-info" bindtap="onViewSubscription" wx:if="{{!subscription || subscription.plan !== 'free'}}">
<view class="sub-info-left">
<text class="sub-info-icon">💳</text>
<text wx:if="{{subscription && subscription.status === 'active'}}">智能模式 · 剩余{{subRemaining}}</text>
<text wx:else>未订阅智能模式</text>
<text wx:if="{{subscription && subscription.status === 'active'}}">{{subRemainText}}</text>
<text wx:else>{{i18n.home.notSubscribed}}</text>
</view>
<text class="sub-info-arrow"></text>
</view>
<view class="sub-info" wx:if="{{subscription && subscription.plan === 'free'}}">
<view class="sub-info-left">
<text class="sub-info-icon">✨</text>
<text>{{i18n.home.smartOpen}}</text>
</view>
</view>
<view class="action-buttons">
<button class="btn-primary" bindtap="onStartTreatment">开始使用</button>
<button class="btn-secondary" bindtap="onManageDevice">设备管理</button>
<button class="btn-primary" bindtap="onStartTreatment">{{i18n.home.start}}</button>
<button class="btn-secondary" bindtap="onManageDevice">{{i18n.home.deviceManage}}</button>
</view>
</view>
</view>
@@ -0,0 +1,37 @@
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 (options) {
var app = getApp()
// 报告页分区跳转带 ?wave=red|ir|uv|yellow,高亮对应光卡片('ir' 对应本页 key 'infrared'
var focus = (options && options.wave) || ''
if (focus === 'ir') focus = 'infrared'
this.setData({ statusBarHeight: app.globalData.statusBarHeight, focusWave: focus })
},
onShow: function () {
i18n.bind(this)
},
onBack: function () {
wx.navigateBack({
fail: function () {
wx.switchTab({ url: '/pages/profile/profile' })
}
})
}
})
@@ -0,0 +1,4 @@
{
"navigationStyle": "custom",
"navigationBarTitleText": "光疗功效介绍"
}
@@ -0,0 +1,40 @@
<view class="page">
<view class="page-header page-header-gradient" style="padding-top: {{statusBarHeight + 24}}px;">
<view class="nav-back nav-back-light" bindtap="onBack"> {{i18n.common.back}}</view>
<view class="page-header-title">{{i18n.lightInfo.navTitle}}</view>
<view class="page-header-subtitle">{{i18n.lightInfo.headerSubtitle}}</view>
</view>
<view class="page-content">
<!-- 四种光 -->
<view class="section-title">{{i18n.lightInfo.lightsSectionTitle}}</view>
<view class="light-card {{focusWave === item.key ? 'light-card-focus' : ''}}" wx:for="{{lights}}" wx:key="key">
<view class="light-card-top">
<view class="swatch" style="background: {{item.color}};"></view>
<view class="light-head">
<view class="light-name">{{i18n.lightInfo.lights[item.key].name}}</view>
<view class="light-wave">{{i18n.lightInfo.lights[item.key].wavelength}}</view>
</view>
</view>
<view class="light-effect" style="color: {{item.color}};">{{i18n.lightInfo.lights[item.key].effect}}</view>
<view class="light-desc">{{i18n.lightInfo.lights[item.key].desc}}</view>
<view class="light-suits">
<text class="suits-label">{{i18n.lightInfo.suitsLabel}}</text>
<text class="suits-text">{{i18n.lightInfo.lights[item.key].suits}}</text>
</view>
</view>
<!-- 护理模式 -->
<view class="section-title section-title-gap">{{i18n.lightInfo.modesSectionTitle}}</view>
<view class="mode-grid">
<view class="mode-card" wx:for="{{i18n.lightInfo.modes}}" wx:for-item="mode" wx:key="name">
<view class="mode-dot" style="background: {{modeColors[index]}};"></view>
<view class="mode-name">{{mode.name}}</view>
<view class="mode-desc">{{mode.desc}}</view>
</view>
</view>
<!-- 免责声明 -->
<view class="disclaimer">{{i18n.lightInfo.disclaimer}}</view>
</view>
</view>
@@ -0,0 +1,131 @@
.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;
}
/* 从扫描报告分区跳转时高亮对应光 */
.light-card-focus {
border: 2rpx solid rgba(60, 60, 60, 0.35);
box-shadow: 0 6rpx 20rpx rgba(0, 0, 0, 0.10);
}
+7 -2
查看文件
@@ -1,4 +1,5 @@
var app = getApp()
var i18n = require('../../i18n/index')
Page({
data: {
@@ -6,6 +7,10 @@ Page({
loading: false
},
onShow: function () {
i18n.bind(this)
},
onLoad: function () {
this.setData({ statusBarHeight: app.globalData.statusBarHeight })
},
@@ -23,11 +28,11 @@ Page({
}
}).catch(function (err) {
self.setData({ loading: false })
wx.showToast({ title: err.message || '登录失败', icon: 'none' })
wx.showToast({ title: err.message || i18n.t('login.loginFailed'), icon: 'none' })
})
},
onAgreement: function () {
wx.showModal({ title: '提示', content: '用户协议和隐私政策内容建设中', showCancel: false })
wx.showModal({ title: i18n.t('login.agreementTitle'), content: i18n.t('login.agreementContent'), showCancel: false })
}
})
+5 -5
查看文件
@@ -1,21 +1,21 @@
<view class="page">
<view class="page-header page-header-pink" style="padding-top: {{statusBarHeight + 24}}px;">
<view class="page-header-title">LumiFlow</view>
<view class="page-header-subtitle">微信登录</view>
<view class="page-header-subtitle">{{i18n.login.wechatLogin}}</view>
</view>
<view class="page-content">
<view class="welcome-text">欢迎使用LumiFlow</view>
<view class="welcome-text">{{i18n.login.welcome}}</view>
<view class="flower-area">
<text class="flower">💎</text>
<view class="desc">登录后即可使用全部功能</view>
<view class="desc">{{i18n.login.desc}}</view>
</view>
<button class="login-btn" loading="{{loading}}" bindtap="onLogin" disabled="{{loading}}">
微信登录
{{i18n.login.wechatLogin}}
</button>
<view class="agreement" bindtap="onAgreement">登录即表示同意《用户协议》和《隐私政策》</view>
<view class="agreement" bindtap="onAgreement">{{i18n.login.agreement}}</view>
</view>
</view>
@@ -0,0 +1,159 @@
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,
// 治疗亮度统一 204 = 80%(果果 2026-07-28 定案,与智能推荐一致)。
// Do NOT expose a brightness slider (safety); builder 层另有 ≤204 硬上限兜底。
brightness: 204,
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,
// 掩码 = IO↔物理位置(实测):右=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()
this._checkAccess()
},
// 手动选择护理模式为智能模式专属(果果 2026-07-28 定案)。普通模式用户不可用——
// 入口链接已按订阅隐藏,此处兜底拦截直达(smart_mode_free 开启时后端返回 active,同样放行)。
_checkAccess: function () {
var http = require('../../utils/request')
http.get('/api/v1/subscription').then(function (sub) {
if (sub && sub.status === 'active') return
wx.showModal({
content: i18n.t('manual.smartOnly'),
showCancel: false,
complete: function () {
wx.navigateBack({ fail: function () { wx.reLaunch({ url: '/pages/index/index' }) } })
}
})
}).catch(function () {}) // 网络失败不拦(避免误伤),实际下发仍有 BLE/后端校验
},
// 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')
})
})
}
})
@@ -0,0 +1,4 @@
{
"navigationStyle": "custom",
"navigationBarTitleText": "手动护理"
}
@@ -0,0 +1,56 @@
<view class="page">
<view class="page-header page-header-pink" style="padding-top: {{statusBarHeight + 24}}px;">
<view class="nav-back" bindtap="onBack"> {{i18n.common.back}}</view>
<view class="page-header-title">{{i18n.manual.navTitle}}</view>
<view class="page-header-subtitle">{{i18n.manual.navSubtitle}}</view>
</view>
<view class="page-content">
<view class="page-title">{{i18n.manual.functionTitle}}</view>
<view class="func-grid">
<view
wx:for="{{functions}}"
wx:key="id"
class="func-card {{selectedFunctionId === item.id ? 'active' : ''}}"
style="{{selectedFunctionId === item.id ? 'border-color:' + item.color + ';' : ''}}"
bindtap="onSelectFunction"
data-id="{{item.id}}">
<view class="func-dot" style="background: {{item.color}};"></view>
<view class="func-name">{{i18n.manual.functions[item.id].name}}</view>
<view class="func-wl" style="color: {{item.color}};">{{i18n.common.wavelengths[item.wl]}}</view>
<view class="func-benefit">{{i18n.manual.functions[item.id].benefit}}</view>
</view>
</view>
<view class="page-title section-title">{{i18n.manual.regionTitle}}</view>
<!-- 脸型图布局(与「使用设置」一致);位置↔idx(实测):0=右 1=左 2=上 3=中 4=下 -->
<view class="face-map">
<view class="face-region forehead {{regions[2].checked ? 'active' : ''}}" bindtap="onToggleRegion" data-idx="2">{{i18n.common.regions.top}}</view>
<view class="face-region left-cheek {{regions[1].checked ? 'active' : ''}}" bindtap="onToggleRegion" data-idx="1">{{i18n.common.regions.left}}</view>
<view class="face-region right-cheek {{regions[0].checked ? 'active' : ''}}" bindtap="onToggleRegion" data-idx="0">{{i18n.common.regions.right}}</view>
<view class="face-region nose {{regions[3].checked ? 'active' : ''}}" bindtap="onToggleRegion" data-idx="3">{{i18n.common.regions.middle}}</view>
<view class="face-region chin {{regions[4].checked ? 'active' : ''}}" bindtap="onToggleRegion" data-idx="4">{{i18n.common.regions.bottom}}</view>
</view>
<view class="page-title section-title">{{i18n.manual.durationTitle}}</view>
<view class="segmented">
<view
wx:for="{{durationOptions}}"
wx:key="min"
class="segment {{selectedMinutes === item.min ? 'active' : ''}}"
bindtap="onSelectDuration"
data-min="{{item.min}}">
{{item.label}}
</view>
</view>
<view class="safety-tip">{{i18n.manual.safetyTip}}</view>
<button class="btn-primary" bindtap="onStart" disabled="{{submitting}}" loading="{{submitting}}">
{{submitting ? i18n.manual.starting : i18n.manual.start}}
</button>
<view class="error-text" wx:if="{{error}}">{{error}}</view>
</view>
</view>
@@ -0,0 +1,129 @@
.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 */
/* face-map 脸型图(与 treatment-setup 同款) */
.face-map {
width: 240rpx;
height: 280rpx;
margin: 0 auto 24rpx;
position: relative;
}
.face-region {
position: absolute;
border: 4rpx solid #e5e5e5;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 22rpx;
color: #999999;
}
.face-region.active {
border-color: #E6508C;
background: rgba(230, 80, 140, 0.2);
color: #E6508C;
}
.face-region.forehead { top: 5%; left: 25%; width: 50%; height: 15%; border-radius: 40rpx 40rpx 50% 50%; }
.face-region.left-cheek { top: 25%; left: 5%; width: 30%; height: 25%; }
.face-region.right-cheek { top: 25%; right: 5%; width: 30%; height: 25%; }
.face-region.nose { top: 45%; left: 35%; width: 30%; height: 18%; border-radius: 40%; }
.face-region.chin { top: 68%; left: 25%; width: 50%; height: 20%; border-radius: 0 0 50% 50%; }
/* 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;
}
+42 -11
查看文件
@@ -1,5 +1,6 @@
var api = require('../../utils/api')
var config = require('../../config/env')
var i18n = require('../../i18n/index')
var app = getApp()
Page({
@@ -16,7 +17,35 @@ Page({
},
onShow: function () {
i18n.bind(this)
this.loadProfile()
// 临时调试入口:仅设备已连接时显示(上线前随 ble-debug 页一并移除)
var ble = require('../../services/ble')
this.setData({ debugVisible: ble.isConnected() })
},
onOpenDebug: function () {
wx.navigateTo({ url: '/pages/ble-debug/ble-debug' })
},
onLightInfo: function () {
wx.navigateTo({ url: '/pages/light-info/light-info' })
},
onSwitchLanguage: function () {
var self = this
var supported = i18n.SUPPORTED
var names = supported.map(function (l) { return i18n.LOCALE_NAMES[l] || l })
wx.showActionSheet({
itemList: names,
success: function (res) {
var target = supported[res.tapIndex]
if (target && target !== i18n.getLocale()) {
i18n.setLocale(target)
i18n.bind(self)
}
}
})
},
loadProfile: function () {
@@ -33,9 +62,11 @@ Page({
})
var p2 = api.getSubscription().then(function (sub) {
var remaining = sub.remaining_days || 0
self.setData({
subscription: sub,
subRemaining: sub.remaining_days || 0
subRemaining: remaining,
subRemainingText: i18n.t('profile.remainDays', { days: remaining })
})
}).catch(function (err) {
console.error('getSubscription failed', err)
@@ -50,19 +81,19 @@ Page({
var self = this
if (this.data.deviceCount > 0) {
wx.showActionSheet({
itemList: ['解绑当前设备'],
itemList: [i18n.t('profile.unbindAction')],
success: function (res) {
if (res.tapIndex === 0) {
wx.showModal({
title: '确认解绑',
content: '解绑后将无法使用该设备,确定要解绑吗?',
title: i18n.t('profile.unbindConfirmTitle'),
content: i18n.t('profile.unbindConfirmText'),
success: function (modalRes) {
if (modalRes.confirm) {
api.unbindDevice().then(function () {
wx.showToast({ title: '已解绑', icon: 'success' })
wx.showToast({ title: i18n.t('profile.unbindDone'), icon: 'success' })
self.loadProfile()
}).catch(function (err) {
wx.showToast({ title: err.message || '解绑失败', icon: 'none' })
wx.showToast({ title: err.message || i18n.t('profile.unbindFailed'), icon: 'none' })
})
}
}
@@ -151,7 +182,7 @@ Page({
var self = this
var nickname = (self.data.editNickname || '').trim()
if (!nickname) {
wx.showToast({ title: '昵称不能为空', icon: 'none' })
wx.showToast({ title: i18n.t('profile.nicknameRequired'), icon: 'none' })
return
}
@@ -170,18 +201,18 @@ Page({
})
}).then(function () {
self.setData({ saving: false, editing: false })
wx.showToast({ title: '保存成功', icon: 'success' })
wx.showToast({ title: i18n.t('common.saveSuccess'), icon: 'success' })
self.loadProfile()
}).catch(function (err) {
self.setData({ saving: false })
wx.showToast({ title: err.message || '保存失败', icon: 'none' })
wx.showToast({ title: err.message || i18n.t('common.saveFailed'), icon: 'none' })
})
},
onLogout: function () {
wx.showModal({
title: '退出登录',
content: '确定要退出登录吗?',
title: i18n.t('profile.logoutConfirmTitle'),
content: i18n.t('profile.logoutConfirmText'),
success: function (res) {
if (res.confirm) {
wx.removeStorageSync('token')
+50 -26
查看文件
@@ -1,7 +1,7 @@
<view class="page">
<view class="loading-container" wx:if="{{loading}}">
<view class="loading-spinner"></view>
<text class="loading-text">加载中...</text>
<text class="loading-text">{{i18n.common.loading}}</text>
</view>
<view class="page-content" wx:if="{{!loading}}">
@@ -9,78 +9,102 @@
<image class="user-avatar-img" wx:if="{{userInfo.avatar}}" src="{{userInfo.avatar}}" mode="aspectFill"></image>
<view class="user-avatar" wx:else>👤</view>
<view class="user-info">
<view class="user-name">{{userInfo.nickname || '未登录'}}</view>
<view class="user-name">{{userInfo.nickname || i18n.profile.notLoggedIn}}</view>
<view class="user-phone">{{userInfo.phone || ''}}</view>
</view>
<view class="edit-profile-btn" bindtap="onEditProfile">编辑资料</view>
<view class="edit-profile-btn" bindtap="onEditProfile">{{i18n.profile.editProfile}}</view>
</view>
<view class="sub-card" wx:if="{{subscription && subscription.status === 'active' && subRemaining > 0}}">
<view class="sub-card" wx:if="{{subscription && subscription.plan === 'free'}}">
<view class="sub-left">
<view class="sub-name">✨ 智能模式</view>
<view class="sub-remain">剩余 {{subRemaining}}</view>
<view class="sub-name">✨ {{i18n.profile.smartMode}}</view>
<view class="sub-remain">{{i18n.profile.smartModeOpen}}</view>
</view>
<text class="status-badge status-badge-gold">使用中</text>
<text class="status-badge status-badge-gold">{{i18n.profile.free}}</text>
</view>
<view class="sub-card" wx:elif="{{subscription && subscription.status === 'active' && subRemaining > 0}}">
<view class="sub-left">
<view class="sub-name">✨ {{i18n.profile.smartMode}}</view>
<view class="sub-remain">{{subRemainingText}}</view>
</view>
<text class="status-badge status-badge-gold">{{i18n.profile.inUse}}</text>
</view>
<view class="sub-card sub-card-inactive" wx:else bindtap="onViewSubscription">
<view class="sub-left">
<view class="sub-name">智能模式</view>
<view class="sub-remain">未订阅</view>
<view class="sub-name">{{i18n.profile.smartMode}}</view>
<view class="sub-remain">{{i18n.profile.notSubscribed}}</view>
</view>
<text class="status-badge" style="background:#eee;color:#999;">去订阅 </text>
<text class="status-badge" style="background:#eee;color:#999;">{{i18n.profile.goSubscribe}}</text>
</view>
<view class="menu-list">
<view class="menu-item" bindtap="onManageDevice">
<view class="menu-icon">📱</view>
<view class="menu-text">我的设备</view>
<text class="menu-device-status" wx:if="{{deviceCount > 0}}">已绑定</text>
<text class="menu-device-status menu-device-unbound" wx:else>未绑定</text>
<view class="menu-text">{{i18n.profile.menuDevice}}</view>
<text class="menu-device-status" wx:if="{{deviceCount > 0}}">{{i18n.profile.bound}}</text>
<text class="menu-device-status menu-device-unbound" wx:else>{{i18n.profile.unbound}}</text>
<text class="menu-arrow"></text>
</view>
<view class="menu-item" bindtap="onViewHistory">
<view class="menu-icon">📋</view>
<view class="menu-text">使用记录</view>
<view class="menu-text">{{i18n.profile.menuHistory}}</view>
<text class="menu-arrow"></text>
</view>
<view class="menu-item" bindtap="onViewSubscription">
<!-- 临时调试入口:仅设备已连接时显示,上线前移除 -->
<view class="menu-item" wx:if="{{debugVisible}}" bindtap="onOpenDebug">
<view class="menu-icon">🔧</view>
<view class="menu-text">调试工具(临时)</view>
<text class="menu-arrow"></text>
</view>
<view class="menu-item" bindtap="onViewSubscription" wx:if="{{!subscription || subscription.plan !== 'free'}}">
<view class="menu-icon">💳</view>
<view class="menu-text">订阅管理</view>
<view class="menu-text">{{i18n.profile.menuSubscription}}</view>
<text class="menu-arrow"></text>
</view>
<view class="menu-item" bindtap="onLightInfo">
<view class="menu-icon">💡</view>
<view class="menu-text">{{i18n.profile.menuLightInfo}}</view>
<text class="menu-arrow"></text>
</view>
<view class="menu-item" bindtap="onHelp">
<view class="menu-icon">❓</view>
<view class="menu-text">使用帮助</view>
<view class="menu-text">{{i18n.profile.menuHelp}}</view>
<text class="menu-arrow"></text>
</view>
<view class="menu-item" bindtap="onContact">
<view class="menu-icon">📞</view>
<view class="menu-text">联系我们</view>
<view class="menu-text">{{i18n.profile.menuContact}}</view>
<text class="menu-arrow"></text>
</view>
<view class="menu-item" bindtap="onSwitchLanguage">
<view class="menu-icon">🌐</view>
<view class="menu-text">{{i18n.profile.language}}</view>
<text class="menu-device-status">{{locale === 'en' ? 'English' : '中文'}}</text>
<text class="menu-arrow"></text>
</view>
</view>
<view class="logout-btn" bindtap="onLogout">退出登录</view>
<view class="logout-btn" bindtap="onLogout">{{i18n.profile.logout}}</view>
<view class="version-text">v0.1.7</view>
<view class="version-text">v0.2.1</view>
</view>
<!-- Edit profile modal -->
<view class="edit-mask" wx:if="{{editing}}" bindtap="onCancelEdit"></view>
<view class="edit-modal" wx:if="{{editing}}">
<view class="edit-modal-title">编辑资料</view>
<view class="edit-modal-title">{{i18n.profile.editProfileTitle}}</view>
<view class="edit-avatar-row" bindtap="onPickAvatar">
<image class="edit-avatar-img" wx:if="{{editAvatarUrl}}" src="{{editAvatarUrl}}" mode="aspectFill"></image>
<view class="edit-avatar-placeholder" wx:else>👤</view>
<text class="edit-avatar-hint">点击更换头像</text>
<text class="edit-avatar-hint">{{i18n.profile.changeAvatar}}</text>
</view>
<view class="edit-field">
<text class="edit-label">昵称</text>
<input class="edit-input" value="{{editNickname}}" bindinput="onEditNicknameInput" placeholder="请输入昵称" maxlength="20" />
<text class="edit-label">{{i18n.profile.nickname}}</text>
<input class="edit-input" value="{{editNickname}}" bindinput="onEditNicknameInput" placeholder="{{i18n.profile.nicknamePlaceholder}}" maxlength="20" />
</view>
<view class="edit-actions">
<button class="btn-secondary edit-btn" bindtap="onCancelEdit">取消</button>
<button class="btn-primary edit-btn" bindtap="onSaveProfile" disabled="{{saving}}" loading="{{saving}}">保存</button>
<button class="btn-secondary edit-btn" bindtap="onCancelEdit">{{i18n.common.cancel}}</button>
<button class="btn-primary edit-btn" bindtap="onSaveProfile" disabled="{{saving}}" loading="{{saving}}">{{i18n.common.save}}</button>
</view>
</view>
</view>
+17 -12
查看文件
@@ -1,6 +1,7 @@
var app = getApp()
var http = require('../../utils/request')
var config = require('../../config/env')
var i18n = require('../../i18n/index')
var DEFAULT_AVATAR = 'https://mmbiz.qpic.cn/mmbiz/icTdbqWNOwNRna42FI9t2NHfLvvMiaiaCJQn3KP2P0vAHp1QBP0piavhGfhKCARlGd6z1EbR58ibJXKCp5WVlNnojwA/640'
@@ -9,7 +10,7 @@ function randomNickname() {
for (var i = 0; i < 4; i++) {
digits += Math.floor(Math.random() * 10)
}
return '用户' + digits
return i18n.t('register.nicknamePrefix') + digits
}
Page({
@@ -21,6 +22,10 @@ Page({
phone: ''
},
onShow: function () {
i18n.bind(this)
},
onLoad: function () {
this.setData({
statusBarHeight: app.globalData.statusBarHeight,
@@ -58,19 +63,19 @@ Page({
onGetPhoneNumber: function (e) {
var self = this
if (!e.detail || !e.detail.code) {
wx.showToast({ title: '需要授权手机号才能完成注册', icon: 'none' })
wx.showToast({ title: i18n.t('register.phoneRequired'), icon: 'none' })
return
}
self.setData({ loading: true })
http.post('/api/v1/user/phone', { code: e.detail.code }).then(function (data) {
self.setData({
loading: false,
phone: '已授权'
phone: i18n.t('register.authorized')
})
wx.showToast({ title: '手机号授权成功', icon: 'success' })
wx.showToast({ title: i18n.t('register.phoneAuthSuccess'), icon: 'success' })
}).catch(function (err) {
self.setData({ loading: false })
wx.showToast({ title: err.message || '手机号授权失败', icon: 'none' })
wx.showToast({ title: err.message || i18n.t('register.phoneAuthFailed'), icon: 'none' })
})
},
@@ -88,13 +93,13 @@ Page({
if (data.code === 0 && data.data && data.data.avatar) {
resolve(data.data.avatar)
} else {
reject(new Error(data.message || '上传失败'))
reject(new Error(data.message || i18n.t('register.uploadFailed')))
}
} catch (e) {
reject(new Error('上传失败'))
reject(new Error(i18n.t('register.uploadFailed')))
}
},
fail: function () { reject(new Error('上传失败')) }
fail: function () { reject(new Error(i18n.t('register.uploadFailed'))) }
})
})
},
@@ -105,12 +110,12 @@ Page({
var avatarUrl = self.data.avatarUrl
if (!self.data.phone) {
wx.showToast({ title: '请先授权手机号', icon: 'none' })
wx.showToast({ title: i18n.t('register.phoneFirst'), icon: 'none' })
return
}
if (!nickname) {
wx.showToast({ title: '请输入昵称', icon: 'none' })
wx.showToast({ title: i18n.t('register.nicknameRequired'), icon: 'none' })
return
}
@@ -132,11 +137,11 @@ Page({
wx.switchTab({ url: '/pages/index/index' })
}).catch(function (err) {
self.setData({ loading: false })
wx.showToast({ title: err.message || '保存失败', icon: 'none' })
wx.showToast({ title: err.message || i18n.t('common.saveFailed'), icon: 'none' })
})
},
onAgreement: function () {
wx.showModal({ title: '提示', content: '用户协议和隐私政策内容建设中', showCancel: false })
wx.showModal({ title: i18n.t('register.agreementTitle'), content: i18n.t('register.agreementContent'), showCancel: false })
}
})
+10 -10
查看文件
@@ -1,24 +1,24 @@
<view class="page">
<view class="page-header page-header-pink" style="padding-top: {{statusBarHeight + 24}}px;">
<view class="page-header-title">LumiFlow</view>
<view class="page-header-subtitle">完善个人资料</view>
<view class="page-header-subtitle">{{i18n.register.subtitle}}</view>
</view>
<view class="page-content">
<view class="section-title">设置头像</view>
<view class="section-title">{{i18n.register.avatarSection}}</view>
<view class="avatar-area">
<view class="avatar-wrap" bindtap="onPickAvatar">
<image class="avatar-img" src="{{avatarUrl}}" mode="aspectFill"></image>
<view class="avatar-edit-hint">点击更换</view>
<view class="avatar-edit-hint">{{i18n.register.changeAvatar}}</view>
</view>
</view>
<view class="section-title">设置昵称</view>
<view class="section-title">{{i18n.register.nicknameSection}}</view>
<view class="input-wrap">
<input type="nickname" class="weui-input nickname-input" value="{{nickname}}" bindinput="onNicknameInput" bindblur="onNicknameBlur" placeholder="请输入昵称" />
<input type="nickname" class="weui-input nickname-input" value="{{nickname}}" bindinput="onNicknameInput" bindblur="onNicknameBlur" placeholder="{{i18n.register.nicknamePlaceholder}}" />
</view>
<view class="section-title">授权手机号</view>
<view class="section-title">{{i18n.register.authorizePhone}}</view>
<view class="phone-area">
<block wx:if="{{phone}}">
<view class="phone-done">
@@ -28,16 +28,16 @@
</block>
<block wx:else>
<button class="phone-btn" open-type="getPhoneNumber" bindgetphonenumber="onGetPhoneNumber" disabled="{{loading}}">
授权手机号
{{i18n.register.authorizePhone}}
</button>
<view class="phone-hint">手机号用于接收服务通知,必须授权</view>
<view class="phone-hint">{{i18n.register.phoneHint}}</view>
</block>
</view>
<button class="submit-btn" bindtap="onSubmit" loading="{{loading}}" disabled="{{loading}}">
完成注册
{{i18n.register.submit}}
</button>
<view class="agreement" bindtap="onAgreement">注册即表示同意《用户协议》和《隐私政策》</view>
<view class="agreement" bindtap="onAgreement">{{i18n.register.agreement}}</view>
</view>
</view>
@@ -0,0 +1,216 @@
var api = require('../../utils/api')
var ble = require('../../services/ble')
var diagnosis = require('../../services/diagnosis')
var i18n = require('../../i18n/index')
// diagnosis 的 wave key ('ir') 与 common.wavelengths 的 key ('infrared') 不同名,需映射
var WAVE_COMMON_KEY = { red: 'red', ir: 'infrared', uv: 'uv', yellow: '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: [],
faceZones: [],
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) }
})
function regionName(key) {
return (common.regions && common.regions[key]) || key
}
function modeName(waveKey) {
return (d.modeNames && d.modeNames[waveKey]) || ''
}
var regionList = (report.regions || []).map(function (r) {
return {
name: regionName(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
}
})
// 推荐护理:现在 recommend_plan 每区一条(可不同色),一条 BLE 命令可全部下发
var recommendList = (report.recommend_plan || []).map(function (p) {
return {
modeLabel: modeName(p.wave),
wavelengthLabel: common.wavelengths[WAVE_COMMON_KEY[p.wave]] || '',
regionsLabel: regionName(p.region),
levelLabel: levelLabel(p.level),
mask: p.mask
}
})
// 面罩示意图数据(固定 5 区,未扫描/无问题的区置灰)
var byKey = {}
;(report.regions || []).forEach(function (r) { byKey[r.region] = r })
var faceZones = diagnosis.REGION_DEFS.map(function (def) {
var r = byKey[def.key]
var hasProblem = !!(r && r.top_problem && r.wave)
return {
key: def.key,
waveKey: hasProblem ? r.wave : null,
name: regionName(def.key),
hasProblem: hasProblem,
modeLabel: hasProblem ? modeName(r.wave) : '',
levelLabel: hasProblem ? levelLabel(r.level) : '',
colorClass: hasProblem ? 'zone-' + r.wave : 'zone-none'
}
})
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,
faceZones: faceZones,
error: ''
})
},
onTapZone: function (e) {
var wave = e.currentTarget.dataset.wave
wx.navigateTo({ url: '/pages/light-info/light-info' + (wave ? '?wave=' + wave : '') })
},
onStartRecommend: function () {
var self = this
var report = self._report || {}
var plan = report.recommend_plan || []
if (!ble.isConnected()) {
self.setData({ error: i18n.t('common.deviceNotConnected') })
return
}
if (!plan.length) return
// 一条 33B 命令携带全部分区差异化方案(每区各自的光色,亮度来自 diagnosis config,默认 204=80%
var mask = report.recommend_mask || 0
var wl = plan[0].wavelength || 2 // treating 页展示用(多色场景显示首个)
self.setData({ starting: true, error: '' })
ble.setParams({
plan: plan.map(function (p) {
return { mask: p.mask, wave: p.wave, brightness: p.brightness }
}),
duration_ms: 600000,
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' })
}
})
@@ -0,0 +1,4 @@
{
"navigationStyle": "custom",
"navigationBarTitleText": "扫描报告"
}
@@ -0,0 +1,103 @@
<view class="page">
<view class="page-header page-header-gradient" style="padding-top:{{statusBarHeight + 8}}px;">
<view class="nav-back nav-back-light" bindtap="onBack"> {{i18n.common.back}}</view>
<view class="page-header-title">{{i18n.report.title}}</view>
<view class="page-header-subtitle">{{i18n.report.subtitle}}</view>
</view>
<view class="loading-container" wx:if="{{loading}}">
<view class="loading-spinner"></view>
<text class="loading-text">{{i18n.common.loading}}</text>
</view>
<view class="page-content" wx:if="{{!loading}}">
<view class="concept-banner" wx:if="{{!calibrated}}">
<text class="concept-icon">ⓘ</text>
<text class="concept-text">{{i18n.report.conceptNotice}}</text>
</view>
<view class="scan-time">{{i18n.report.scannedAt}}{{scannedAtText}}</view>
<!-- Overall -->
<view class="card">
<view class="card-title">{{i18n.report.overallTitle}}</view>
<view class="chip-row" wx:if="{{hasProblem}}">
<view class="chip chip-lv{{item.level}}" wx:for="{{overallList}}" wx:key="label">
<text class="chip-name">{{item.label}}</text>
<text class="chip-lv">{{item.levelLabel}}</text>
</view>
</view>
<view class="empty-hint" wx:else>{{i18n.report.noProblem}}</view>
</view>
<!-- Face zones — 脸型图布局(与「使用设置」区域选择一致,果果 2026-07-28 定稿) -->
<view class="card">
<view class="card-title">{{i18n.report.faceTitle}}</view>
<!-- 脸型图(与「使用设置」同款布局);faceZones 由 REGION_DEFS 定序:0=右 1=左 2=上 3=中 4=下 -->
<view class="face-map">
<view class="face-region forehead {{faceZones[2].colorClass}}" bindtap="onTapZone" data-wave="{{faceZones[2].waveKey}}">
<view class="fz-name">{{faceZones[2].name}}</view>
<view class="fz-mode" wx:if="{{faceZones[2].hasProblem}}">{{faceZones[2].modeLabel}}</view>
</view>
<view class="face-region left-cheek {{faceZones[1].colorClass}}" bindtap="onTapZone" data-wave="{{faceZones[1].waveKey}}">
<view class="fz-name">{{faceZones[1].name}}</view>
<view class="fz-mode" wx:if="{{faceZones[1].hasProblem}}">{{faceZones[1].modeLabel}}</view>
</view>
<view class="face-region right-cheek {{faceZones[0].colorClass}}" bindtap="onTapZone" data-wave="{{faceZones[0].waveKey}}">
<view class="fz-name">{{faceZones[0].name}}</view>
<view class="fz-mode" wx:if="{{faceZones[0].hasProblem}}">{{faceZones[0].modeLabel}}</view>
</view>
<view class="face-region nose {{faceZones[3].colorClass}}" bindtap="onTapZone" data-wave="{{faceZones[3].waveKey}}">
<view class="fz-name">{{faceZones[3].name}}</view>
<view class="fz-mode" wx:if="{{faceZones[3].hasProblem}}">{{faceZones[3].modeLabel}}</view>
</view>
<view class="face-region chin {{faceZones[4].colorClass}}" bindtap="onTapZone" data-wave="{{faceZones[4].waveKey}}">
<view class="fz-name">{{faceZones[4].name}}</view>
<view class="fz-mode" wx:if="{{faceZones[4].hasProblem}}">{{faceZones[4].modeLabel}}</view>
</view>
</view>
<!-- 底部图例:各光简要功效(果果指定保留) -->
<view class="face-legend">
<view class="legend-item"><view class="legend-dot dot-red"></view><text class="legend-label">{{i18n.report.modeNames.red}}</text></view>
<view class="legend-item"><view class="legend-dot dot-ir"></view><text class="legend-label">{{i18n.report.modeNames.ir}}</text></view>
<view class="legend-item"><view class="legend-dot dot-uv"></view><text class="legend-label">{{i18n.report.modeNames.uv}}</text></view>
<view class="legend-item"><view class="legend-dot dot-yellow"></view><text class="legend-label">{{i18n.report.modeNames.yellow}}</text></view>
</view>
<view class="face-tap-hint">{{i18n.report.faceTapHint}}</view>
<view class="face-all-normal-hint" wx:if="{{!hasProblem}}">{{i18n.report.allNormalHint}}</view>
</view>
<!-- Per-region -->
<view class="card">
<view class="card-title">{{i18n.report.regionTitle}}</view>
<view class="region-row" wx:for="{{regionList}}" wx:key="name">
<text class="region-name">{{item.name}}</text>
<view class="region-right">
<text class="region-problem {{item.hasProblem ? '' : 'region-problem-none'}}">{{item.problemLabel}}</text>
<text class="region-lv region-lv{{item.level}}" wx:if="{{item.hasProblem}}">{{item.levelLabel}}</text>
</view>
</view>
</view>
<!-- Recommendation -->
<view class="card" wx:if="{{recommendList.length > 0}}">
<view class="card-title">{{i18n.report.recommendTitle}}</view>
<view class="rec-row" wx:for="{{recommendList}}" wx:key="mask">
<view class="rec-dot"></view>
<view class="rec-body">
<text class="rec-label">{{item.modeLabel}}</text>
<text class="rec-meta">{{item.wavelengthLabel}} · {{item.regionsLabel}}</text>
</view>
</view>
</view>
<view class="error-text" wx:if="{{error}}">{{error}}</view>
<view class="actions">
<button class="btn-primary" bindtap="onStartRecommend" disabled="{{starting}}" loading="{{starting}}" wx:if="{{recommendList.length > 0}}">{{i18n.report.startTreatment}}</button>
<button class="btn-secondary" bindtap="onManual">{{i18n.report.manualInstead}}</button>
</view>
<view class="disclaimer">{{i18n.report.disclaimer}}</view>
</view>
</view>
@@ -0,0 +1,147 @@
.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; }
/* 脸型图 — 与「使用设置」同款布局,放大以容纳模式名 */
.face-map {
width: 320rpx;
height: 400rpx;
margin: 0 auto 16rpx;
position: relative;
}
.face-region {
position: absolute;
border: 3rpx solid #e5e5e5;
border-radius: 50%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: #FAFAFA;
color: #999;
}
.face-region:active { transform: scale(0.96); }
.fz-name { font-size: 22rpx; font-weight: 600; }
.fz-mode { font-size: 18rpx; margin-top: 2rpx; }
.face-region.forehead { top: 5%; left: 25%; width: 50%; height: 15%; border-radius: 40rpx 40rpx 50% 50%; }
.face-region.left-cheek { top: 25%; left: 5%; width: 30%; height: 25%; }
.face-region.right-cheek { top: 25%; right: 5%; width: 30%; height: 25%; }
.face-region.nose { top: 45%; left: 35%; width: 30%; height: 18%; border-radius: 40%; }
.face-region.chin { top: 68%; left: 25%; width: 50%; height: 20%; border-radius: 0 0 50% 50%; }
/* 波长配色(置于 .face-region 之后以覆盖默认色) */
.zone-red { background: rgba(232, 93, 93, 0.14); border-color: rgba(232, 93, 93, 0.55); color: #B23C3C; }
.zone-ir { background: rgba(192, 107, 74, 0.14); border-color: rgba(192, 107, 74, 0.55); color: #8C4A2E; }
.zone-uv { background: rgba(142, 107, 216, 0.14); border-color: rgba(142, 107, 216, 0.55); color: #5F3FAE; }
.zone-yellow { background: rgba(232, 185, 61, 0.16); border-color: rgba(232, 185, 61, 0.6); color: #8A6A10; }
.zone-none { background: #FAFAFA; border-color: #e5e5e5; color: #999; }
/* 底部图例:各光简要功效 */
.face-legend { display: flex; flex-wrap: wrap; justify-content: center; margin-top: 8rpx; }
.legend-item { display: flex; align-items: center; margin: 0 16rpx 10rpx 0; }
.legend-dot { width: 16rpx; height: 16rpx; border-radius: 50%; margin-right: 8rpx; }
.dot-red { background: #E85D5D; }
.dot-ir { background: #C06B4A; }
.dot-uv { background: #8E6BD8; }
.dot-yellow { background: #E8B93D; }
.legend-label { font-size: 22rpx; color: #888; }
.face-tap-hint { font-size: 22rpx; color: #bbb; margin-top: 14rpx; text-align: center; }
.face-all-normal-hint { font-size: 24rpx; color: #6BBF59; text-align: center; margin-top: 12rpx; }
.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;
}
+11 -6
查看文件
@@ -1,4 +1,5 @@
var http = require('../../utils/request')
var i18n = require('../../i18n/index')
Page({
data: {
@@ -8,6 +9,10 @@ Page({
error: ''
},
onShow: function () {
i18n.bind(this)
},
onBack: function () {
wx.navigateBack({
fail: function () {
@@ -31,7 +36,7 @@ Page({
success: function (res) {
var deviceId = self.parseDeviceId(res.result)
if (!deviceId) {
self.setData({ scanning: false, error: '无效的设备二维码' })
self.setData({ scanning: false, error: i18n.t('scan.invalidQr') })
return
}
self.bindDevice(deviceId)
@@ -66,12 +71,12 @@ Page({
}).catch(function (err) {
self.setData({ scanning: false })
if (err && err.code === 2001) {
wx.showToast({ title: '已绑定设备', icon: 'none' })
wx.showToast({ title: i18n.t('scan.alreadyBound'), icon: 'none' })
setTimeout(function () {
wx.navigateBack()
}, 1500)
} else {
self.setData({ error: err.message || '绑定失败' })
self.setData({ error: err.message || i18n.t('scan.bindFailed') })
}
})
},
@@ -79,16 +84,16 @@ Page({
onManualInput: function () {
var self = this
wx.showModal({
title: '手动输入设备号',
title: i18n.t('scan.manualInput'),
editable: true,
placeholderText: '请输入设备ID,如 672B6D5A 4DCD861',
placeholderText: i18n.t('scan.manualPlaceholder'),
success: function (res) {
if (res.confirm && res.content) {
var deviceId = self.parseDeviceId(res.content)
if (deviceId) {
self.bindDevice(deviceId)
} else {
wx.showToast({ title: '设备号格式不正确', icon: 'none' })
wx.showToast({ title: i18n.t('scan.invalidFormat'), icon: 'none' })
}
}
}
+6 -6
查看文件
@@ -1,20 +1,20 @@
<view class="page">
<view class="page-header page-header-pink" style="padding-top: {{statusBarHeight + 24}}px;">
<view class="nav-back" bindtap="onBack"> 返回</view>
<view class="page-header-title">扫码绑定</view>
<view class="page-header-subtitle">扫描设备底部二维码</view>
<view class="nav-back" bindtap="onBack"> {{i18n.common.back}}</view>
<view class="page-header-title">{{i18n.scan.title}}</view>
<view class="page-header-subtitle">{{i18n.scan.subtitle}}</view>
</view>
<view class="page-content">
<view class="scan-area">
<text class="scan-icon">📷</text>
<text class="scan-text">扫描设备二维码</text>
<text class="scan-text">{{i18n.scan.scanText}}</text>
</view>
<button class="btn-primary" bindtap="onScan" disabled="{{scanning}}">
{{scanning ? '扫描中...' : '扫码'}}
{{scanning ? i18n.scan.scanning : i18n.scan.scan}}
</button>
<button class="btn-secondary" bindtap="onManualInput">手动输入设备号</button>
<button class="btn-secondary" bindtap="onManualInput">{{i18n.scan.manualInput}}</button>
<view class="error-text" wx:if="{{error}}">{{error}}</view>
</view>
@@ -1,4 +1,5 @@
var api = require('../../utils/api')
var i18n = require('../../i18n/index')
Page({
data: {
@@ -8,6 +9,7 @@ Page({
purchasing: false,
subscription: null,
subRemaining: 0,
subRemainingText: '',
loadingPlans: true
},
@@ -25,10 +27,18 @@ Page({
},
onShow: function () {
i18n.bind(this)
this.loadPlans()
this.loadSubscription()
},
planName: function (key) {
if (key === 'trial') return i18n.t('subPlans.planTrial')
if (key === 'monthly') return i18n.t('subPlans.planMonthly')
if (key === 'yearly') return i18n.t('subPlans.planYearly')
return key
},
loadPlans: function () {
var self = this
self.setData({ loadingPlans: true })
@@ -41,16 +51,16 @@ Page({
var plans = serverPlans.map(function (p) {
var daily = p.days > 0 ? (p.price / p.days).toFixed(1) : 0
var desc = ''
if (p.key === 'trial') desc = p.days + '天免费体验'
else if (p.key === 'yearly') desc = '省¥' + Math.round(monthlyPrice * 12 - p.price)
else desc = '约' + daily + '元/天'
if (p.key === 'trial') desc = i18n.t('subPlans.descTrial', { days: p.days })
else if (p.key === 'yearly') desc = i18n.t('subPlans.descSave', { amount: Math.round(monthlyPrice * 12 - p.price) })
else desc = i18n.t('subPlans.descDaily', { price: daily })
return {
key: p.key,
name: p.name,
name: self.planName(p.key),
price: p.price,
priceLabel: p.key === 'trial' ? '免费' : '¥' + p.price,
priceLabel: p.key === 'trial' ? i18n.t('subPlans.free') : '¥' + p.price,
desc: desc,
tag: p.key === 'yearly' ? '推荐' : '',
tag: p.key === 'yearly' ? i18n.t('subPlans.tagRecommend') : '',
disabled: false
}
})
@@ -61,9 +71,9 @@ Page({
self.setData({
loadingPlans: false,
plans: [
{ key: 'trial', name: '试用', price: 0, priceLabel: '免费', desc: '7天免费体验', disabled: false },
{ key: 'monthly', name: '月卡', price: 99, priceLabel: '¥99', desc: '约3.3元/天' },
{ key: 'yearly', name: '年卡', price: 899, priceLabel: '¥899', desc: '省¥289', tag: '推荐' }
{ key: 'trial', name: self.planName('trial'), price: 0, priceLabel: i18n.t('subPlans.free'), desc: i18n.t('subPlans.descTrial', { days: 7 }), disabled: false },
{ key: 'monthly', name: self.planName('monthly'), price: 99, priceLabel: '¥99', desc: i18n.t('subPlans.descDaily', { price: '3.3' }) },
{ key: 'yearly', name: self.planName('yearly'), price: 899, priceLabel: '¥899', desc: i18n.t('subPlans.descSave', { amount: 289 }), tag: i18n.t('subPlans.tagRecommend') }
]
})
self.checkTrialUsed()
@@ -75,7 +85,7 @@ Page({
var sub = self.data.subscription
if (sub && sub.trial_used) {
var plans = self.data.plans.map(function (p) {
if (p.key === 'trial') return Object.assign({}, p, { disabled: true, tag: '已使用' })
if (p.key === 'trial') return Object.assign({}, p, { disabled: true, tag: i18n.t('subPlans.tagUsed') })
return p
})
self.setData({ plans: plans })
@@ -86,16 +96,18 @@ Page({
loadSubscription: function () {
var self = this
api.getSubscription().then(function (sub) {
var remaining = sub.remaining_days || 0
self.setData({
subscription: sub,
subRemaining: sub.remaining_days || 0
subRemaining: remaining,
subRemainingText: i18n.t('subPlans.daysValue', { days: remaining })
})
self.checkTrialUsed()
}).catch(function () {})
},
onAgreement: function () {
wx.showModal({ title: '提示', content: '订阅协议内容建设中', showCancel: false })
wx.showModal({ title: i18n.t('subPlans.tipTitle'), content: i18n.t('subPlans.agreementBuilding'), showCancel: false })
},
onSelect: function (e) {
@@ -116,8 +128,8 @@ Page({
if (plan.key === 'trial') {
wx.showModal({
title: '激活试用',
content: '免费试用 ' + plan.desc + '?',
title: i18n.t('subPlans.activateTrialTitle'),
content: i18n.t('subPlans.activateTrialConfirm', { desc: plan.desc }),
success: function (res) {
if (res.confirm) self.doActivateTrial()
}
@@ -127,10 +139,10 @@ Page({
var isRenew = self.data.subscription && self.data.subscription.status === 'active' && self.data.subRemaining > 0
var planDays = plan.key === 'yearly' ? 365 : 30
var title = isRenew ? '确认续费' : '确认支付'
var title = isRenew ? i18n.t('subPlans.confirmRenewTitle') : i18n.t('subPlans.confirmPayTitle')
var content = isRenew
? '在现有订阅基础上延长' + planDays + '天,支付 ¥' + plan.price
: '支付 ¥' + plan.price
? i18n.t('subPlans.renewContent', { days: planDays, price: plan.price })
: i18n.t('subPlans.payContent', { price: plan.price })
wx.showModal({
title: title,
@@ -148,13 +160,13 @@ Page({
self.setData({ purchasing: true })
api.activateTrial().then(function () {
self.setData({ purchasing: false })
wx.showToast({ title: '试用已激活', icon: 'success' })
wx.showToast({ title: i18n.t('subPlans.trialActivated'), icon: 'success' })
setTimeout(function () {
wx.redirectTo({ url: '/pages/subscribe-success/subscribe-success?plan=trial' })
}, 1000)
}).catch(function (err) {
self.setData({ purchasing: false })
wx.showToast({ title: err.message || '激活失败', icon: 'none' })
wx.showToast({ title: err.message || i18n.t('subPlans.activateFailed'), icon: 'none' })
})
},
@@ -164,7 +176,7 @@ Page({
api.purchase(plan.key).then(function (data) {
if (!data.payment_params) {
self.setData({ purchasing: false })
wx.showToast({ title: '支付服务暂不可用', icon: 'none' })
wx.showToast({ title: i18n.t('subPlans.payUnavailable'), icon: 'none' })
return
}
@@ -182,13 +194,13 @@ Page({
},
fail: function (err) {
self.setData({ purchasing: false })
var msg = (err.errMsg || '').indexOf('cancel') > -1 ? '已取消支付' : '支付失败'
var msg = (err.errMsg || '').indexOf('cancel') > -1 ? i18n.t('subPlans.payCancelled') : i18n.t('subPlans.payFailed')
wx.showToast({ title: msg, icon: 'none' })
}
})
}).catch(function (err) {
self.setData({ purchasing: false })
wx.showToast({ title: err.message || '创建订单失败', icon: 'none' })
wx.showToast({ title: err.message || i18n.t('subPlans.createOrderFailed'), icon: 'none' })
})
},
@@ -201,7 +213,7 @@ Page({
api.syncPaymentOrder(orderId).then(function (result) {
if (result.status === 'paid') {
self.setData({ purchasing: false })
wx.showToast({ title: '支付成功', icon: 'success' })
wx.showToast({ title: i18n.t('subPlans.paySuccess'), icon: 'success' })
setTimeout(function () {
wx.redirectTo({ url: '/pages/subscribe-success/subscribe-success?plan=' + planKey })
}, 1000)
@@ -210,7 +222,7 @@ Page({
setTimeout(pollSync, 2000)
} else {
self.setData({ purchasing: false })
wx.showToast({ title: '支付处理中,请稍后在订阅页查看', icon: 'none' })
wx.showToast({ title: i18n.t('subPlans.payProcessing'), icon: 'none' })
}
}).catch(function () {
if (retryCount < maxRetries) {
@@ -218,7 +230,7 @@ Page({
setTimeout(pollSync, 2000)
} else {
self.setData({ purchasing: false })
wx.showToast({ title: '支付处理中,请稍后在订阅页查看', icon: 'none' })
wx.showToast({ title: i18n.t('subPlans.payProcessing'), icon: 'none' })
}
})
}
@@ -1,35 +1,35 @@
<view class="page">
<view class="page-header page-header-gold" style="padding-top: {{statusBarHeight + 24}}px;">
<view class="nav-back" bindtap="onBack"> 返回</view>
<view class="page-header-title">订阅服务</view>
<view class="page-header-subtitle">解锁智能模式</view>
<view class="nav-back" bindtap="onBack"> {{i18n.common.back}}</view>
<view class="page-header-title">{{i18n.subPlans.title}}</view>
<view class="page-header-subtitle">{{i18n.subPlans.subtitle}}</view>
</view>
<view class="page-content">
<!-- Current subscription status -->
<view class="current-sub" wx:if="{{subscription && subscription.status === 'active' && subRemaining > 0}}">
<view class="current-sub-row">
<text class="current-sub-label">当前套餐</text>
<text class="current-sub-value">{{subscription.plan === 'trial' ? '试用' : subscription.plan === 'monthly' ? '月卡' : subscription.plan === 'yearly' ? '年卡' : subscription.plan}}</text>
<text class="current-sub-label">{{i18n.subPlans.currentPlanLabel}}</text>
<text class="current-sub-value">{{subscription.plan === 'trial' ? i18n.subPlans.planTrial : subscription.plan === 'monthly' ? i18n.subPlans.planMonthly : subscription.plan === 'yearly' ? i18n.subPlans.planYearly : subscription.plan}}</text>
</view>
<view class="current-sub-row">
<text class="current-sub-label">剩余天数</text>
<text class="current-sub-value">{{subRemaining}}</text>
<text class="current-sub-label">{{i18n.subPlans.remainDaysLabel}}</text>
<text class="current-sub-value">{{subRemainingText}}</text>
</view>
</view>
<view class="page-title">选择订阅套餐</view>
<view class="page-title">{{i18n.subPlans.choosePlan}}</view>
<view class="loading-plans" wx:if="{{loadingPlans}}">
<view class="loading-spinner"></view>
<text class="loading-text">加载套餐中...</text>
<text class="loading-text">{{i18n.subPlans.loadingPlans}}</text>
</view>
<view class="service-card" wx:if="{{!loadingPlans}}">
<view class="service-icon">✨</view>
<view class="service-info">
<view class="service-name">智能模式</view>
<view class="service-desc">智能自动调节</view>
<view class="service-name">{{i18n.subPlans.smartMode}}</view>
<view class="service-desc">{{i18n.subPlans.smartModeDesc}}</view>
</view>
</view>
@@ -44,9 +44,9 @@
<block wx:if="{{!loadingPlans}}">
<button class="btn-primary btn-primary-gold" bindtap="onPurchase" disabled="{{purchasing}}" loading="{{purchasing}}">
{{subscription && subscription.status === 'active' && subRemaining > 0 ? '续费' : '确认支付'}}
{{subscription && subscription.status === 'active' && subRemaining > 0 ? i18n.subPlans.renew : i18n.subPlans.confirmPay}}
</button>
<view class="agreement" bindtap="onAgreement">支付即表示同意《订阅协议》</view>
<view class="agreement" bindtap="onAgreement">{{i18n.subPlans.agreement}}</view>
</block>
</view>
</view>
@@ -1,8 +1,14 @@
var i18n = require('../../i18n/index')
Page({
data: {
statusBarHeight: 44
},
onShow: function () {
i18n.bind(this)
},
onLoad: function () {
var app = getApp()
this.setData({ statusBarHeight: app.globalData.statusBarHeight })
@@ -1,35 +1,35 @@
<view class="page">
<view class="page-header page-header-pink" style="padding-top: {{statusBarHeight + 24}}px;">
<view class="nav-back" bindtap="onBack"> 返回</view>
<view class="page-header-title">使用设置</view>
<view class="page-header-subtitle">试用期已结束</view>
<view class="nav-back" bindtap="onBack"> {{i18n.common.back}}</view>
<view class="page-header-title">{{i18n.subPrompt.title}}</view>
<view class="page-header-subtitle">{{i18n.subPrompt.subtitle}}</view>
</view>
<view class="page-content">
<view class="page-title">选择模式</view>
<view class="page-title">{{i18n.subPrompt.selectMode}}</view>
<view class="mode-selector">
<view class="mode-option active">
<text class="mode-icon">🔄</text>
<view class="mode-label">普通模式</view>
<view class="mode-sub">6色循环</view>
<view class="mode-label">{{i18n.subPrompt.normalMode}}</view>
<view class="mode-sub">{{i18n.subPrompt.normalModeSub}}</view>
</view>
<view class="mode-option locked">
<text class="mode-icon">🔒</text>
<view class="mode-label">智能模式</view>
<view class="mode-sub">订阅解锁</view>
<view class="mode-label">{{i18n.subPrompt.smartMode}}</view>
<view class="mode-sub">{{i18n.subPrompt.smartModeSub}}</view>
</view>
</view>
<view class="tip-card">
<text class="tip-card-icon">⏰</text>
<view class="tip-card-body">
<view class="tip-card-title">试用已结束</view>
<view class="tip-card-sub">订阅后继续使用智能模式</view>
<view class="tip-card-title">{{i18n.subPrompt.trialEndedTitle}}</view>
<view class="tip-card-sub">{{i18n.subPrompt.trialEndedSub}}</view>
</view>
</view>
<button class="btn-primary btn-primary-gold" bindtap="onViewPlans">立即订阅</button>
<button class="btn-secondary" bindtap="onUseFreeMode">先使用普通模式</button>
<button class="btn-primary btn-primary-gold" bindtap="onViewPlans">{{i18n.subPrompt.subscribeNow}}</button>
<button class="btn-secondary" bindtap="onUseFreeMode">{{i18n.subPrompt.useNormalFirst}}</button>
</view>
</view>
@@ -1,9 +1,10 @@
var api = require('../../utils/api')
var i18n = require('../../i18n/index')
Page({
data: {
statusBarHeight: 44,
planName: '年卡会员',
planName: '',
expiryDate: ''
},
@@ -15,33 +16,56 @@ Page({
})
},
onShow: function () {
i18n.bind(this)
this.render()
},
// Recompute locale-dependent display strings from stored raw values.
render: function () {
this.setData({
planName: this.planNameFor(this._plan),
expiryDate: this._expireTime ? this.formatDate(this._expireTime) : ''
})
},
planNameFor: function (plan) {
if (plan === 'trial') return i18n.t('subSuccess.planTrial')
if (plan === 'monthly') return i18n.t('subSuccess.planMonthly')
if (plan === 'yearly') return i18n.t('subSuccess.planYearly')
return i18n.t('subSuccess.planDefault')
},
formatDate: function (raw) {
var date = new Date(raw)
var y = date.getFullYear()
var m = ('0' + (date.getMonth() + 1)).slice(-2)
var d = ('0' + date.getDate()).slice(-2)
if (i18n.getLocale() === 'en') return y + '-' + m + '-' + d
return y + '年' + m + '月' + d + '日'
},
onLoad: function (options) {
var self = this
var app = getApp()
self.setData({ statusBarHeight: app.globalData.statusBarHeight })
var planMap = { yearly: '年卡会员', monthly: '月卡会员', trial: '试用会员' }
var plan = options.plan || 'yearly'
self.setData({ planName: planMap[plan] || '会员' })
self._plan = plan
// Fetch actual subscription expiry from server instead of computing client-side
api.getSubscription().then(function (res) {
if (res && res.expire_time) {
var date = new Date(res.expire_time)
var y = date.getFullYear()
var m = ('0' + (date.getMonth() + 1)).slice(-2)
var d = ('0' + date.getDate()).slice(-2)
self.setData({ expiryDate: y + '年' + m + '月' + d + '日' })
self._expireTime = res.expire_time
}
self.render()
}).catch(function () {
// Fallback: compute from current date if server call fails
var durationMap = { yearly: 365, monthly: 30, trial: 7 }
var now = new Date()
now.setDate(now.getDate() + (durationMap[plan] || 365))
var y = now.getFullYear()
var m = ('0' + (now.getMonth() + 1)).slice(-2)
var d = ('0' + now.getDate()).slice(-2)
self.setData({ expiryDate: y + '年' + m + '月' + d + '日' })
self._expireTime = now.getTime()
self.render()
})
},
@@ -1,25 +1,25 @@
<view class="page">
<view class="page-header page-header-green" style="padding-top: {{statusBarHeight + 24}}px;">
<view class="nav-back" bindtap="onBack"> 返回</view>
<view class="page-header-title">订阅成功</view>
<view class="page-header-subtitle">欢迎使用智能模式</view>
<view class="nav-back" bindtap="onBack"> {{i18n.common.back}}</view>
<view class="page-header-title">{{i18n.subSuccess.title}}</view>
<view class="page-header-subtitle">{{i18n.subSuccess.subtitle}}</view>
</view>
<view class="page-content">
<view class="success-area">
<view class="success-icon">✓</view>
<view class="success-title">订阅成功!</view>
<view class="success-title">{{i18n.subSuccess.successTitle}}</view>
</view>
<view class="sub-card">
<view class="sub-header">
<text class="sub-name">✨ {{planName}}</text>
<text class="sub-status status-badge status-badge-green">已订阅</text>
<text class="sub-status status-badge status-badge-green">{{i18n.subSuccess.subscribed}}</text>
</view>
<view class="sub-info">有效期至:{{expiryDate}}</view>
<view class="sub-info">{{i18n.subSuccess.validUntil}}{{expiryDate}}</view>
</view>
<button class="btn-primary btn-primary-gold" bindtap="onStartSmart">立即体验智能模式</button>
<button class="btn-secondary btn-secondary-gold" bindtap="onBackHome">返回首页</button>
<button class="btn-primary btn-primary-gold" bindtap="onStartSmart">{{i18n.subSuccess.startSmart}}</button>
<button class="btn-secondary btn-secondary-gold" bindtap="onBackHome">{{i18n.subSuccess.backHome}}</button>
</view>
</view>
+35 -21
查看文件
@@ -1,6 +1,7 @@
var ble = require('../../services/ble')
var api = require('../../utils/api')
var commandSync = require('../../services/command-sync')
var i18n = require('../../i18n/index')
Page({
data: {
statusBarHeight: 44,
@@ -16,26 +17,37 @@ Page({
paused: false,
completed: false,
startedAt: 0,
regionText: '全区域',
regionText: '',
fittingLost: false,
deviceFault: false
},
_getRegionText: function (regionMask) {
var REGION_MAP = {1:'右区', 2:'左区', 4:'上区', 8:'中区', 16:'下区', 32:'左上区', 64:'右上区'}
if (regionMask === 0x7F) return '全区域'
// 掩码 = IO↔物理位置(实测):0x01右 0x02左 0x04上 0x08中 0x10下
var REGION_MAP = {
1: 'common.regions.right',
2: 'common.regions.left',
4: 'common.regions.top',
8: 'common.regions.middle',
16: 'common.regions.bottom'
}
if (regionMask === 0x1F) return i18n.t('treating.allRegions')
var names = []
for (var bit in REGION_MAP) {
if (regionMask & parseInt(bit)) names.push(REGION_MAP[bit])
if (regionMask & parseInt(bit)) names.push(i18n.t(REGION_MAP[bit]))
}
return names.length > 0 ? names.join('+') : '全区域'
return names.length > 0 ? names.join('+') : i18n.t('treating.allRegions')
},
onShow: function () {
i18n.bind(this)
},
onLoad: function (options) {
var app = getApp()
this.setData({ statusBarHeight: app.globalData.statusBarHeight })
this._deviceId = ble.getDeviceId() || (app.globalData.currentDevice && app.globalData.currentDevice.device_id) || ''
var regions = parseInt(options.regions) || 0x7F
var regions = parseInt(options.regions) || 0x1F
this.setData({
regions: regions,
wavelength: parseInt(options.wavelength) || 2,
@@ -87,17 +99,19 @@ Page({
},
onFitting: function (data) {
if (this.data.mode === 0) return
if (this.data.completed) return
if (data.fitting === 0 && !this.data.fittingLost) {
this.setData({ fittingLost: true, paused: true })
wx.showToast({ title: '检测到离肤,请重新佩戴设备', icon: 'none', duration: 3000 })
wx.showToast({ title: i18n.t('treating.fittingLost'), icon: 'none', duration: 3000 })
} else if (data.fitting === 1 && this.data.fittingLost) {
this.setData({ fittingLost: false, paused: false })
wx.showToast({ title: '已重新贴合,继续使用', icon: 'success', duration: 2000 })
wx.showToast({ title: i18n.t('treating.fittingRestored'), icon: 'success', duration: 2000 })
}
},
onRunState: function (data) {
if (this.data.mode === 0) return
if (this._finishing) return
console.log('[TREAT] run_state:', data.run_state)
@@ -110,8 +124,8 @@ Page({
this.setData({ deviceFault: true })
var self = this
wx.showModal({
title: '设备故障',
content: '设备上报故障,已自动停止。',
title: i18n.t('treating.faultTitle'),
content: i18n.t('treating.faultContent'),
showCancel: false,
complete: function () { self.finishAsComplete() }
})
@@ -134,8 +148,8 @@ Page({
if (self.data.battery > 0 && self.data.battery <= 5) {
clearInterval(timer)
wx.showModal({
title: '电量过低',
content: '设备电量不足5%,已自动停止。请及时充电。',
title: i18n.t('treating.lowBatteryTitle'),
content: i18n.t('treating.lowBatteryContent'),
showCancel: false,
complete: function () { self.finishAsComplete() }
})
@@ -144,7 +158,7 @@ Page({
if (self.data.battery > 0 && self.data.battery <= 10 && !self._lowBatteryWarned) {
self._lowBatteryWarned = true
wx.showToast({ title: '电量低,请及时充电', icon: 'none', duration: 3000 })
wx.showToast({ title: i18n.t('treating.lowBatteryWarn'), icon: 'none', duration: 3000 })
}
if (remaining <= 0) {
@@ -260,8 +274,8 @@ Page({
onException: function (err) {
wx.showModal({
title: '设备异常',
content: err.error_msg || '使用过程中出现异常',
title: i18n.t('treating.exceptionTitle'),
content: err.error_msg || i18n.t('treating.exceptionContent'),
showCancel: false,
complete: function () {
wx.navigateBack()
@@ -272,7 +286,7 @@ Page({
onDisconnect: function () {
this.setData({ paused: true })
wx.showToast({
title: '设备连接断开,正在重连...',
title: i18n.t('treating.disconnected'),
icon: 'none',
duration: 3000
})
@@ -281,7 +295,7 @@ Page({
onReconnect: function () {
this.setData({ paused: false })
wx.showToast({
title: '设备已重新连接',
title: i18n.t('treating.reconnected'),
icon: 'success',
duration: 2000
})
@@ -294,8 +308,8 @@ Page({
this._localTimer = null
}
wx.showModal({
title: '连接丢失',
content: '设备蓝牙连接已断开,无法恢复。本次使用数据将保存。',
title: i18n.t('treating.reconnectFailedTitle'),
content: i18n.t('treating.reconnectFailedContent'),
showCancel: false,
complete: function () {
self.finishAsComplete()
@@ -310,8 +324,8 @@ Page({
onStop: function () {
var self = this
wx.showModal({
title: '结束使用',
content: '确定要提前结束本次使用吗?',
title: i18n.t('treating.stopTitle'),
content: i18n.t('treating.stopContent'),
success: function (res) {
if (res.confirm) {
ble.stopTreatment().then(function (res) {
+8 -8
查看文件
@@ -1,14 +1,14 @@
<view class="page">
<view class="page-header page-header-blue" style="padding-top: {{statusBarHeight + 24}}px;">
<view class="nav-back nav-back-light" bindtap="onBack"> 返回</view>
<view class="page-header-title">使用中</view>
<view class="page-header-subtitle">正在使用...</view>
<view class="nav-back nav-back-light" bindtap="onBack"> {{i18n.common.back}}</view>
<view class="page-header-title">{{i18n.treating.title}}</view>
<view class="page-header-subtitle">{{i18n.treating.subtitle}}</view>
</view>
<view class="page-content">
<view class="countdown-area">
<text class="countdown-time">{{remainingText}}</text>
<view class="countdown-label">剩余时间</view>
<view class="countdown-label">{{i18n.treating.remainingLabel}}</view>
</view>
<view class="progress-bar">
@@ -16,17 +16,17 @@
</view>
<view class="mode-tag-area">
<text class="mode-tag">{{mode === 1 ? '✨ 智能模式' : '🔄 普通模式'}} · {{regionText}}</text>
<text class="mode-tag">{{mode === 1 ? i18n.treating.smartMode : i18n.treating.normalMode}} · {{regionText}}</text>
</view>
<view class="relax-card">
<text class="relax-icon">💆</text>
<view class="relax-text">
<text>请放松心情</text>
<text>享受美好时光</text>
<text>{{i18n.treating.relax1}}</text>
<text>{{i18n.treating.relax2}}</text>
</view>
</view>
<button class="btn-primary btn-primary-red" bindtap="onStop">停止使用</button>
<button class="btn-primary btn-primary-red" bindtap="onStop">{{i18n.treating.stopBtn}}</button>
</view>
</view>
@@ -1,5 +1,6 @@
var api = require('../../utils/api')
var ble = require('../../services/ble')
var i18n = require('../../i18n/index')
Page({
data: {
@@ -15,6 +16,10 @@ Page({
synced: false
},
onShow: function () {
i18n.bind(this)
},
onBack: function () {
wx.reLaunch({ url: '/pages/index/index' })
},
@@ -33,7 +38,7 @@ Page({
regions: parseInt(options.regions) || 0,
duration: durationMs,
avgPd: options.avg_pd || 0,
durationText: mins > 0 ? mins + '分钟' : secs + '秒',
durationText: mins > 0 ? i18n.t('treatmentDone.durationMin', { min: mins }) : i18n.t('treatmentDone.durationSec', { sec: secs }),
mode: parseInt(options.mode) || 0,
regionNames: ble.getRegionName(parseInt(options.regions) || 0)
})
@@ -1,30 +1,30 @@
<view class="page">
<view class="page-header page-header-green" style="padding-top: {{statusBarHeight + 24}}px;">
<view class="nav-back" bindtap="onBack"> 返回</view>
<view class="page-header-title">使用完成</view>
<view class="page-header-subtitle">本次使用已结束</view>
<view class="nav-back" bindtap="onBack"> {{i18n.common.back}}</view>
<view class="page-header-title">{{i18n.treatmentDone.title}}</view>
<view class="page-header-subtitle">{{i18n.treatmentDone.headerSub}}</view>
</view>
<view class="page-content">
<view class="result-icon">✓</view>
<view class="result-title">使用完成!</view>
<view class="result-title">{{i18n.treatmentDone.resultTitle}}</view>
<view class="result-data">
<view class="result-item">
<view class="result-value">{{durationText}}</view>
<view class="result-label">使用时长</view>
<view class="result-label">{{i18n.treatmentDone.durationLabel}}</view>
</view>
<view class="result-item">
<view class="result-value">{{regionNames.length}}</view>
<view class="result-label">使用区域</view>
<view class="result-label">{{i18n.treatmentDone.regionLabel}}</view>
</view>
</view>
<view class="result-tip">
<text class="result-tip-icon">✨</text>
<text class="result-tip-text">本次使用已记录</text>
<text class="result-tip-text">{{i18n.treatmentDone.recorded}}</text>
</view>
<button class="btn-primary" bindtap="onBackHome">返回首页</button>
<button class="btn-primary" bindtap="onBackHome">{{i18n.treatmentDone.backHome}}</button>
</view>
</view>
@@ -1,13 +1,14 @@
var ble = require('../../services/ble')
var i18n = require('../../i18n/index')
Page({
data: {
statusBarHeight: 44,
regions: [
{ name: '右区', mask: 0x01, checked: true },
{ name: '左区', mask: 0x02, checked: true },
{ name: '上区', mask: 0x04, checked: true },
{ name: '中区', mask: 0x08, checked: true },
{ name: '下区', mask: 0x10, checked: true }
{ name: '', mask: 0x01, checked: true },
{ name: '', mask: 0x02, checked: true },
{ name: '', mask: 0x04, checked: true },
{ name: '', mask: 0x08, checked: true },
{ name: '', mask: 0x10, checked: true }
],
selectedMode: 0,
subExpired: false,
@@ -25,9 +26,24 @@ Page({
})
},
onManual: function () {
wx.navigateTo({ url: '/pages/manual-treatment/manual-treatment' })
},
onLoad: function () {
var app = getApp()
this.setData({ statusBarHeight: app.globalData.statusBarHeight })
i18n.bind(this)
// 顺序对应 data.regions 掩码 0x01..0x10 = IO1..IO5 = 右/左/上/中/下(实测)
var regionKeys = ['right', 'left', 'top', 'middle', 'bottom']
var regions = this.data.regions
regions.forEach(function (r, i) {
r.name = i18n.t('common.regions.' + regionKeys[i])
})
this.setData({
regions: regions,
subDaysText: i18n.t('setup.remainDays', { days: this.data.subDays })
})
this.checkSubscription()
},
@@ -37,7 +53,8 @@ Page({
http.get('/api/v1/subscription').then(function (sub) {
self.setData({
subExpired: sub.status !== 'active',
subDays: sub.remaining_days || 0
subDays: sub.remaining_days || 0,
subDaysText: i18n.t('setup.remainDays', { days: sub.remaining_days || 0 })
})
}).catch(function () {})
},
@@ -73,7 +90,7 @@ Page({
})
if (mask === 0) {
wx.showToast({ title: '请至少选择一个区域', icon: 'none' })
wx.showToast({ title: i18n.t('setup.noRegion'), icon: 'none' })
return
}
@@ -88,7 +105,7 @@ Page({
ble.setParams({
region_mask: mask,
wavelength: 2,
brightness: 200,
brightness: 204, // 治疗亮度统一 80%(果果 2026-07-28 定案)
duration_ms: self.data.fixedDurationMs,
mode: self.data.selectedMode,
control: 0x02
@@ -103,7 +120,7 @@ Page({
'&mode=' + self.data.selectedMode
})
}).catch(function (err) {
self.setData({ submitting: false, error: err.error_msg || '启动失败' })
self.setData({ submitting: false, error: err.error_msg || i18n.t('setup.startFailed') })
})
},
@@ -1,42 +1,45 @@
<view class="page">
<view class="page-header page-header-pink" style="padding-top: {{statusBarHeight + 24}}px;">
<view class="nav-back" bindtap="onBack"> 返回</view>
<view class="page-header-title">使用设置</view>
<view class="page-header-subtitle">选择模式和区域</view>
<view class="nav-back" bindtap="onBack"> {{i18n.common.back}}</view>
<view class="page-header-title">{{i18n.setup.title}}</view>
<view class="page-header-subtitle">{{i18n.setup.subtitle}}</view>
</view>
<view class="page-content">
<view class="page-title">选择模式</view>
<view class="page-title">{{i18n.setup.selectMode}}</view>
<view class="mode-selector">
<view class="mode-option {{selectedMode === 0 ? 'active' : ''}}" bindtap="onSelectMode" data-value="0">
<text class="mode-icon">🔄</text>
<view class="mode-label">普通模式</view>
<view class="mode-sub">6色循环</view>
<view class="mode-label">{{i18n.setup.normalMode}}</view>
<view class="mode-sub">{{i18n.setup.normalModeSub}}</view>
</view>
<view class="mode-option {{selectedMode === 1 ? 'active' : ''}} {{subExpired ? 'locked' : ''}}" bindtap="onSelectMode" data-value="1">
<text class="mode-icon">✨</text>
<view class="mode-label">智能模式</view>
<view class="mode-sub" wx:if="{{!subExpired}}">剩余{{subDays}}</view>
<view class="mode-sub" wx:else>订阅解锁</view>
<view class="mode-label">{{i18n.setup.smartMode}}</view>
<view class="mode-sub" wx:if="{{!subExpired}}">{{subDaysText}}</view>
<view class="mode-sub" wx:else>{{i18n.setup.subscribeUnlock}}</view>
</view>
</view>
<view class="page-title" style="font-size:28rpx;">选择区域</view>
<view class="mode-tip" wx:if="{{selectedMode === 0}}">普通模式固定 10 分钟,使用区域默认全区域,不可调整</view>
<view class="page-title" style="font-size:28rpx;">{{i18n.setup.selectRegion}}</view>
<view class="mode-tip" wx:if="{{selectedMode === 0}}">{{i18n.setup.normalTip}}</view>
<!-- 位置↔idx(实测 IO↔物理位置):idx0=右(0x01) idx1=左(0x02) idx2=上(0x04) idx3=中(0x08) idx4=下(0x10) -->
<view class="face-map">
<view class="face-region forehead {{regions[2].checked ? 'active' : ''}} {{selectedMode === 0 ? 'disabled' : ''}}" bindtap="onToggleRegion" data-idx="2">上区</view>
<view class="face-region left-cheek {{regions[1].checked ? 'active' : ''}} {{selectedMode === 0 ? 'disabled' : ''}}" bindtap="onToggleRegion" data-idx="1">左区</view>
<view class="face-region right-cheek {{regions[0].checked ? 'active' : ''}} {{selectedMode === 0 ? 'disabled' : ''}}" bindtap="onToggleRegion" data-idx="0">右区</view>
<view class="face-region nose {{regions[3].checked ? 'active' : ''}} {{selectedMode === 0 ? 'disabled' : ''}}" bindtap="onToggleRegion" data-idx="3">中区</view>
<view class="face-region chin {{regions[4].checked ? 'active' : ''}} {{selectedMode === 0 ? 'disabled' : ''}}" bindtap="onToggleRegion" data-idx="4">下区</view>
<view class="face-region forehead {{regions[2].checked ? 'active' : ''}} {{selectedMode === 0 ? 'disabled' : ''}}" bindtap="onToggleRegion" data-idx="2">{{i18n.common.regions.top}}</view>
<view class="face-region left-cheek {{regions[1].checked ? 'active' : ''}} {{selectedMode === 0 ? 'disabled' : ''}}" bindtap="onToggleRegion" data-idx="1">{{i18n.common.regions.left}}</view>
<view class="face-region right-cheek {{regions[0].checked ? 'active' : ''}} {{selectedMode === 0 ? 'disabled' : ''}}" bindtap="onToggleRegion" data-idx="0">{{i18n.common.regions.right}}</view>
<view class="face-region nose {{regions[3].checked ? 'active' : ''}} {{selectedMode === 0 ? 'disabled' : ''}}" bindtap="onToggleRegion" data-idx="3">{{i18n.common.regions.middle}}</view>
<view class="face-region chin {{regions[4].checked ? 'active' : ''}} {{selectedMode === 0 ? 'disabled' : ''}}" bindtap="onToggleRegion" data-idx="4">{{i18n.common.regions.bottom}}</view>
</view>
<button class="btn-primary" bindtap="onStart" disabled="{{submitting}}" loading="{{submitting}}">
开始使用
{{i18n.setup.startBtn}}
</button>
<view class="manual-entry" wx:if="{{selectedMode === 1}}" bindtap="onManual" style="text-align:center;color:#E8503A;font-size:26rpx;margin-top:28rpx;">{{i18n.manual.entryLink}}</view>
<view class="error-text" wx:if="{{error}}">{{error}}</view>
</view>
</view>
+32 -82
查看文件
@@ -1,14 +1,16 @@
var ble = require('../../services/ble')
var FITTING_TIMEOUT = 10000
var i18n = require('../../i18n/index')
Page({
data: {
statusBarHeight: 44,
checking: false,
result: null,
error: '',
battery: 0,
connected: false
connected: false,
reconnecting: false
},
onShow: function () {
i18n.bind(this)
},
onBack: function () {
@@ -21,109 +23,57 @@ Page({
onLoad: function () {
var app = getApp()
this.setData({ statusBarHeight: app.globalData.statusBarHeight })
var device = app.globalData.currentDevice || {}
this.setData({
battery: device.battery || 0,
statusBarHeight: app.globalData.statusBarHeight,
connected: ble.isConnected()
})
},
onShow: function () {
this.checkWearing()
},
_cleanup: function () {
if (this._onFitting) { ble.off('fitting', this._onFitting); this._onFitting = null }
if (this._onBattery) { ble.off('battery', this._onBattery); this._onBattery = null }
if (this._fittingTimer) { clearTimeout(this._fittingTimer); this._fittingTimer = null }
},
checkWearing: function () {
var self = this
self._cleanup()
self._resolved = false
self.setData({ checking: true, result: null, error: '' })
if (!ble.isConnected()) {
self.setData({ checking: false, result: 'fail', error: '设备未连接,请点击重新连接' })
return
}
self.setData({ connected: true })
self._onBattery = function (info) {
self.setData({ battery: info.battery })
}
ble.on('battery', self._onBattery)
self._onFitting = function (data) {
if (self._resolved) return
console.log('[WEAR] 贴合状态:', data.fitting)
if (data.fitting === 1) {
self._resolved = true
self._cleanup()
self.setData({ checking: false, result: 'ok' })
}
}
ble.on('fitting', self._onFitting)
ble.setParams({
region_mask: 0x1F,
wavelength: 2,
brightness: 26,
hold_time: 0,
control: 0x01
}).then(function () {
console.log('[WEAR] 微弱光贴合检测指令已发送')
}).catch(function () {})
self._fittingTimer = setTimeout(function () {
if (self._resolved) return
self._resolved = true
self._cleanup()
console.log('[WEAR] 超时未收到 fitting 事件,降级为通过(兼容旧固件)')
self.setData({ checking: false, result: 'ok' })
}, FITTING_TIMEOUT)
},
onUnload: function () {
this._cleanup()
clearTimeout(this._reconnectTimer)
ble.stopScan()
var device = app.globalData.currentDevice || {}
this.setData({ battery: device.battery || 0 })
},
onNext: function () {
wx.navigateTo({ url: '/pages/treatment-setup/treatment-setup' })
if (!ble.isConnected()) {
wx.showToast({ title: i18n.t('common.deviceNotConnected'), icon: 'none' })
return
}
wx.redirectTo({
url: '/pages/treatment-setup/treatment-setup'
})
},
onRetry: function () {
var self = this
if (self.data.checking) return
if (self.data.reconnecting) return
if (ble.isConnected()) {
self.checkWearing()
self.setData({ connected: true })
return
}
self.setData({ checking: true, result: null, error: '', connected: false })
clearTimeout(self._reconnectTimer)
self.setData({ connected: false, reconnecting: true })
ble.disconnect()
setTimeout(function () {
ble.startScan({
onFound: function () {},
onConnected: function () {
clearTimeout(self._reconnectTimer)
self.setData({ checking: false, connected: true })
self.checkWearing()
self.setData({ connected: true, reconnecting: false })
wx.showToast({ title: i18n.t('wearCheck.connectSuccess'), icon: 'success' })
},
onError: function (err) {
clearTimeout(self._reconnectTimer)
self.setData({ checking: false, result: 'fail', error: err.msg || '连接失败' })
self.setData({ reconnecting: false })
wx.showToast({ title: err.msg || i18n.t('wearCheck.connectFailed'), icon: 'none' })
}
})
self._reconnectTimer = setTimeout(function () {
ble.stopScan()
self.setData({ checking: false, result: 'fail', error: '搜索超时,请确认设备已开机' })
}, 15000)
self.setData({ reconnecting: false })
wx.showToast({ title: i18n.t('wearCheck.searchTimeout'), icon: 'none' })
}, 5000)
}, 500)
},
onUnload: function () {
clearTimeout(this._reconnectTimer)
ble.stopScan()
}
})
+13 -24
查看文件
@@ -1,8 +1,8 @@
<view class="page">
<view class="page-header page-header-pink" style="padding-top: {{statusBarHeight + 24}}px;">
<view class="nav-back" bindtap="onBack"> 返回</view>
<view class="page-header-title">确认佩戴</view>
<view class="page-header-subtitle">请确保设备正确佩戴</view>
<view class="nav-back" bindtap="onBack"> {{i18n.common.back}}</view>
<view class="page-header-title">{{i18n.wearCheck.title}}</view>
<view class="page-header-subtitle">{{i18n.wearCheck.subtitle}}</view>
</view>
<view class="page-content">
@@ -11,35 +11,24 @@
<view class="device-row-info">
<view class="device-row-name">
LumiFlow
<text class="status-badge status-badge-green" wx:if="{{connected}}">已连接</text>
<text class="status-badge" style="background:#ccc;" wx:else>未连接</text>
<text class="status-badge status-badge-green" wx:if="{{connected}}">{{i18n.wearCheck.connected}}</text>
<text class="status-badge" style="background:#ccc;" wx:else>{{i18n.wearCheck.disconnected}}</text>
</view>
<view class="device-row-battery">电量 {{battery}}%</view>
<view class="device-row-battery">{{i18n.wearCheck.battery}} {{battery}}%</view>
</view>
</view>
<view class="guide-card">
<view class="guide-title">佩戴指引</view>
<view class="guide-img">设备佩戴示意图</view>
<view class="guide-title">{{i18n.wearCheck.guideTitle}}</view>
<view class="guide-img">{{i18n.wearCheck.guideImg}}</view>
<view class="guide-steps">
<view class="guide-step">1. 取出设备,展开弹性绑带</view>
<view class="guide-step">2. 将设备正确佩戴</view>
<view class="guide-step">3. 调节绑带至舒适位置</view>
<view class="guide-step">{{i18n.wearCheck.step1}}</view>
<view class="guide-step">{{i18n.wearCheck.step2}}</view>
<view class="guide-step">{{i18n.wearCheck.step3}}</view>
</view>
</view>
<button class="btn-primary" bindtap="checkWearing" disabled="{{checking}}" loading="{{checking}}">
确认已佩戴
</button>
<button class="btn-secondary" bindtap="onRetry">重新连接</button>
<view class="check-result" wx:if="{{result === 'ok'}}">
<text class="check-ok">✓ 佩戴确认成功</text>
</view>
<view class="check-result" wx:if="{{result === 'fail'}}">
<text class="check-fail">{{error}}</text>
</view>
<button wx:if="{{result === 'ok'}}" class="btn-primary mt-30" bindtap="onNext">设置参数</button>
<button class="btn-primary" wx:if="{{connected}}" bindtap="onNext">{{i18n.wearCheck.confirmBtn}}</button>
<button class="btn-secondary" wx:if="{{!connected}}" bindtap="onRetry" disabled="{{reconnecting}}" loading="{{reconnecting}}">{{reconnecting ? i18n.wearCheck.connecting : i18n.wearCheck.reconnect}}</button>
</view>
</view>
+1 -1
查看文件
@@ -99,7 +99,7 @@ function handleValueChange(res) {
console.warn('[BLE] ADC checksum mismatch, dropping')
return
}
emit('adc', { pd: parsed.pd, vbat: vbatMv, battery: batteryPct, fitting: parsed.fitting, run_state: parsed.run_state })
emit('adc', { pd: parsed.pd, vbat: vbatMv, battery: batteryPct, fitting: parsed.fitting, scan_wave: parsed.scan_wave, run_state: parsed.run_state, is_fault: parsed.is_fault })
emit('battery', { battery: batteryPct, vbat: vbatMv })
emit('fitting', { fitting: parsed.fitting })
emit('run_state', { run_state: parsed.run_state })
+77 -33
查看文件
@@ -57,18 +57,23 @@ var TREAT_MODE = {
SMART: 1
}
// 区域 = IO↔物理位置(果果 2026-07-28 真机实测):右=IO1(0x01) 左=IO2(0x02) 上=IO3(0x04)
// 中=IO4(0x08) 下=IO5(0x10)。此前口头给的"纵列左IO1/中上IO2…"实测不符,以本表为准。
var REGION = {
LEFT_CHEEK: 0x01,
RIGHT_CHEEK: 0x02,
FOREHEAD: 0x04,
CHIN: 0x08,
NOSE: 0x10,
LEFT_EYE: 0x20,
RIGHT_EYE: 0x40,
FULL_FACE: 0x7F
RIGHT: 0x01,
LEFT: 0x02,
TOP: 0x04,
MIDDLE: 0x08,
BOTTOM: 0x10,
FULL_FACE: 0x1F
}
var REGION_NAMES = ['left_cheek', 'right_cheek', 'forehead', 'chin', 'nose', 'left_eye', 'right_eye']
var REGION_NAMES = ['right', 'left', 'top', 'middle', 'bottom']
// FFE4 Byte17 高三位 = 扫描时光色指示(协议简述-添加扫描时光色.docx 2026-07):
// 001=红光 010=红外 011=紫外(蓝) 100=暖黄。注意与下发命令的 WAVELENGTH 编码
// (IR=1,R=2,UV=3,Y=4) 不同——1/2 正好是红与红外互换,绝不能复用那套常量。
var SCAN_WAVE_KEY = { 1: 'red', 2: 'ir', 3: 'uv', 4: 'yellow' }
var DEVICE_ERR = {
0x00: 'SUCCESS',
@@ -167,37 +172,66 @@ var CONTROL = {
TREAT: 0x02
}
// 每 IO 6 字节,颜色格顺序 [红][红外][紫外][暖黄](厂商原表,新版协议简述再次确认)。
// 两种调用方式:
// 单色广播(旧):{ region_mask, wavelength(编码), brightness, ... }
// 分区差异化(新):{ plan: [{ mask, wave:'red'|'ir'|'uv'|'yellow', brightness }], hold_time, control }
// —— 同一条命令里不同区可用不同颜色不同亮度(协议本身就支持 5 IO × 4 色独立)。
var WAVE_BYTE_OFFSET = { red: 0, ir: 1, uv: 2, yellow: 3 }
// 安全包线(builder 层硬上限,纵深防御——即使服务端 diagnosis_config 误配也不可突破):
// 治疗亮度 ≤204(80%),单次时长 ≤600s(10min)。固件侧安全机制大多未实现,此处是最后防线。
var MAX_TREAT_BRIGHTNESS = 204
var MAX_HOLD_SECONDS = 600
function buildVendorCommand(options) {
options = options || {}
var regionMask = options.region_mask || REGION.FULL_FACE
var wavelength = options.wavelength || WAVELENGTH.R
var brightness = clampByte(options.brightness === undefined ? 200 : options.brightness)
var gain = clampUInt16(options.current_gain === undefined ? 0 : options.current_gain)
var durationMs = options.duration_ms || 600000
var holdSeconds = clampUInt16(options.hold_time === undefined ? Math.round(durationMs / 1000) : options.hold_time)
var control = options.control !== undefined ? options.control : CONTROL.TREAT
var regions = [
REGION.LEFT_CHEEK,
REGION.RIGHT_CHEEK,
REGION.FOREHEAD,
REGION.CHIN,
REGION.NOSE
]
if (holdSeconds > MAX_HOLD_SECONDS) holdSeconds = MAX_HOLD_SECONDS
var isTreat = control === CONTROL.TREAT
function clampTreat(b) {
b = clampByte(b)
return isTreat && b > MAX_TREAT_BRIGHTNESS ? MAX_TREAT_BRIGHTNESS : b
}
// IO1..IO5 物理槽位 = bit 升序(Byte n*6 对应 mask 1<<n)。直接用字面量,不依赖
// REGION 常量名——区域↔mask 语义调整时这里必须始终是 0x01,0x02,0x04,0x08,0x10。
var ioMasks = [0x01, 0x02, 0x04, 0x08, 0x10] // IO1=右 IO2=左 IO3=上 IO4=中 IO5=下
var bytes = []
var i, j
for (var i = 0; i < regions.length; i++) {
var enabled = (regionMask & regions[i]) !== 0
var red = 0
var infrared = 0
var uv = 0
var warmYellow = 0
if (enabled) {
if (wavelength === WAVELENGTH.R) red = brightness
if (wavelength === WAVELENGTH.IR) infrared = brightness
if (wavelength === WAVELENGTH.UV) uv = brightness
if (wavelength === WAVELENGTH.Y) warmYellow = brightness
if (options.plan && options.plan.length) {
for (i = 0; i < ioMasks.length; i++) {
var slot = [0, 0, 0, 0]
for (j = 0; j < options.plan.length; j++) {
var p = options.plan[j]
if ((p.mask & ioMasks[i]) === 0) continue
var off = WAVE_BYTE_OFFSET[p.wave]
if (off === undefined) continue
slot[off] = clampTreat(p.brightness === undefined ? 200 : p.brightness)
}
bytes.push(slot[0], slot[1], slot[2], slot[3], (gain >> 8) & 0xFF, gain & 0xFF)
}
} else {
var regionMask = options.region_mask || REGION.FULL_FACE
var wavelength = options.wavelength || WAVELENGTH.R
var brightness = clampTreat(options.brightness === undefined ? 200 : options.brightness)
for (i = 0; i < ioMasks.length; i++) {
var enabled = (regionMask & ioMasks[i]) !== 0
var red = 0
var infrared = 0
var uv = 0
var warmYellow = 0
if (enabled) {
if (wavelength === WAVELENGTH.R) red = brightness
if (wavelength === WAVELENGTH.IR) infrared = brightness
if (wavelength === WAVELENGTH.UV) uv = brightness
if (wavelength === WAVELENGTH.Y) warmYellow = brightness
}
bytes.push(red, infrared, uv, warmYellow, (gain >> 8) & 0xFF, gain & 0xFF)
}
bytes.push(red, infrared, uv, warmYellow, (gain >> 8) & 0xFF, gain & 0xFF)
}
bytes.push((holdSeconds >> 8) & 0xFF, holdSeconds & 0xFF)
@@ -217,13 +251,20 @@ function parseVendorStatus(buffer) {
for (var p = 0; p < 7; p++) {
pds19.push(bytes[p * 2] | (bytes[p * 2 + 1] << 8))
}
// Byte17 复用:固件约定「非 0 即贴合通过」;高三位另表扫描光色(001红/010红外/011紫外/100暖黄)。
// 扫描态 Byte17 = 0x20/0x40/0x60/0x80(有光色→非0→贴合);检测启动但未贴合时 = 0。
// 注意:贴合与光色共用一字节,固件当前无独立贴合位(已反馈,待其拆分)。
// Byte18 运行状态:0=IDLE 1=检测 2=治疗 0xFF=故障。
var scanWaveCode = (bytes[16] >> 5) & 0x07
return {
type: 'adc',
is_heartbeat: false,
pd: pds19,
vbat: bytes[14] | (bytes[15] << 8),
fitting: bytes[16],
fitting: bytes[16] !== 0 ? 1 : 0,
scan_wave: SCAN_WAVE_KEY[scanWaveCode] || null,
run_state: bytes[17],
is_fault: bytes[17] === 0xFF,
checksum_ok: checksum19 === bytes[18],
raw_hex: bytesToHex(bytes)
}
@@ -326,11 +367,13 @@ function parseException(payload) {
// --- display helpers ---
// 掩码 = IO↔物理位置(与 REGION 常量一致)。注意:此处为硬编码中文(历史遗留,
// history/treatment-done 在用);新页面请改用 i18n common.regions.* 取标签。
function getRegionName(mask) {
var names = []
var bits = [
[0x01, '右区'], [0x02, '左区'], [0x04, '上区'],
[0x08, '中区'], [0x10, '下区'], [0x20, '左上区'], [0x40, '右上区']
[0x08, '中区'], [0x10, '下区']
]
for (var i = 0; i < bits.length; i++) {
if (mask & bits[i][0]) names.push(bits[i][1])
@@ -362,6 +405,7 @@ module.exports = {
TREAT_MODE: TREAT_MODE,
REGION: REGION,
REGION_NAMES: REGION_NAMES,
SCAN_WAVE_KEY: SCAN_WAVE_KEY,
CONTROL: CONTROL,
DEVICE_ERR: DEVICE_ERR,
+238
查看文件
@@ -0,0 +1,238 @@
// Skin-tendency diagnosis — 按《扫描+护理完整流程.docx》7.1/7.2/7.3 +
// 《协议简述-添加扫描时光色.docx》+ 果果 2026-07 定案实现。
//
// 核心链路:28 组 PD(4 波长×7 路) → 按 PD_REGION_MAP 聚合成 5 区 →
// 每波长算 5 区平均当基准 → 吸收度 NR = 本区/基准 − 1 →
// 每区取 4 个 NR 的最大者(argmax),超过阈值 TH 才判定问题 → 该波长即该区治疗光色。
//
// 极性(已由固件批注版文档坐实,新版协议简述再次确认"0x0FFF表示最低光照"):
// PD ADC 值越大 = 反射光越弱 = 皮肤吸收越多。故 NR = zone/avg 1(吸收多→NR 高)。
// config.polarity = 'direct' 可切回"值大=光强"的旧假设(备用开关,标定时救急)。
//
// PD↔区域映射(果果转达):左=PD1 上=PD2 中=PD3/6/7 下=PD4 右=PD5(方位与实测 IO 表一致)。
// 仍待定:3 路 PD 区的聚合——文档 7.1 自相矛盾(几何平均 vs 取最小),按更细的几何平均+MAD剔异常实现。
// 所有阈值/亮度/映射均可被服务端 diagnosis_config 覆盖,标定后免发版调参。
// 问题类型 -> 治疗波长编码(下发命令用:IR=1, R=2, UV=3, Y=4)。
var PROBLEM_WAVELENGTH = { aging: 2, acne: 3, pigment: 4, deep: 1 }
// 波长 key -> 揭示的问题类型。
var WAVE_PROBLEM = { red: 'aging', uv: 'acne', yellow: 'pigment', ir: 'deep' }
// 波长 key -> 下发命令编码。
var WAVE_KEY_CODE = { red: 2, uv: 3, yellow: 4, ir: 1 }
// 5 区 = IO↔物理位置(果果 2026-07-28 实测):右=IO1(0x01) 左=IO2(0x02) 上=IO3(0x04) 中=IO4(0x08) 下=IO5(0x10)。
var REGION_DEFS = [
{ key: 'right', mask: 0x01 },
{ key: 'left', mask: 0x02 },
{ key: 'top', mask: 0x04 },
{ key: 'middle', mask: 0x08 },
{ key: 'bottom', mask: 0x10 }
]
var DEFAULT_CONFIG = {
calibrated: false,
polarity: 'inverted', // inverted=值大吸收多(已确认) | direct=旧假设备用
th: 0.10, // 判定阈值(文档建议初始值,需临床标定)
severity: { low: 0.05, mid: 0.15, high: 0.30 }, // 轻/中/重分级,仅报告展示
brightness: 204, // 治疗强度 80%(果果定案),仅用于推荐方案下发
// PD↔区域映射(果果 2026-07-28 确认):左=PD1 上=PD2 中=PD3/6/7 下=PD4 右=PD5(下标 0 基)。
pd_region_map: { right: [4], left: [0], top: [1], middle: [2, 5, 6], bottom: [3] },
problem_wavelength: PROBLEM_WAVELENGTH
}
function _median(arr) {
var s = arr.slice().sort(function (a, b) { return a - b })
var m = Math.floor(s.length / 2)
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2
}
// 区域有效值:1 路直接用;≥3 路按 7.1 节"几何平均 + 剔奇异值"(防黑痣/毛发单点失真)。
// 剔异常用 MAD(中位数绝对偏差)而非文档字面的 3σ:n=3 时单个离群点会把标准差同步撑大,
// 3σ 判据永不触发(审计实证 [900,9000,905] 不剔);MAD 阈值 4.45×MAD ≈ 3σ 等效(3×1.4826)。
function _aggregate(vals) {
if (!vals.length) return 0
if (vals.length === 1) return vals[0]
var kept = vals
if (vals.length >= 3) {
var med = _median(vals)
var devs = vals.map(function (v) { return Math.abs(v - med) })
var mad = _median(devs)
var thr = 4.45 * mad
var filtered = vals.filter(function (v) { return Math.abs(v - med) <= thr })
if (filtered.length >= 2) kept = filtered
}
var logSum = 0
for (var i = 0; i < kept.length; i++) {
if (kept[i] <= 0) return 0 // 有无效 0 值时该区数据不可信
logSum += Math.log(kept[i])
}
return Math.exp(logSum / kept.length)
}
function _severityLevel(nr, severity) {
if (nr > severity.high) return 3
if (nr > severity.mid) return 2
if (nr > severity.low) return 1
return 0
}
// scanData: {
// device_id, scanned_at,
// region_mask, // 本次扫描选中的区域(默认 0x1F)
// waves: { red:[7], ir:[7], uv:[7], yellow:[7] } // 任意子集;每项 = 7 路 PD ADC
// }
function analyze(scanData, config) {
config = config || DEFAULT_CONFIG
var th = config.th !== undefined ? config.th : DEFAULT_CONFIG.th
var severity = config.severity || DEFAULT_CONFIG.severity
var brightness = config.brightness || DEFAULT_CONFIG.brightness
var pdMap = config.pd_region_map || DEFAULT_CONFIG.pd_region_map
var problemWave = config.problem_wavelength || PROBLEM_WAVELENGTH
var inverted = config.polarity !== 'direct'
scanData = scanData || {}
var waves = scanData.waves || {}
var scannedMask = scanData.region_mask || 0x1F
// 1) 聚合:每波长 × 每选中区 → 区域有效值
var zone = {} // zone[waveKey][regionKey] = 有效值
var included = [] // 参与本次扫描的区域定义
var r, def
for (r = 0; r < REGION_DEFS.length; r++) {
if ((scannedMask & REGION_DEFS[r].mask) !== 0) included.push(REGION_DEFS[r])
}
for (var wk in waves) {
if (!waves.hasOwnProperty(wk) || !WAVE_PROBLEM[wk]) continue
var pdArr = waves[wk]
if (!pdArr || !pdArr.length) continue
zone[wk] = {}
for (r = 0; r < included.length; r++) {
def = included[r]
var idxs = pdMap[def.key] || []
var vals = []
for (var k = 0; k < idxs.length; k++) {
var v = pdArr[idxs[k]]
if (v !== undefined && v !== null && v > 0) vals.push(v)
}
if (vals.length) zone[wk][def.key] = _aggregate(vals)
}
}
// 2) 每波长的基准 = 该波长下各区有效值的平均(7.1Avg_PD_X_AllZones
var avg = {}
for (wk in zone) {
if (!zone.hasOwnProperty(wk)) continue
var sum = 0
var n = 0
for (r = 0; r < included.length; r++) {
var zv = zone[wk][included[r].key]
if (zv !== undefined) { sum += zv; n++ }
}
if (n > 0) avg[wk] = sum / n
}
// 3) 吸收度 NR + 每区 argmax 判定
var regions = []
var overallAcc = {}
for (r = 0; r < included.length; r++) {
def = included[r]
var nrs = [] // 该区所有可算波长的 {wave, nr}
for (wk in zone) {
if (!zone.hasOwnProperty(wk)) continue
if (zone[wk][def.key] === undefined || !avg[wk]) continue
var ratio = zone[wk][def.key] / avg[wk]
var nr = inverted ? (ratio - 1) : (1 - ratio)
nrs.push({ wave: wk, nr: Math.round(nr * 1000) / 1000 })
}
nrs.sort(function (a, b) { return b.nr - a.nr })
var top = nrs.length ? nrs[0] : null
var hasProblem = !!(top && top.nr > th)
var problemType = hasProblem ? WAVE_PROBLEM[top.wave] : null
var level = hasProblem ? Math.max(1, _severityLevel(top.nr, severity)) : 0
if (problemType && (!overallAcc[problemType] || level > overallAcc[problemType])) {
overallAcc[problemType] = level
}
regions.push({
region: def.key,
mask: def.mask,
top_problem: problemType,
wave: hasProblem ? top.wave : null,
level: level,
score: top ? top.nr : 0,
nrs: nrs // 全量吸收度,供标定期回看
})
}
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 })
// 4) 推荐方案:每个问题区一条(区独立、可不同色),一条 BLE 命令即可全部下发。
var recommendPlan = []
var recommendMask = 0
for (r = 0; r < regions.length; r++) {
var reg = regions[r]
if (!reg.top_problem || !reg.wave) continue
recommendPlan.push({
region: reg.region,
mask: reg.mask,
wave: reg.wave,
wavelength: WAVE_KEY_CODE[reg.wave],
label_key: reg.top_problem,
level: reg.level,
brightness: brightness
})
recommendMask |= reg.mask
}
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
}
}
// 把 auto-scan 按波长分桶后的平均值组装成 scanData。
// buckets: { red:[7], ir:[7], uv:[7], yellow:[7] } 任意子集(旧固件无波长标识时只有 red)。
function buildScanData(buckets, regionMask, deviceId, scannedAt) {
return {
device_id: deviceId || null,
scanned_at: scannedAt || 0,
region_mask: regionMask || 0x1F,
waves: buckets || {}
}
}
// 无设备数据时的演示报告(开发者工具 mock)。
// 数值按已确认极性编造:值大=吸收多 → 左区红光偏高(光老化)、中区紫外偏高(痘痘油脂)。
function mockReport() {
return analyze({
device_id: 'MOCK',
scanned_at: 0,
region_mask: 0x1F,
waves: {
// 下标: 0=PD1左 1=PD2上 2=PD3中 3=PD4下 4=PD5右 5=PD6中 6=PD7中
red: [1350, 920, 900, 910, 905, 890, 940],
uv: [2050, 2020, 2600, 2010, 2080, 2560, 2620],
yellow: [1500, 1520, 1490, 1505, 1495, 1530, 1510],
ir: [1800, 1790, 1805, 1795, 1800, 1815, 1790]
}
}, DEFAULT_CONFIG)
}
module.exports = {
DEFAULT_CONFIG: DEFAULT_CONFIG,
PROBLEM_WAVELENGTH: PROBLEM_WAVELENGTH,
WAVE_PROBLEM: WAVE_PROBLEM,
WAVE_KEY_CODE: WAVE_KEY_CODE,
REGION_DEFS: REGION_DEFS,
analyze: analyze,
buildScanData: buildScanData,
mockReport: mockReport
}
+6
查看文件
@@ -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) }
@@ -0,0 +1,30 @@
-- 迁移:扫描报告存储 + 诊断配置种子(对应小程序 commit 6b91ebe 的诊断链路上线)
-- 幂等:可重复执行。只做两件事,不碰其他表/账号。
-- 执行方式见 WorkLog: products/jw-beauty/2026-07-28-生产DB迁移指引.md
-- 1) 扫描报告表(Phase 1 已定义,生产一直未建)
CREATE TABLE IF NOT EXISTS scan_reports (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
user_id BIGINT UNSIGNED NOT NULL,
device_id VARCHAR(32) NULL,
scanned_at DATETIME NULL,
regions JSON NULL COMMENT 'array of per-region analysis',
overall JSON NULL COMMENT 'array of overall tendencies',
recommend_mask INT NOT NULL DEFAULT 0 COMMENT 'bitmask of regions recommended for care',
recommend_plan JSON NULL COMMENT 'array of {region, mask, wave, wavelength, label_key, level, brightness}',
raw_pd JSON NULL COMMENT 'raw PD samples for the calibration dataset (may be partial)',
calibrated TINYINT NOT NULL DEFAULT 0 COMMENT 'whether thresholds were calibrated',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_scan_reports_user_created (user_id, created_at),
KEY idx_scan_reports_device_created (device_id, created_at),
CONSTRAINT fk_scan_reports_user FOREIGN KEY (user_id) REFERENCES users (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 2) 诊断配置种子(2026-07-28 新结构:极性/阈值/亮度/PD映射全部热调)
-- pd_region_map 已由固件确认(2026-07-28):左=PD1 中上=PD2 中=PD3/6/7 中下=PD4 右=PD50基下标)
-- ON DUPLICATE KEY UPDATE 会覆盖已有的 diagnosis_config——当前生产从未配置过该键,
-- 若执行前发现已有人工调过的值(见指引文档的核查步骤),先备份再执行。
INSERT INTO system_settings (setting_key, setting_value) VALUES
('diagnosis_config', '{"calibrated": false, "note": "阈值为待标定初始值(文档建议值);polarity=inverted 与 pd_region_map(左PD1/中上PD2/中PD3,6,7/中下PD4/右PD5) 均已由固件确认(2026-07-28)", "polarity": "inverted", "th": 0.10, "severity": {"low": 0.05, "mid": 0.15, "high": 0.30}, "brightness": 204, "pd_region_map": {"left": [0], "top": [1], "middle": [2, 5, 6], "bottom": [3], "right": [4]}, "problem_wavelength": {"aging": 2, "acne": 3, "pigment": 4, "deep": 1}}')
ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value);
+20 -1
查看文件
@@ -84,6 +84,24 @@ CREATE TABLE IF NOT EXISTS treatment_records (
CONSTRAINT fk_treatment_user FOREIGN KEY (user_id) REFERENCES users (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS scan_reports (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
user_id BIGINT UNSIGNED NOT NULL,
device_id VARCHAR(32) NULL,
scanned_at DATETIME NULL,
regions JSON NULL COMMENT 'array of per-region analysis',
overall JSON NULL COMMENT 'array of overall tendencies',
recommend_mask INT NOT NULL DEFAULT 0 COMMENT 'bitmask of regions recommended for care',
recommend_plan JSON NULL COMMENT 'array of {wavelength, mask, label_key}',
raw_pd JSON NULL COMMENT 'raw PD samples for the calibration dataset (may be partial)',
calibrated TINYINT NOT NULL DEFAULT 0 COMMENT 'whether thresholds were calibrated',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_scan_reports_user_created (user_id, created_at),
KEY idx_scan_reports_device_created (device_id, created_at),
CONSTRAINT fk_scan_reports_user FOREIGN KEY (user_id) REFERENCES users (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS device_events (
event_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
device_id VARCHAR(32) NOT NULL,
@@ -185,4 +203,5 @@ INSERT IGNORE INTO system_settings (setting_key, setting_value) VALUES
('enable_register', 'true'),
('enable_binding', 'true'),
('enable_free_mode', 'true'),
('maintenance_mode', 'false');
('maintenance_mode', 'false'),
('diagnosis_config', '{"calibrated": false, "note": "阈值为待标定初始值(文档建议值);polarity=inverted 与 pd_region_map(左PD1/中上PD2/中PD3,6,7/中下PD4/右PD5) 均已由固件确认(2026-07-28)", "polarity": "inverted", "th": 0.10, "severity": {"low": 0.05, "mid": 0.15, "high": 0.30}, "brightness": 204, "pd_region_map": {"left": [0], "top": [1], "middle": [2, 5, 6], "bottom": [3], "right": [4]}, "problem_wavelength": {"aging": 2, "acne": 3, "pigment": 4, "deep": 1}}');
+79
查看文件
@@ -0,0 +1,79 @@
const { query, one, limitClause } = require('../lib/db')
/**
* Create a scan report
* @param {Object} report
* @param {number} report.user_id
* @param {string|null} report.device_id
* @param {string|null} report.scanned_at - MySQL datetime string
* @param {string} report.regions - JSON string (array)
* @param {string} report.overall - JSON string (array)
* @param {number} report.recommend_mask
* @param {string} report.recommend_plan - JSON string (array)
* @param {string} report.raw_pd - JSON string
* @param {number} report.calibrated - 0/1
* @returns {Promise<Object>} query result (has insertId)
*/
async function create(report) {
return query(
`INSERT INTO scan_reports
(user_id, device_id, scanned_at, regions, overall, recommend_mask, recommend_plan, raw_pd, calibrated)
VALUES (:user_id, :device_id, :scanned_at, :regions, :overall, :recommend_mask, :recommend_plan, :raw_pd, :calibrated)`,
report
)
}
/**
* Find a scan report by id
* @param {number} id
* @returns {Promise<Object|null>}
*/
async function findById(id) {
return one('SELECT * FROM scan_reports WHERE id = :id', { id })
}
/**
* Find the latest scan report for a user, optionally filtered by device
* @param {number} userId
* @param {string} [deviceId]
* @returns {Promise<Object|null>}
*/
async function findLatestByUser(userId, deviceId) {
if (deviceId) {
return one(
'SELECT * FROM scan_reports WHERE user_id = :user_id AND device_id = :device_id ORDER BY created_at DESC, id DESC LIMIT 1',
{ user_id: userId, device_id: deviceId }
)
}
return one(
'SELECT * FROM scan_reports WHERE user_id = :user_id ORDER BY created_at DESC, id DESC LIMIT 1',
{ user_id: userId }
)
}
/**
* List scan reports for a user with pagination
* @param {number} userId
* @param {Object} opts
* @param {number} opts.pageSize
* @param {number} opts.offset
* @returns {Promise<{records: Array, total: number}>}
*/
async function listByUser(userId, { pageSize, offset }) {
const total = await query(
'SELECT COUNT(*) AS total FROM scan_reports WHERE user_id = :user_id',
{ user_id: userId }
)
const records = await query(
'SELECT * FROM scan_reports WHERE user_id = :user_id ORDER BY created_at DESC, id DESC' + limitClause(pageSize, offset),
{ user_id: userId }
)
return { records, total: total[0].total }
}
module.exports = {
create,
findById,
findLatestByUser,
listByUser
}
+1 -1
查看文件
@@ -257,7 +257,7 @@ router.get('/settings', requireAdmin, wrap(async (req, res) => {
}))
router.post('/settings', requireAdmin, wrap(async (req, res) => {
const ALLOWED_KEYS = ['system_name', 'admin_email', 'timezone', 'monthly_price', 'yearly_price', 'trial_days', 'enable_register', 'enable_binding', 'enable_free_mode', 'enable_smart_mode', 'maintenance_mode']
const ALLOWED_KEYS = ['system_name', 'admin_email', 'timezone', 'monthly_price', 'yearly_price', 'trial_days', 'enable_register', 'enable_binding', 'enable_free_mode', 'enable_smart_mode', 'maintenance_mode', 'diagnosis_config']
for (const key of Object.keys(req.body || {})) {
if (!ALLOWED_KEYS.includes(key)) continue
await settingsDao.update(key, req.body[key])
+4
查看文件
@@ -28,6 +28,10 @@ router.get('/subscription/plans', wrap(async (req, res) => {
}))
router.get('/subscription', requireUser, wrap(async (req, res) => {
const settings = await getSettings()
if (settings.smart_mode_free === true || settings.smart_mode_free === 'true') {
return res.json(ok({ status: 'active', plan: 'free', remaining_days: 9999, trial_used: false }))
}
const sub = await subscriptionDao.findActive(req.user.user_id)
const trialUsed = await subscriptionDao.findTrial(req.user.user_id)
if (!sub) return res.json(ok({ status: 'inactive', plan: 'none', remaining_days: 0, trial_used: !!trialUsed }))
+47
查看文件
@@ -3,11 +3,21 @@ const { ok, fail } = require('../lib/response')
const { requireUser } = require('../middleware/auth')
const { toMysqlDate } = require('../lib/utils')
const treatmentDao = require('../dao/treatment.dao')
const scanReportDao = require('../dao/scan-report.dao')
const bindingDao = require('../dao/binding.dao')
const logDao = require('../dao/log.dao')
const { getSettings } = require('../lib/settings-cache')
const wrap = fn => (req, res, next) => fn(req, res, next).catch(next)
// Fallback used if diagnosis_config has not been seeded in system_settings yet
const DEFAULT_DIAGNOSIS_CONFIG = {
calibrated: false,
note: '占位阈值,待真机标定后调整',
levels: { low: 0.15, mid: 0.35, high: 0.55 },
problem_wavelength: { aging: 2, acne: 3, pigment: 4, deep: 1 }
}
router.get('/treatment/history', requireUser, wrap(async (req, res) => {
const page = Math.max(1, parseInt(req.query.page, 10) || 1)
const pageSize = Math.min(Math.max(1, parseInt(req.query.page_size, 10) || 20), 100)
@@ -49,6 +59,43 @@ router.post('/treatment/sync', requireUser, wrap(async (req, res) => {
res.json(ok({ record_id: sessionId }))
}))
// --- Scan reports ---
router.post('/treatment/report', requireUser, wrap(async (req, res) => {
const d = req.body || {}
const result = await scanReportDao.create({
user_id: req.user.user_id,
device_id: d.device_id == null ? null : String(d.device_id),
scanned_at: toMysqlDate(d.scanned_at),
regions: JSON.stringify(d.regions || []),
overall: JSON.stringify(d.overall || []),
recommend_mask: parseInt(d.recommend_mask, 10) || 0,
recommend_plan: JSON.stringify(d.recommend_plan || []),
raw_pd: JSON.stringify(d.raw_pd || {}),
calibrated: d.calibrated ? 1 : 0
})
await logDao.write({ user_id: req.user.user_id, action: 'scan_report_create', detail: '保存扫描报告: #' + result.insertId, ip: req.ip })
res.json(ok({ report_id: result.insertId }))
}))
router.get('/treatment/report/latest', requireUser, wrap(async (req, res) => {
const report = await scanReportDao.findLatestByUser(req.user.user_id, req.query.device_id)
res.json(ok(report))
}))
router.get('/treatment/report/:id', requireUser, wrap(async (req, res) => {
const report = await scanReportDao.findById(req.params.id)
if (!report || String(report.user_id) !== String(req.user.user_id)) return res.json(fail(1005, 'record_not_found'))
res.json(ok(report))
}))
// --- Server-driven diagnosis thresholds ---
router.get('/treatment/diagnosis-config', requireUser, wrap(async (req, res) => {
const settings = await getSettings()
res.json(ok(settings.diagnosis_config || DEFAULT_DIAGNOSIS_CONFIG))
}))
router.get('/treatment/:record_id', requireUser, wrap(async (req, res) => {
const record = await treatmentDao.findBySession(req.params.record_id, req.user.user_id)
if (!record) return res.json(fail(1005, 'record_not_found'))