比较提交

...
14 次代码提交
作者 SHA1 备注 提交日期
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
修改 82 个文件,包含 2965 行新增337 行删除
+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) {
+4 -1
查看文件
@@ -17,7 +17,10 @@
"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"
],
"window": {
"navigationBarBackgroundColor": "#ffffff",
+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
}
+40
查看文件
@@ -0,0 +1,40 @@
// 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: '检测超时,未收到数据,请重试',
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',
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'
}
}
+67
查看文件
@@ -0,0 +1,67 @@
// 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: '设备未连接',
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'
}
}
+44
查看文件
@@ -0,0 +1,44 @@
// 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: '手动选择波长护理 '
},
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 '
}
}
+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'
}
}
+57
查看文件
@@ -0,0 +1,57 @@
// Strings for the scan report page (pages/scan-report).
module.exports = {
zh: {
title: '肌肤扫描报告',
subtitle: '基于多光谱反射的肤质倾向参考',
scannedAt: '扫描时间',
overallTitle: '整体倾向',
regionTitle: '分区分析',
recommendTitle: '推荐护理',
noProblem: '未见明显倾向',
startTreatment: '开始推荐护理',
manualInstead: '我自己选',
disclaimer: '本报告依据光反射信号给出肤质倾向参考,不构成医疗诊断。实际护理请结合自身情况。',
conceptNotice: '当前诊断依据尚在标定中,结果仅供参考',
level: { low: '轻度', mid: '中度', high: '明显' },
problems: {
aging: '光老化 / 暗沉',
acne: '痘痘 / 出油',
pigment: '色素 / 暗黄',
deep: '深层循环 / 疲劳'
},
regionNames: {
right: '右脸',
left: '左脸',
top: '额头',
middle: '鼻区',
bottom: '下巴'
}
},
en: {
title: 'Skin Scan Report',
subtitle: 'Skin-tendency reference from multispectral reflection',
scannedAt: 'Scanned at',
overallTitle: 'Overall Tendency',
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'
},
regionNames: {
right: 'Right cheek',
left: 'Left cheek',
top: 'Forehead',
middle: 'Nose',
bottom: 'Chin'
}
}
}
+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'
}
}
+59
查看文件
@@ -0,0 +1,59 @@
// Strings for the treating (live treatment) page. Keep zh/en with identical key structure.
module.exports = {
zh: {
title: '使用中',
subtitle: '正在使用...',
remainingLabel: '剩余时间',
smartMode: '✨ 智能模式',
normalMode: '🔄 普通模式',
allRegions: '全区域',
regionTopLeft: '左上区',
regionTopRight: '右上区',
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',
regionTopLeft: 'Top-left',
regionTopRight: 'Top-right',
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'
}
}
+48 -30
查看文件
@@ -1,4 +1,7 @@
var ble = require('../../services/ble')
var api = require('../../utils/api')
var diagnosis = require('../../services/diagnosis')
var i18n = require('../../i18n/index')
var SCAN_TIMEOUT = 180000
Page({
@@ -29,6 +32,10 @@ Page({
})
},
onShow: function () {
i18n.bind(this)
},
_cleanup: function () {
if (this._timeoutTimer) { clearTimeout(this._timeoutTimer); this._timeoutTimer = null }
if (this._onAdc) { ble.off('adc', this._onAdc); this._onAdc = null }
@@ -40,7 +47,7 @@ Page({
var self = this
if (!ble.isConnected()) {
self.setData({ error: '设备未连接' })
self.setData({ error: i18n.t('common.deviceNotConnected') })
return
}
@@ -88,7 +95,7 @@ Page({
console.log('[SCAN] 未贴合,检测失败')
self._completed = true
self._cleanup()
self.setData({ phase: 'ready', error: '面膜未佩戴好,请重新佩戴后再试' })
self.setData({ phase: 'ready', error: i18n.t('autoScan.notWorn') })
return
}
@@ -127,7 +134,7 @@ Page({
}).catch(function (err) {
console.error('[SCAN] 检测命令发送失败:', err)
self._cleanup()
self.setData({ phase: 'ready', error: '检测命令发送失败' })
self.setData({ phase: 'ready', error: i18n.t('autoScan.cmdFailed') })
})
self._timeoutTimer = setTimeout(function () {
@@ -138,7 +145,7 @@ Page({
} else {
self._completed = true
self._cleanup()
self.setData({ phase: 'ready', error: '检测超时,未收到数据,请重试' })
self.setData({ phase: 'ready', error: i18n.t('autoScan.timeout') })
}
}, SCAN_TIMEOUT)
},
@@ -158,39 +165,50 @@ Page({
getApp().globalData.lastScanPdAvg = avg
console.log('[SCAN] PD平均值:', avg, '帧数:', count)
this._startTreatment()
this._buildAndGoReport(avg)
},
_startTreatment: function () {
// Scan finished -> run the (placeholder) diagnosis, store + persist a report, and
// navigate to the report page so the user sees their skin tendencies and chooses
// care. This replaces the previous auto-start-treatment behavior.
//
// NOTE: today only single-wavelength red PD is collected, and the thresholds +
// PD->region mapping in services/diagnosis.js are placeholders pending calibration.
// Report + persistence work end to end regardless; the numbers get real after
// 4-wavelength scan collection and calibration land.
_buildAndGoReport: function (avg) {
var self = this
var app = getApp()
var mask = self.data.regions || 0x1F
var avg = self.data.pdAvg
var params = self._calculateTreatParams(mask, avg)
// 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()
console.log('[SCAN] 自动发送治疗命令:', JSON.stringify(params))
ble.setParams(params).then(function () {
return ble.startTreatment(mask)
}).then(function () {
wx.redirectTo({
url: '/pages/treating/treating?regions=' + mask +
'&wavelength=2&duration=600000&mode=1'
})
}).catch(function (err) {
self.setData({ error: err.error_msg || '启动治疗失败' })
})
},
function proceed(config) {
var scanData = diagnosis.fromRedScan(avg, mask, deviceId, scannedAt)
var report = diagnosis.analyze(scanData, config || diagnosis.DEFAULT_CONFIG)
app.globalData.lastScanReport = report
_calculateTreatParams: function (mask, pdAvg) {
// TODO: 接入完整诊断算法(NR 归一化吸收率 → 匹配光谱+强度)
// 需要 PD→区域映射确认后实现
return {
region_mask: mask,
wavelength: 2,
brightness: 200,
duration_ms: 600000,
mode: 1,
control: 0x02
// Best-effort persist (also seeds the calibration dataset via raw_pd); never blocks navigation.
api.saveReport({
device_id: report.device_id,
scanned_at: report.scanned_at,
regions: report.regions,
overall: report.overall,
recommend_mask: report.recommend_mask,
recommend_plan: report.recommend_plan,
raw_pd: report.raw_pd,
calibrated: report.calibrated
}).catch(function () {})
wx.redirectTo({ url: '/pages/scan-report/scan-report' })
}
api.getDiagnosisConfig().then(function (cfg) {
proceed(cfg && cfg.levels ? cfg : diagnosis.DEFAULT_CONFIG)
}).catch(function () {
proceed(diagnosis.DEFAULT_CONFIG)
})
},
onUnload: function () {
+8 -9
查看文件
@@ -1,16 +1,15 @@
<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">{{phase === 'ready' ? '准备就绪' : phase === 'waiting_fit' ? '等待佩戴贴合...' : phase === 'collecting' ? '采集数据中 (' + frameCount + '帧)...' : '检测完成,正在启动...'}}</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 wx:if="{{phase === 'ready'}}">
<view class="scan-hint">设备已佩戴,点击下方按钮开始智能检测</view>
<view class="scan-hint sub-hint">检测过程约需 2 分钟,请保持设备贴合</view>
<button class="btn-primary mt-30" bindtap="onStartScan">开始检测</button>
<view class="scan-hint">{{i18n.autoScan.readyHint}}</view>
<button class="btn-primary mt-30" bindtap="onStartScan">{{i18n.autoScan.startBtn}}</button>
</view>
<!-- 等待贴合 -->
@@ -20,7 +19,7 @@
<view class="scan-line"></view>
</view>
</view>
<view class="scan-status">正在检测面膜贴合状态,请确保设备正确佩戴...</view>
<view class="scan-status">{{i18n.autoScan.waitingStatus}}</view>
</view>
<!-- 采集中 -->
@@ -30,12 +29,12 @@
<view class="scan-line"></view>
</view>
</view>
<view class="scan-status">已贴合,正在采集光谱数据... 已收到 {{frameCount}} 帧</view>
<view class="scan-status">{{i18n.autoScan.collectingStatusPre}}{{frameCount}}{{i18n.autoScan.collectingStatusPost}}</view>
</view>
<!-- 完成,自动跳转 -->
<view wx:if="{{phase === 'done'}}">
<view class="scan-status">检测完成(共 {{frameCount}} 帧),正在启动治疗...</view>
<view class="scan-status">{{i18n.autoScan.doneStatusPre}}{{frameCount}}{{i18n.autoScan.doneStatusPost}}</view>
</view>
<view class="scan-error" wx:if="{{error}}">{{error}}</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>
+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,34 @@
var i18n = require('../../i18n/index')
Page({
data: {
statusBarHeight: 44,
// Ordered list of lights; strings live in i18n under lightInfo.lights[key],
// colors are presentation-only and stay in JS.
lights: [
{ key: 'red', color: '#E8503A' },
{ key: 'uv', color: '#7C4DFF' },
{ key: 'yellow', color: '#F5B841' },
{ key: 'infrared', color: '#8B2E3C' }
],
// Parallel to i18n.lightInfo.modes (matched by index).
modeColors: ['#E8503A', '#7C4DFF', '#F5B841', '#8B2E3C', '#F08AA8']
},
onLoad: function () {
var app = getApp()
this.setData({ statusBarHeight: app.globalData.statusBarHeight })
},
onShow: function () {
i18n.bind(this)
},
onBack: function () {
wx.navigateBack({
fail: function () {
wx.switchTab({ url: '/pages/profile/profile' })
}
})
}
})
@@ -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" 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,125 @@
.section-title {
font-size: 30rpx;
font-weight: 600;
color: #333333;
margin-bottom: 24rpx;
}
.section-title-gap {
margin-top: 44rpx;
}
/* 光卡片 */
.light-card {
background: #ffffff;
border-radius: 24rpx;
padding: 32rpx;
margin-bottom: 24rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.04);
}
.light-card-top {
display: flex;
align-items: center;
gap: 24rpx;
margin-bottom: 20rpx;
}
.swatch {
width: 72rpx;
height: 72rpx;
border-radius: 50%;
flex-shrink: 0;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.12);
}
.light-head {
display: flex;
flex-direction: column;
gap: 6rpx;
}
.light-name {
font-size: 32rpx;
font-weight: 600;
color: #333333;
}
.light-wave {
font-size: 24rpx;
color: #999999;
}
.light-effect {
font-size: 28rpx;
font-weight: 600;
margin-bottom: 12rpx;
}
.light-desc {
font-size: 26rpx;
color: #666666;
line-height: 1.6;
margin-bottom: 16rpx;
}
.light-suits {
font-size: 24rpx;
line-height: 1.5;
padding-top: 16rpx;
border-top: 2rpx solid #f2f2f2;
}
.suits-label {
color: #999999;
margin-right: 12rpx;
}
.suits-text {
color: #555555;
}
/* 护理模式 */
.mode-grid {
display: flex;
flex-wrap: wrap;
gap: 20rpx;
}
.mode-card {
width: calc(50% - 10rpx);
box-sizing: border-box;
background: #ffffff;
border-radius: 20rpx;
padding: 28rpx 24rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.04);
}
.mode-dot {
width: 40rpx;
height: 40rpx;
border-radius: 50%;
margin-bottom: 16rpx;
}
.mode-name {
font-size: 28rpx;
font-weight: 600;
color: #333333;
margin-bottom: 8rpx;
}
.mode-desc {
font-size: 24rpx;
color: #888888;
line-height: 1.5;
}
/* 免责声明 */
.disclaimer {
margin-top: 44rpx;
font-size: 22rpx;
color: #aaaaaa;
line-height: 1.6;
text-align: center;
}
+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,143 @@
var ble = require('../../services/ble')
var i18n = require('../../i18n/index')
// Manual care page: user directly picks a care function (-> single light
// wavelength), regions and duration, then starts — skipping the smart scan.
//
// Care function -> wavelength (protocol WAVELENGTH: IR=1, R=2, UV=3, Y=4).
// SINGLE light per session: buildVendorCommand only lights one wavelength.
// FUTURE EXTENSION: a composite/multi-light care mode would require the vendor
// command builder to accept several wavelengths at once — not implemented here.
Page({
data: {
statusBarHeight: 44,
// fixed safe brightness for care sessions: 140 ≈ 55% (护理中档位).
// Do NOT expose a brightness slider (safety); the hardware also enforces
// its own current/thermal limits regardless of this value.
brightness: 140,
functions: [
{ id: 'rejuvenate', wavelength: 2, wl: 'red', color: '#E8503A' },
{ id: 'acne', wavelength: 3, wl: 'uv', color: '#7C4DFF' },
{ id: 'eventone', wavelength: 4, wl: 'yellow', color: '#F5B841' },
{ id: 'revitalize', wavelength: 1, wl: 'infrared', color: '#8B2E3C' }
],
selectedFunctionId: 'rejuvenate',
selectedWavelength: 2,
// region mask mapping identical to treatment-setup: 右=0x01 左=0x02 上=0x04 中=0x08 下=0x10
regions: [
{ key: 'right', mask: 0x01, checked: true },
{ key: 'left', mask: 0x02, checked: true },
{ key: 'top', mask: 0x04, checked: true },
{ key: 'middle', mask: 0x08, checked: true },
{ key: 'bottom', mask: 0x10, checked: true }
],
// safety: a single session must NOT exceed 10 minutes (hardware caps at 10).
durations: [5, 10],
selectedMinutes: 10,
durationOptions: [],
submitting: false,
error: ''
},
onLoad: function () {
var app = getApp()
if (app && app.globalData && app.globalData.statusBarHeight) {
this.setData({ statusBarHeight: app.globalData.statusBarHeight })
}
},
onShow: function () {
i18n.bind(this)
this.refreshDurationOptions()
},
// Duration labels carry a dynamic number, which WXML cannot substitute, so
// build the final strings in JS. Re-run on every onShow to follow locale.
refreshDurationOptions: function () {
var options = this.data.durations.map(function (min) {
return { min: min, label: i18n.t('manual.durationUnit', { min: min }) }
})
this.setData({ durationOptions: options })
},
onBack: function () {
wx.navigateBack({
fail: function () {
wx.reLaunch({ url: '/pages/index/index' })
}
})
},
onSelectFunction: function (e) {
var id = e.currentTarget.dataset.id
var funcs = this.data.functions
var wavelength = this.data.selectedWavelength
for (var i = 0; i < funcs.length; i++) {
if (funcs[i].id === id) {
wavelength = funcs[i].wavelength
break
}
}
this.setData({ selectedFunctionId: id, selectedWavelength: wavelength })
},
onToggleRegion: function (e) {
var idx = e.currentTarget.dataset.idx
var regions = this.data.regions
regions[idx].checked = !regions[idx].checked
this.setData({ regions: regions })
},
onSelectDuration: function (e) {
var min = parseInt(e.currentTarget.dataset.min, 10)
this.setData({ selectedMinutes: min })
},
onStart: function () {
var self = this
if (!ble.isConnected()) {
self.setData({ error: i18n.t('common.deviceNotConnected') })
return
}
var mask = 0
self.data.regions.forEach(function (r) {
if (r.checked) mask |= r.mask
})
if (mask === 0) {
wx.showToast({ title: i18n.t('manual.noRegionSelected'), icon: 'none' })
return
}
var wavelength = self.data.selectedWavelength
var durationMs = self.data.selectedMinutes * 60000
self.setData({ submitting: true, error: '' })
ble.setParams({
region_mask: mask,
wavelength: wavelength,
brightness: self.data.brightness,
duration_ms: durationMs,
mode: 0,
control: 0x02
}).then(function () {
return ble.startTreatment(mask)
}).then(function () {
self.setData({ submitting: false })
wx.redirectTo({
url: '/pages/treating/treating?regions=' + mask +
'&wavelength=' + wavelength +
'&duration=' + durationMs +
'&mode=0'
})
}).catch(function (err) {
self.setData({
submitting: false,
error: (err && err.error_msg) || i18n.t('manual.startFailed')
})
})
}
})
@@ -0,0 +1,4 @@
{
"navigationStyle": "custom",
"navigationBarTitleText": "手动护理"
}
@@ -0,0 +1,58 @@
<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>
<view class="region-grid">
<view
wx:for="{{regions}}"
wx:key="key"
class="region-chip {{item.checked ? 'active' : ''}}"
bindtap="onToggleRegion"
data-idx="{{index}}">
{{i18n.common.regions[item.key]}}
</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,125 @@
.nav-back {
position: absolute;
left: 32rpx;
font-size: 30rpx;
color: #ffffff;
}
.section-title {
font-size: 28rpx;
margin-top: 40rpx;
}
/* care function cards */
.func-grid {
display: flex;
flex-wrap: wrap;
gap: 20rpx;
}
.func-card {
width: calc(50% - 10rpx);
box-sizing: border-box;
padding: 28rpx 24rpx;
background: #ffffff;
border: 4rpx solid #e5e5e5;
border-radius: 24rpx;
}
.func-card.active {
background: #fdf2f8;
}
.func-dot {
width: 40rpx;
height: 40rpx;
border-radius: 50%;
margin-bottom: 16rpx;
}
.func-name {
font-size: 30rpx;
font-weight: 600;
color: #333333;
margin-bottom: 6rpx;
}
.func-wl {
font-size: 24rpx;
margin-bottom: 10rpx;
}
.func-benefit {
font-size: 22rpx;
color: #999999;
line-height: 1.5;
}
/* region checkbox grid */
.region-grid {
display: flex;
flex-wrap: wrap;
gap: 20rpx;
justify-content: center;
}
.region-chip {
min-width: 140rpx;
padding: 20rpx 0;
text-align: center;
border: 4rpx solid #e5e5e5;
border-radius: 16rpx;
font-size: 26rpx;
color: #999999;
flex: 1 1 26%;
}
.region-chip.active {
border-color: #E6508C;
background: rgba(230, 80, 140, 0.12);
color: #E6508C;
}
/* duration segmented control */
.segmented {
display: flex;
gap: 20rpx;
}
.segment {
flex: 1;
padding: 24rpx 0;
text-align: center;
border: 4rpx solid #e5e5e5;
border-radius: 16rpx;
font-size: 28rpx;
color: #666666;
}
.segment.active {
border-color: #E6508C;
background: #fdf2f8;
color: #E6508C;
font-weight: 600;
}
.safety-tip {
margin: 32rpx 0 24rpx;
padding: 20rpx 24rpx;
background: #fff7e6;
border-radius: 16rpx;
font-size: 24rpx;
color: #ad6800;
line-height: 1.6;
}
.error-text {
text-align: center;
color: #ff4d4f;
font-size: 26rpx;
margin-top: 16rpx;
}
.btn-primary::after {
border: none;
}
+35 -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,9 +17,30 @@ Page({
},
onShow: function () {
i18n.bind(this)
this.loadProfile()
},
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 () {
var self = this
self.setData({ loading: true })
@@ -33,9 +55,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 +74,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 +175,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 +194,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')
+44 -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,96 @@
<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" 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.8</view>
<view class="version-text">v0.2.0</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,184 @@
var api = require('../../utils/api')
var ble = require('../../services/ble')
var diagnosis = require('../../services/diagnosis')
var i18n = require('../../i18n/index')
var WAVE_CODE_KEY = { 1: 'infrared', 2: 'red', 3: 'uv', 4: 'yellow' }
function pad2(n) { return (n < 10 ? '0' : '') + n }
function formatTime(ms) {
if (!ms) ms = Date.now()
var d = new Date(ms)
return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate()) +
' ' + pad2(d.getHours()) + ':' + pad2(d.getMinutes())
}
Page({
data: {
statusBarHeight: 44,
loading: true,
calibrated: true,
hasProblem: false,
scannedAtText: '',
overallList: [],
regionList: [],
recommendList: [],
starting: false,
error: ''
},
onLoad: function (options) {
var app = getApp()
this.setData({ statusBarHeight: app.globalData.statusBarHeight })
this._options = options || {}
},
onShow: function () {
i18n.bind(this)
this._loadReport()
},
onBack: function () {
wx.navigateBack({ fail: function () { wx.reLaunch({ url: '/pages/index/index' }) } })
},
_loadReport: function () {
var self = this
var app = getApp()
var opts = self._options || {}
if (opts.report_id) {
self.setData({ loading: true })
api.getReport(opts.report_id).then(function (rep) {
self._render(self._normalize(rep))
}).catch(function () { self._fallback() })
return
}
if (app.globalData.lastScanReport) {
self._render(app.globalData.lastScanReport)
return
}
self.setData({ loading: true })
api.getLatestReport().then(function (rep) {
// Backend ok() coerces null -> {}, so treat an empty payload as "no report".
if (self._hasContent(rep)) self._render(self._normalize(rep))
else self._fallback()
}).catch(function () { self._fallback() })
},
_hasContent: function (rep) {
if (!rep) return false
return !!((rep.regions && rep.regions.length) ||
(rep.overall && rep.overall.length) ||
(rep.recommend_plan && rep.recommend_plan.length))
},
_fallback: function () {
// Preview/demo when no device data is available yet.
this._render(diagnosis.mockReport())
},
_normalize: function (rep) {
if (!rep) return diagnosis.mockReport()
var scannedAt = 0
if (typeof rep.scanned_at === 'number') scannedAt = rep.scanned_at
else if (rep.scanned_at) scannedAt = Date.parse(rep.scanned_at) || 0
return {
device_id: rep.device_id || null,
scanned_at: scannedAt,
regions: rep.regions || [],
overall: rep.overall || [],
recommend_mask: rep.recommend_mask || 0,
recommend_plan: rep.recommend_plan || [],
raw_pd: rep.raw_pd || {},
calibrated: !!rep.calibrated
}
},
_render: function (report) {
var d = i18n.getDict().report
var common = i18n.getDict().common
function levelLabel(l) {
if (l >= 3) return d.level.high
if (l === 2) return d.level.mid
if (l === 1) return d.level.low
return d.noProblem
}
var overallList = (report.overall || []).map(function (o) {
return { label: d.problems[o.type] || o.type, level: o.level, levelLabel: levelLabel(o.level) }
})
var regionList = (report.regions || []).map(function (r) {
return {
name: d.regionNames[r.region] || r.region,
level: r.level,
levelLabel: levelLabel(r.level),
problemLabel: r.top_problem ? (d.problems[r.top_problem] || r.top_problem) : d.noProblem,
hasProblem: !!r.top_problem
}
})
var defs = diagnosis.REGION_DEFS
var recommendList = (report.recommend_plan || []).map(function (p) {
var names = []
for (var i = 0; i < defs.length; i++) {
if (p.mask & defs[i].mask) names.push(d.regionNames[defs[i].key] || defs[i].key)
}
var waveKey = WAVE_CODE_KEY[p.wavelength]
return {
label: d.problems[p.label_key] || '',
wavelengthLabel: (waveKey && common.wavelengths[waveKey]) || '',
regionsLabel: names.join('、'),
wavelength: p.wavelength,
mask: p.mask
}
})
this._report = report
this.setData({
loading: false,
calibrated: !!report.calibrated,
hasProblem: overallList.length > 0,
scannedAtText: formatTime(report.scanned_at),
overallList: overallList,
regionList: regionList,
recommendList: recommendList,
error: ''
})
},
onStartRecommend: function () {
var self = this
var report = self._report || {}
if (!ble.isConnected()) {
self.setData({ error: i18n.t('common.deviceNotConnected') })
return
}
var plan = (report.recommend_plan && report.recommend_plan[0]) || null
var mask = plan ? plan.mask : (report.recommend_mask || 0x1F)
var wl = plan ? plan.wavelength : 2
if (!mask) mask = 0x1F
self.setData({ starting: true, error: '' })
ble.setParams({
region_mask: mask, wavelength: wl, brightness: 140,
duration_ms: 600000, mode: 1, control: 0x02
}).then(function () {
return ble.startTreatment(mask)
}).then(function () {
wx.redirectTo({
url: '/pages/treating/treating?regions=' + mask +
'&wavelength=' + wl + '&duration=600000&mode=1'
})
}).catch(function (err) {
self.setData({ starting: false, error: (err && err.error_msg) || i18n.t('common.networkError') })
})
},
onManual: function () {
wx.redirectTo({ url: '/pages/manual-treatment/manual-treatment' })
}
})
@@ -0,0 +1,4 @@
{
"navigationStyle": "custom",
"navigationBarTitleText": "扫描报告"
}
@@ -0,0 +1,66 @@
<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>
<!-- 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.label}}</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,100 @@
.page {
min-height: 100vh;
background: #f5f5f5;
}
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
padding-top: 120rpx;
}
.loading-spinner {
width: 56rpx;
height: 56rpx;
border: 6rpx solid #eee;
border-top-color: #E8503A;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.loading-text { margin-top: 20rpx; color: #999; font-size: 26rpx; }
@keyframes spin { to { transform: rotate(360deg); } }
.concept-banner {
display: flex;
align-items: center;
background: #FFF7E6;
border: 1rpx solid #FFE1A8;
border-radius: 14rpx;
padding: 18rpx 22rpx;
margin-bottom: 20rpx;
}
.concept-icon { color: #E8971A; margin-right: 12rpx; font-size: 30rpx; }
.concept-text { color: #A66908; font-size: 24rpx; line-height: 1.4; flex: 1; }
.scan-time { color: #999; font-size: 24rpx; margin: 0 6rpx 20rpx; }
.card {
background: #fff;
border-radius: 18rpx;
padding: 28rpx 26rpx;
margin-bottom: 22rpx;
box-shadow: 0 4rpx 18rpx rgba(0,0,0,0.04);
}
.card-title { font-size: 30rpx; font-weight: 600; color: #333; margin-bottom: 20rpx; }
.chip-row { display: flex; flex-wrap: wrap; }
.chip {
display: flex;
align-items: center;
border-radius: 999rpx;
padding: 10rpx 22rpx;
margin: 0 16rpx 16rpx 0;
background: #F3F4F6;
}
.chip-name { font-size: 26rpx; color: #333; }
.chip-lv { font-size: 22rpx; color: #fff; margin-left: 12rpx; padding: 2rpx 14rpx; border-radius: 999rpx; background: #bbb; }
.chip-lv1 .chip-lv { background: #6BBF59; }
.chip-lv2 .chip-lv { background: #F5B841; }
.chip-lv3 .chip-lv { background: #E8503A; }
.empty-hint { color: #999; font-size: 26rpx; }
.region-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx 0;
border-bottom: 1rpx solid #f0f0f0;
}
.region-row:last-child { border-bottom: none; }
.region-name { font-size: 28rpx; color: #333; }
.region-right { display: flex; align-items: center; }
.region-problem { font-size: 26rpx; color: #555; margin-right: 14rpx; }
.region-problem-none { color: #aaa; }
.region-lv { font-size: 22rpx; color: #fff; padding: 2rpx 14rpx; border-radius: 999rpx; background: #bbb; }
.region-lv1 { background: #6BBF59; }
.region-lv2 { background: #F5B841; }
.region-lv3 { background: #E8503A; }
.rec-row { display: flex; align-items: flex-start; padding: 16rpx 0; }
.rec-dot {
width: 14rpx; height: 14rpx; border-radius: 50%;
background: #E8503A; margin-top: 12rpx; margin-right: 18rpx; flex-shrink: 0;
}
.rec-body { display: flex; flex-direction: column; }
.rec-label { font-size: 28rpx; color: #333; font-weight: 500; }
.rec-meta { font-size: 24rpx; color: #999; margin-top: 6rpx; }
.error-text { color: #E8503A; font-size: 26rpx; text-align: center; margin: 10rpx 0; }
.actions { margin-top: 12rpx; }
.actions .btn-primary, .actions .btn-secondary { margin-bottom: 20rpx; }
.disclaimer {
color: #b0b0b0;
font-size: 22rpx;
line-height: 1.5;
text-align: center;
margin: 20rpx 10rpx 40rpx;
}
+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>
+33 -20
查看文件
@@ -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,19 +17,31 @@ 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 '全区域'
var REGION_MAP = {
1: 'common.regions.right',
2: 'common.regions.left',
4: 'common.regions.top',
8: 'common.regions.middle',
16: 'common.regions.bottom',
32: 'treating.regionTopLeft',
64: 'treating.regionTopRight'
}
if (regionMask === 0x7F) 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) {
@@ -91,10 +104,10 @@ Page({
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 })
}
},
@@ -112,8 +125,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() }
})
@@ -136,8 +149,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() }
})
@@ -146,7 +159,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) {
@@ -262,8 +275,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()
@@ -274,7 +287,7 @@ Page({
onDisconnect: function () {
this.setData({ paused: true })
wx.showToast({
title: '设备连接断开,正在重连...',
title: i18n.t('treating.disconnected'),
icon: 'none',
duration: 3000
})
@@ -283,7 +296,7 @@ Page({
onReconnect: function () {
this.setData({ paused: false })
wx.showToast({
title: '设备已重新连接',
title: i18n.t('treating.reconnected'),
icon: 'success',
duration: 2000
})
@@ -296,8 +309,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()
@@ -312,8 +325,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,23 @@ 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)
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 +52,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 +89,7 @@ Page({
})
if (mask === 0) {
wx.showToast({ title: '请至少选择一个区域', icon: 'none' })
wx.showToast({ title: i18n.t('setup.noRegion'), icon: 'none' })
return
}
@@ -103,7 +119,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,44 @@
<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>
<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" 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>
+17 -8
查看文件
@@ -1,10 +1,16 @@
var ble = require('../../services/ble')
var i18n = require('../../i18n/index')
Page({
data: {
statusBarHeight: 44,
battery: 0,
connected: false
connected: false,
reconnecting: false
},
onShow: function () {
i18n.bind(this)
},
onBack: function () {
@@ -27,7 +33,7 @@ Page({
onNext: function () {
if (!ble.isConnected()) {
wx.showToast({ title: '设备未连接', icon: 'none' })
wx.showToast({ title: i18n.t('common.deviceNotConnected'), icon: 'none' })
return
}
wx.redirectTo({
@@ -37,29 +43,32 @@ Page({
onRetry: function () {
var self = this
if (self.data.reconnecting) return
if (ble.isConnected()) {
self.setData({ connected: true })
return
}
self.setData({ connected: false })
self.setData({ connected: false, reconnecting: true })
ble.disconnect()
setTimeout(function () {
ble.startScan({
onFound: function () {},
onConnected: function () {
clearTimeout(self._reconnectTimer)
self.setData({ connected: true })
wx.showToast({ title: '连接成功', icon: 'success' })
self.setData({ connected: true, reconnecting: false })
wx.showToast({ title: i18n.t('wearCheck.connectSuccess'), icon: 'success' })
},
onError: function (err) {
clearTimeout(self._reconnectTimer)
wx.showToast({ title: err.msg || '连接失败', icon: 'none' })
self.setData({ reconnecting: false })
wx.showToast({ title: err.msg || i18n.t('wearCheck.connectFailed'), icon: 'none' })
}
})
self._reconnectTimer = setTimeout(function () {
ble.stopScan()
wx.showToast({ title: '搜索超时', icon: 'none' })
}, 15000)
self.setData({ reconnecting: false })
wx.showToast({ title: i18n.t('wearCheck.searchTimeout'), icon: 'none' })
}, 5000)
}, 500)
},
+13 -13
查看文件
@@ -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,24 +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" wx:if="{{connected}}" bindtap="onNext">确认已佩戴,开始检测</button>
<button class="btn-secondary" wx:if="{{!connected}}" bindtap="onRetry">重新连接</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>
+204
查看文件
@@ -0,0 +1,204 @@
// Skin-tendency diagnosis — FRAMEWORK / placeholder implementation.
//
// STATUS: the diagnosis 依据 is NOT yet calibrated (客户也认为"还只是概念").
// This module defines the full data pipeline so the report UI + storage work end
// to end today, but the thresholds and the PD->region mapping are PLACEHOLDERS to
// be replaced after real-device calibration. It degrades gracefully when only
// partial scan data (currently single-wavelength red) is available.
//
// Two things here need hardware/calibration confirmation before this is "real":
// 1) PD_REGION_MAP — which of the 7 PDs feed which facial region (docs disagree).
// 2) levels thresholds — normalized-absorption cutoffs per severity level.
// Both can be overridden at runtime by the server-side diagnosis_config, so tuning
// after calibration does NOT require an app release.
// Problem type -> recommended care wavelength (IR=1, R=2, UV=3, Y=4).
var PROBLEM_WAVELENGTH = { aging: 2, acne: 3, pigment: 4, deep: 1 }
// Scanned wavelength code -> the problem it reveals (inverse of the above).
var WAVELENGTH_PROBLEM = { 1: 'deep', 2: 'aging', 3: 'acne', 4: 'pigment' }
// Dictionary wave-key -> wavelength code.
var WAVE_KEY_CODE = { red: 2, uv: 3, yellow: 4, ir: 1 }
// 5 regions in the app's neutral convention (right/left/top/middle/bottom),
// matching treatment-setup.js masks.
var REGION_DEFS = [
{ key: 'right', mask: 0x01 },
{ key: 'left', mask: 0x02 },
{ key: 'top', mask: 0x04 },
{ key: 'middle', mask: 0x08 },
{ key: 'bottom', mask: 0x10 }
]
// PLACEHOLDER PD->region map. Flow doc: 7 PDs = 1 special region (3 PDs) + 4 regions
// (1 PD each). The exact assignment is UNCONFIRMED — replace after hardware confirms
// the PD physical layout. Each region lists the PD index(es) that feed its signal.
var PD_REGION_MAP = {
right: [0], left: [1], top: [2], middle: [3, 5, 6], bottom: [4]
}
var MAX_ADC = 4095
var DEFAULT_CONFIG = {
calibrated: false,
levels: { low: 0.15, mid: 0.35, high: 0.55 },
problem_wavelength: PROBLEM_WAVELENGTH
}
function _avg(arr) {
if (!arr || !arr.length) return 0
var s = 0
for (var i = 0; i < arr.length; i++) s += arr[i]
return s / arr.length
}
// Normalized absorption in [0,1]: the more the light is absorbed (lower reflected PD
// relative to the底噪 baseline), the stronger the tendency signal.
function _absorption(pdValue, baseline) {
if (!baseline || baseline <= 0) return 0
var a = (baseline - pdValue) / baseline
if (a < 0) a = 0
if (a > 1) a = 1
return a
}
function _levelOf(score, levels) {
if (score >= levels.high) return 3
if (score >= levels.mid) return 2
if (score >= levels.low) return 1
return 0
}
// scanData: {
// device_id, scanned_at,
// region_mask, // which regions were scanned (default 0x1F)
// noise: [7], // per-PD 底噪 baseline (optional; defaults MAX_ADC)
// waves: { red:[7], uv:[7], yellow:[7], ir:[7] } // any subset; each = 7 PD values
// }
function analyze(scanData, config) {
config = config || DEFAULT_CONFIG
var levels = config.levels || DEFAULT_CONFIG.levels
var problemWave = config.problem_wavelength || PROBLEM_WAVELENGTH
scanData = scanData || {}
var waves = scanData.waves || {}
var noise = scanData.noise
var scannedMask = scanData.region_mask || 0x1F
var regions = []
var overallAcc = {}
for (var r = 0; r < REGION_DEFS.length; r++) {
var def = REGION_DEFS[r]
if ((scannedMask & def.mask) === 0) continue
var pdIdx = PD_REGION_MAP[def.key] || []
var problems = []
for (var wk in waves) {
if (!waves.hasOwnProperty(wk)) continue
var code = WAVE_KEY_CODE[wk]
var problemType = WAVELENGTH_PROBLEM[code]
if (!problemType) continue
var pdVals = []
var baseVals = []
for (var k = 0; k < pdIdx.length; k++) {
var idx = pdIdx[k]
if (waves[wk][idx] !== undefined) pdVals.push(waves[wk][idx])
baseVals.push(noise && noise[idx] !== undefined ? noise[idx] : MAX_ADC)
}
if (!pdVals.length) continue
var score = _absorption(_avg(pdVals), _avg(baseVals))
var level = _levelOf(score, levels)
if (level > 0) {
problems.push({ type: problemType, level: level, score: Math.round(score * 100) / 100 })
if (!overallAcc[problemType] || level > overallAcc[problemType]) {
overallAcc[problemType] = level
}
}
}
problems.sort(function (a, b) { return (b.level - a.level) || (b.score - a.score) })
var top = problems.length ? problems[0] : null
regions.push({
region: def.key,
mask: def.mask,
top_problem: top ? top.type : null,
level: top ? top.level : 0,
problems: problems
})
}
var overall = []
for (var pt in overallAcc) {
if (overallAcc.hasOwnProperty(pt)) overall.push({ type: pt, level: overallAcc[pt] })
}
overall.sort(function (a, b) { return b.level - a.level })
// Recommendation: care each region-with-a-top-problem using that problem's wavelength.
// One BLE command carries one wavelength, so plan entries are grouped by wavelength.
var planByWave = {}
var recommendMask = 0
for (var i = 0; i < regions.length; i++) {
var reg = regions[i]
if (!reg.top_problem) continue
var wl = problemWave[reg.top_problem]
if (!wl) continue
planByWave[wl] = (planByWave[wl] || 0) | reg.mask
recommendMask |= reg.mask
}
var recommendPlan = []
for (var w in planByWave) {
if (!planByWave.hasOwnProperty(w)) continue
var code2 = parseInt(w, 10)
recommendPlan.push({ wavelength: code2, mask: planByWave[w], label_key: WAVELENGTH_PROBLEM[code2] })
}
return {
device_id: scanData.device_id || null,
scanned_at: scanData.scanned_at || 0,
regions: regions,
overall: overall,
recommend_mask: recommendMask,
recommend_plan: recommendPlan,
raw_pd: waves,
calibrated: !!config.calibrated
}
}
// Adapt the current (single-wavelength red) auto-scan result into scanData, so the
// pipeline works today. pdAvg = 7 averaged PD values under red light.
function fromRedScan(pdAvg, regionMask, deviceId, scannedAt, noise) {
return {
device_id: deviceId || null,
scanned_at: scannedAt || 0,
region_mask: regionMask || 0x1F,
noise: noise || null,
waves: { red: (pdAvg || []).slice(0, 7) }
}
}
// Deterministic mock report for UI development / demo when there is no device data.
function mockReport() {
return analyze({
device_id: 'MOCK',
scanned_at: 0,
region_mask: 0x1F,
noise: [4000, 4000, 4000, 4000, 4000, 4000, 4000],
waves: {
red: [3500, 3600, 1900, 3400, 3450, 3500, 3500],
uv: [3700, 3750, 3600, 1700, 3700, 1800, 1900],
yellow: [3500, 3550, 3400, 3450, 2300, 3500, 3520],
ir: [3600, 3600, 3550, 3580, 3560, 3600, 3600]
}
}, DEFAULT_CONFIG)
}
module.exports = {
DEFAULT_CONFIG: DEFAULT_CONFIG,
PROBLEM_WAVELENGTH: PROBLEM_WAVELENGTH,
WAVELENGTH_PROBLEM: WAVELENGTH_PROBLEM,
REGION_DEFS: REGION_DEFS,
analyze: analyze,
fromRedScan: fromRedScan,
mockReport: mockReport
}
+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) }
+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": "占位阈值,待真机标定后调整", "levels": {"low": 0.15, "mid": 0.35, "high": 0.55}, "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'))