比较提交
29
次代码提交
515153f134
..
main
| 作者 | SHA1 | 提交日期 | |
|---|---|---|---|
|
|
a9a1899b49 | ||
|
|
edf15eb32a | ||
|
|
21dccd2274 | ||
|
|
74b0c5fa86 | ||
|
|
c0de0b03a2 | ||
|
|
431d4058a8 | ||
|
|
39177e5ff4 | ||
|
|
9745b6d7b8 | ||
|
|
88ca7dec6b | ||
|
|
dff82a6a82 | ||
|
|
a67a7357cf | ||
|
|
6b91ebe35d | ||
|
|
b5416f56af | ||
|
|
4466c53c7b | ||
|
|
f5eaf05067 | ||
|
|
28c711cd00 | ||
|
|
8e11b2da10 | ||
|
|
a7299708e2 | ||
|
|
f2ee086cc8 | ||
|
|
d8eb6677c6 | ||
|
|
cc5159ebb7 | ||
|
|
cdc6348ac4 | ||
|
|
a53e6c521c | ||
|
|
5775568cd6 | ||
|
|
dae50a74fd | ||
|
|
0341e62726 | ||
|
|
94c9af146a | ||
|
|
784af52b7a | ||
|
|
06e8c9831c |
@@ -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;
|
||||
|
||||
二进制文件未显示。
@@ -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) {
|
||||
|
||||
+5
-1
@@ -17,7 +17,11 @@
|
||||
"pages/profile/profile",
|
||||
"pages/history/history",
|
||||
"pages/help/help",
|
||||
"pages/contact/contact"
|
||||
"pages/contact/contact",
|
||||
"pages/light-info/light-info",
|
||||
"pages/manual-treatment/manual-treatment",
|
||||
"pages/scan-report/scan-report",
|
||||
"pages/ble-debug/ble-debug"
|
||||
],
|
||||
"window": {
|
||||
"navigationBarBackgroundColor": "#ffffff",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Strings for the auto-scan (smart scan) page. Keep zh/en with identical key structure.
|
||||
// Pre/Post pairs are concatenated around a frame count in WXML (WXML cannot do {param}).
|
||||
module.exports = {
|
||||
zh: {
|
||||
title: '智能检测',
|
||||
subReady: '准备就绪',
|
||||
subWaiting: '等待佩戴贴合...',
|
||||
subCollectingPre: '采集数据中 (',
|
||||
subCollectingPost: '帧)...',
|
||||
subDone: '检测完成,正在启动...',
|
||||
readyHint: '设备已佩戴,点击下方按钮开始智能检测',
|
||||
startBtn: '开始检测',
|
||||
waitingStatus: '正在检测面膜贴合状态,请确保设备正确佩戴...',
|
||||
collectingStatusPre: '已贴合,正在采集光谱数据... 已收到 ',
|
||||
collectingStatusPost: ' 帧',
|
||||
doneStatusPre: '检测完成(共 ',
|
||||
doneStatusPost: ' 帧),正在启动治疗...',
|
||||
notWorn: '面膜未佩戴好,请重新佩戴后再试',
|
||||
timeout: '检测超时,未收到数据,请重试',
|
||||
deviceFault: '设备异常,扫描已停止,请重启设备后再试',
|
||||
cmdFailed: '检测命令发送失败'
|
||||
},
|
||||
en: {
|
||||
title: 'Smart Scan',
|
||||
subReady: 'Ready',
|
||||
subWaiting: 'Waiting for fit...',
|
||||
subCollectingPre: 'Collecting data (',
|
||||
subCollectingPost: ' frames)...',
|
||||
subDone: 'Scan complete, starting...',
|
||||
readyHint: 'Device is on. Tap the button below to start the smart scan',
|
||||
startBtn: 'Start Scan',
|
||||
waitingStatus: 'Checking mask fit, please make sure the device is worn correctly...',
|
||||
collectingStatusPre: 'Fitted. Collecting spectral data... received ',
|
||||
collectingStatusPost: ' frames',
|
||||
doneStatusPre: 'Scan complete (',
|
||||
doneStatusPost: ' frames), starting treatment...',
|
||||
notWorn: 'The mask is not worn properly. Please re-fit and try again',
|
||||
timeout: 'Scan timed out with no data. Please try again',
|
||||
deviceFault: 'Device error — scan stopped. Please restart the device and try again',
|
||||
cmdFailed: 'Failed to send scan command'
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// Shared strings used across many pages.
|
||||
module.exports = {
|
||||
zh: {
|
||||
tab: { home: '首页', records: '记录', mine: '我的' },
|
||||
loading: '加载中...',
|
||||
confirm: '确定',
|
||||
cancel: '取消',
|
||||
save: '保存',
|
||||
saving: '保存中...',
|
||||
back: '返回',
|
||||
retry: '重试',
|
||||
close: '关闭',
|
||||
done: '完成',
|
||||
start: '开始',
|
||||
stop: '停止',
|
||||
unknown: '未知',
|
||||
saveSuccess: '保存成功',
|
||||
saveFailed: '保存失败',
|
||||
networkError: '网络异常,请重试',
|
||||
deviceNotConnected: '设备未连接',
|
||||
// 区域 = IO↔物理位置(实测):right=IO1(0x01) left=IO2(0x02) top=IO3(0x04)
|
||||
// middle=IO4(0x08) bottom=IO5(0x10)。语义以掩码为准。
|
||||
regions: {
|
||||
right: '右区',
|
||||
left: '左区',
|
||||
top: '上区',
|
||||
middle: '中区',
|
||||
bottom: '下区'
|
||||
},
|
||||
wavelengths: {
|
||||
red: '红光',
|
||||
infrared: '红外光',
|
||||
uv: '紫外光',
|
||||
yellow: '黄光'
|
||||
}
|
||||
},
|
||||
en: {
|
||||
tab: { home: 'Home', records: 'Records', mine: 'Me' },
|
||||
loading: 'Loading...',
|
||||
confirm: 'OK',
|
||||
cancel: 'Cancel',
|
||||
save: 'Save',
|
||||
saving: 'Saving...',
|
||||
back: 'Back',
|
||||
retry: 'Retry',
|
||||
close: 'Close',
|
||||
done: 'Done',
|
||||
start: 'Start',
|
||||
stop: 'Stop',
|
||||
unknown: 'Unknown',
|
||||
saveSuccess: 'Saved',
|
||||
saveFailed: 'Save failed',
|
||||
networkError: 'Network error, please retry',
|
||||
deviceNotConnected: 'Device not connected',
|
||||
regions: {
|
||||
right: 'Right',
|
||||
left: 'Left',
|
||||
top: 'Top',
|
||||
middle: 'Center',
|
||||
bottom: 'Bottom'
|
||||
},
|
||||
wavelengths: {
|
||||
red: 'Red',
|
||||
infrared: 'Infrared',
|
||||
uv: 'UV',
|
||||
yellow: 'Yellow'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.'
|
||||
}
|
||||
}
|
||||
@@ -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.'
|
||||
}
|
||||
}
|
||||
@@ -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}'
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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.'
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// Strings for the manual treatment (direct wavelength/function select) page
|
||||
// (pages/manual-treatment). Owned by the manual-mode feature. Keep zh/en in sync.
|
||||
module.exports = {
|
||||
zh: {
|
||||
navTitle: '手动护理',
|
||||
navSubtitle: '直接选择护理功能,快速开始',
|
||||
functionTitle: '选择护理功能',
|
||||
functions: {
|
||||
rejuvenate: { name: '嫩肤焕亮', benefit: '红光温和促进胶原,改善暗沉' },
|
||||
acne: { name: '净痘控油', benefit: '紫光清洁抑菌,平衡油脂' },
|
||||
eventone: { name: '匀净提亮', benefit: '黄光舒缓肌肤,提亮肤色' },
|
||||
revitalize: { name: '深层焕活', benefit: '红外深层导入,焕活紧致' }
|
||||
},
|
||||
regionTitle: '选择区域',
|
||||
durationTitle: '选择时长',
|
||||
durationUnit: '{min} 分钟',
|
||||
safetyTip: '单次护理最长 10 分钟,亮度已设为护理中档,请佩戴护目镜',
|
||||
start: '开始护理',
|
||||
starting: '启动中...',
|
||||
noRegionSelected: '请至少选择一个区域',
|
||||
startFailed: '启动失败',
|
||||
entryLink: '手动选择波长护理 ›',
|
||||
smartOnly: '手动选择护理模式为智能模式专属,开通订阅后即可使用'
|
||||
},
|
||||
en: {
|
||||
navTitle: 'Manual Care',
|
||||
navSubtitle: 'Pick a care function and start right away',
|
||||
functionTitle: 'Select Care Function',
|
||||
functions: {
|
||||
rejuvenate: { name: 'Rejuvenate & Brighten', benefit: 'Red light boosts collagen, eases dullness' },
|
||||
acne: { name: 'Clear & Oil Control', benefit: 'UV light cleanses and balances oil' },
|
||||
eventone: { name: 'Even & Brighten', benefit: 'Yellow light soothes and brightens tone' },
|
||||
revitalize: { name: 'Deep Revitalize', benefit: 'Infrared penetrates deep to firm skin' }
|
||||
},
|
||||
regionTitle: 'Select Regions',
|
||||
durationTitle: 'Select Duration',
|
||||
durationUnit: '{min} min',
|
||||
safetyTip: 'Sessions last up to 10 min at a safe medium brightness. Wear eye protection.',
|
||||
start: 'Start Care',
|
||||
starting: 'Starting...',
|
||||
noRegionSelected: 'Please select at least one region',
|
||||
startFailed: 'Failed to start',
|
||||
entryLink: 'Manual wavelength care ›',
|
||||
smartOnly: 'Choosing a care mode manually is a Smart Mode feature. Subscribe to unlock it.'
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Strings for the scan report page (pages/scan-report).
|
||||
module.exports = {
|
||||
zh: {
|
||||
title: '肌肤扫描报告',
|
||||
subtitle: '基于多光谱反射的肤质倾向参考',
|
||||
scannedAt: '扫描时间',
|
||||
overallTitle: '整体倾向',
|
||||
faceTitle: '分区光疗方案',
|
||||
faceTapHint: '点击分区可查看该光的功效介绍',
|
||||
allNormalHint: '未发现明显问题区域,可进行日常舒缓养护',
|
||||
regionTitle: '分区分析',
|
||||
recommendTitle: '推荐护理',
|
||||
noProblem: '未见明显倾向',
|
||||
startTreatment: '开始推荐护理',
|
||||
manualInstead: '我自己选',
|
||||
disclaimer: '本报告依据光反射信号给出肤质倾向参考,不构成医疗诊断。实际护理请结合自身情况。',
|
||||
conceptNotice: '当前诊断依据尚在标定中,结果仅供参考',
|
||||
level: { low: '轻度', mid: '中度', high: '明显' },
|
||||
problems: {
|
||||
aging: '光老化 / 暗沉',
|
||||
acne: '痘痘 / 出油',
|
||||
pigment: '色素 / 暗黄',
|
||||
deep: '深层循环 / 疲劳'
|
||||
},
|
||||
modeNames: {
|
||||
red: '嫩肤焕亮',
|
||||
ir: '深层焕活',
|
||||
uv: '净痘控油',
|
||||
yellow: '匀净提亮'
|
||||
}
|
||||
},
|
||||
en: {
|
||||
title: 'Skin Scan Report',
|
||||
subtitle: 'Skin-tendency reference from multispectral reflection',
|
||||
scannedAt: 'Scanned at',
|
||||
overallTitle: 'Overall Tendency',
|
||||
faceTitle: 'Zone Care Map',
|
||||
faceTapHint: 'Tap a zone to learn about its light',
|
||||
allNormalHint: 'No notable concerns found — a gentle daily care session works well',
|
||||
regionTitle: 'Zone Analysis',
|
||||
recommendTitle: 'Recommended Care',
|
||||
noProblem: 'No notable tendency',
|
||||
startTreatment: 'Start Recommended Care',
|
||||
manualInstead: 'Choose Myself',
|
||||
disclaimer: 'This report gives a skin-tendency reference from light-reflection signals and is not a medical diagnosis. Please consider your own condition.',
|
||||
conceptNotice: 'Diagnosis thresholds are still being calibrated; results are for reference only',
|
||||
level: { low: 'Mild', mid: 'Moderate', high: 'Notable' },
|
||||
problems: {
|
||||
aging: 'Photoaging / Dullness',
|
||||
acne: 'Acne / Oiliness',
|
||||
pigment: 'Pigment / Sallowness',
|
||||
deep: 'Deep circulation / Fatigue'
|
||||
},
|
||||
modeNames: {
|
||||
red: 'Glow & Firm',
|
||||
ir: 'Deep Revive',
|
||||
uv: 'Clear & Balance',
|
||||
yellow: 'Even & Brighten'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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.'
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// Strings for the treating (live treatment) page. Keep zh/en with identical key structure.
|
||||
module.exports = {
|
||||
zh: {
|
||||
title: '使用中',
|
||||
subtitle: '正在使用...',
|
||||
remainingLabel: '剩余时间',
|
||||
smartMode: '✨ 智能模式',
|
||||
normalMode: '🔄 普通模式',
|
||||
allRegions: '全区域',
|
||||
relax1: '请放松心情',
|
||||
relax2: '享受美好时光',
|
||||
stopBtn: '停止使用',
|
||||
fittingLost: '检测到离肤,请重新佩戴设备',
|
||||
fittingRestored: '已重新贴合,继续使用',
|
||||
faultTitle: '设备故障',
|
||||
faultContent: '设备上报故障,已自动停止。',
|
||||
lowBatteryTitle: '电量过低',
|
||||
lowBatteryContent: '设备电量不足5%,已自动停止。请及时充电。',
|
||||
lowBatteryWarn: '电量低,请及时充电',
|
||||
exceptionTitle: '设备异常',
|
||||
exceptionContent: '使用过程中出现异常',
|
||||
disconnected: '设备连接断开,正在重连...',
|
||||
reconnected: '设备已重新连接',
|
||||
reconnectFailedTitle: '连接丢失',
|
||||
reconnectFailedContent: '设备蓝牙连接已断开,无法恢复。本次使用数据将保存。',
|
||||
stopTitle: '结束使用',
|
||||
stopContent: '确定要提前结束本次使用吗?'
|
||||
},
|
||||
en: {
|
||||
title: 'In Progress',
|
||||
subtitle: 'Treatment in progress...',
|
||||
remainingLabel: 'Time Left',
|
||||
smartMode: '✨ Smart Mode',
|
||||
normalMode: '🔄 Normal Mode',
|
||||
allRegions: 'All regions',
|
||||
relax1: 'Relax and unwind',
|
||||
relax2: 'Enjoy your moment',
|
||||
stopBtn: 'Stop',
|
||||
fittingLost: 'Device lifted off skin. Please put it back on',
|
||||
fittingRestored: 'Re-fitted, continuing',
|
||||
faultTitle: 'Device Fault',
|
||||
faultContent: 'The device reported a fault and stopped automatically.',
|
||||
lowBatteryTitle: 'Battery Too Low',
|
||||
lowBatteryContent: 'Battery is below 5%. Stopped automatically. Please charge soon.',
|
||||
lowBatteryWarn: 'Low battery, please charge soon',
|
||||
exceptionTitle: 'Device Error',
|
||||
exceptionContent: 'Something went wrong during the session',
|
||||
disconnected: 'Device disconnected, reconnecting...',
|
||||
reconnected: 'Device reconnected',
|
||||
reconnectFailedTitle: 'Connection Lost',
|
||||
reconnectFailedContent: 'The Bluetooth connection was lost and cannot be restored. This session will be saved.',
|
||||
stopTitle: 'End Session',
|
||||
stopContent: 'Are you sure you want to end this session early?'
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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,14 +47,16 @@ Page({
|
||||
var self = this
|
||||
|
||||
if (!ble.isConnected()) {
|
||||
self.setData({ error: '设备未连接' })
|
||||
self.setData({ error: i18n.t('common.deviceNotConnected') })
|
||||
return
|
||||
}
|
||||
|
||||
self._pdSum = [0, 0, 0, 0, 0, 0, 0]
|
||||
// 按扫描光色分桶累加(FFE4 Byte17 高三位,新协议)。旧固件无标识 → 全部归 red 桶。
|
||||
self._buckets = {}
|
||||
self._frameCount = 0
|
||||
self._completed = false
|
||||
self._collecting = false
|
||||
self._seenRunState1 = false
|
||||
self._lastFitting = -1
|
||||
self._lastRunState = -1
|
||||
self.setData({ phase: 'waiting_fit', frameCount: 0, pdAvg: [], error: '' })
|
||||
@@ -73,17 +82,30 @@ Page({
|
||||
self._lastRunState = data.run_state
|
||||
}
|
||||
|
||||
// 设备故障(Byte18=0xFF):立即中止,不等 3 分钟超时
|
||||
if (data.run_state === 0xFF) {
|
||||
console.error('[SCAN] 设备故障,中止扫描')
|
||||
self._completed = true
|
||||
self._cleanup()
|
||||
self.setData({ phase: 'ready', error: i18n.t('autoScan.deviceFault') })
|
||||
return
|
||||
}
|
||||
|
||||
if (data.run_state === 1) {
|
||||
self._seenRunState1 = true
|
||||
}
|
||||
|
||||
if (self._lastFitting === 1 && data.run_state === 1 && !self._collecting) {
|
||||
console.log('[SCAN] 贴合确认,开始采集PD数据')
|
||||
self._collecting = true
|
||||
self.setData({ phase: 'collecting' })
|
||||
}
|
||||
|
||||
if (self._lastFitting === 0 && data.run_state === 0 && self.data.phase === 'waiting_fit') {
|
||||
if (self._seenRunState1 && self._lastFitting === 0 && data.run_state === 0 && self.data.phase === 'waiting_fit') {
|
||||
console.log('[SCAN] 未贴合,检测失败')
|
||||
self._completed = true
|
||||
self._cleanup()
|
||||
self.setData({ phase: 'ready', error: '面膜未佩戴好,请重新佩戴后再试' })
|
||||
self.setData({ phase: 'ready', error: i18n.t('autoScan.notWorn') })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -98,10 +120,17 @@ Page({
|
||||
if (self._completed || !self._collecting) return
|
||||
if (!data || !data.pd || data.pd.length < 7) return
|
||||
|
||||
self._frameCount++
|
||||
for (var i = 0; i < 7; i++) {
|
||||
self._pdSum[i] += data.pd[i]
|
||||
var wave = data.scan_wave || 'red' // 旧固件帧无光色标识,按单波长红光兼容
|
||||
var bucket = self._buckets[wave]
|
||||
if (!bucket) {
|
||||
bucket = { sum: [0, 0, 0, 0, 0, 0, 0], n: 0 }
|
||||
self._buckets[wave] = bucket
|
||||
}
|
||||
bucket.n++
|
||||
for (var i = 0; i < 7; i++) {
|
||||
bucket.sum[i] += data.pd[i]
|
||||
}
|
||||
self._frameCount++
|
||||
self.setData({
|
||||
frameCount: self._frameCount,
|
||||
vbat: data.vbat || self.data.vbat,
|
||||
@@ -122,7 +151,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 () {
|
||||
@@ -133,7 +162,7 @@ Page({
|
||||
} else {
|
||||
self._completed = true
|
||||
self._cleanup()
|
||||
self.setData({ phase: 'ready', error: '检测超时,未收到数据,请重试' })
|
||||
self.setData({ phase: 'ready', error: i18n.t('autoScan.timeout') })
|
||||
}
|
||||
}, SCAN_TIMEOUT)
|
||||
},
|
||||
@@ -143,49 +172,69 @@ Page({
|
||||
this._completed = true
|
||||
this._cleanup()
|
||||
|
||||
var avg = []
|
||||
var count = this._frameCount || 1
|
||||
for (var i = 0; i < 7; i++) {
|
||||
avg.push(Math.round(this._pdSum[i] / count))
|
||||
// 每个光色桶各自求平均 → waves: { red:[7], ir:[7], uv:[7], yellow:[7] }(有几色算几色)
|
||||
var waves = {}
|
||||
var firstAvg = []
|
||||
for (var wave in this._buckets) {
|
||||
if (!this._buckets.hasOwnProperty(wave)) continue
|
||||
var b = this._buckets[wave]
|
||||
if (!b.n) continue
|
||||
var avg = []
|
||||
for (var i = 0; i < 7; i++) avg.push(Math.round(b.sum[i] / b.n))
|
||||
waves[wave] = avg
|
||||
if (!firstAvg.length) firstAvg = avg
|
||||
}
|
||||
|
||||
this.setData({ phase: 'done', pdAvg: avg, frameCount: count })
|
||||
getApp().globalData.lastScanPdAvg = avg
|
||||
console.log('[SCAN] PD平均值:', avg, '帧数:', count)
|
||||
this.setData({ phase: 'done', pdAvg: firstAvg, frameCount: this._frameCount })
|
||||
getApp().globalData.lastScanPdAvg = firstAvg
|
||||
console.log('[SCAN] 分桶结果:', JSON.stringify(Object.keys(waves).map(function (k) { return k })), '总帧数:', this._frameCount)
|
||||
|
||||
this._startTreatment()
|
||||
this._buildAndGoReport(waves)
|
||||
},
|
||||
|
||||
_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: thresholds + PD->region mapping in services/diagnosis.js are placeholders
|
||||
// pending hardware confirmation / calibration (tunable via server diagnosis_config).
|
||||
// Old firmware sends no wave marker -> everything lands in the red bucket and the
|
||||
// pipeline degrades to single-wavelength gracefully.
|
||||
_buildAndGoReport: function (waves) {
|
||||
var self = this
|
||||
var app = getApp()
|
||||
var mask = self.data.regions || 0x1F
|
||||
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.buildScanData(waves, 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) {
|
||||
// 非空对象即采用——缺失字段由 analyze 逐项回退 DEFAULT;只发部分字段(如仅调 pd_region_map)也生效
|
||||
var usable = cfg && typeof cfg === 'object' && Object.keys(cfg).length > 0
|
||||
proceed(usable ? cfg : diagnosis.DEFAULT_CONFIG)
|
||||
}).catch(function () {
|
||||
proceed(diagnosis.DEFAULT_CONFIG)
|
||||
})
|
||||
},
|
||||
|
||||
onUnload: function () {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
// TEMPORARY debug page for on-device verification (region↔IO mapping, color byte
|
||||
// order, scan wave bucketing, PD polarity). Guoguo-only tooling — intentionally
|
||||
// NOT i18n'd; remove this page before any release build.
|
||||
var ble = require('../../services/ble')
|
||||
var protocol = require('../../services/ble/protocol')
|
||||
|
||||
var WAVES = [
|
||||
{ key: 'red', code: 2, label: '红光' },
|
||||
{ key: 'ir', code: 1, label: '红外' },
|
||||
{ key: 'uv', code: 3, label: '紫外' },
|
||||
{ key: 'yellow', code: 4, label: '暖黄' }
|
||||
]
|
||||
var REGION_LABELS = { 1: '右区/IO1', 2: '左区/IO2', 4: '上区/IO3', 8: '中区/IO4', 16: '下区/IO5' }
|
||||
var RUN_STATES = { 0: 'IDLE', 1: '检测', 2: '治疗', 255: '故障(0xFF)' }
|
||||
|
||||
function hex(bytes) {
|
||||
return bytes.map(function (b) { return ('0' + b.toString(16)).slice(-2).toUpperCase() }).join(' ')
|
||||
}
|
||||
|
||||
Page({
|
||||
data: {
|
||||
statusBarHeight: 44,
|
||||
connected: false,
|
||||
waves: WAVES,
|
||||
selectedWave: 'red',
|
||||
// live FFE4 status
|
||||
pd: [], vbat: 0, battery: 0, fitting: -1, scanWave: '-', runState: '-', frames: 0,
|
||||
logs: []
|
||||
},
|
||||
|
||||
onLoad: function () {
|
||||
this.setData({ statusBarHeight: getApp().globalData.statusBarHeight })
|
||||
},
|
||||
|
||||
onShow: function () {
|
||||
var self = this
|
||||
self.setData({ connected: ble.isConnected() })
|
||||
self._onAdc = function (d) {
|
||||
self.setData({
|
||||
pd: d.pd || [],
|
||||
vbat: d.vbat || 0,
|
||||
battery: d.battery || 0,
|
||||
fitting: d.fitting,
|
||||
scanWave: d.scan_wave || '-',
|
||||
runState: RUN_STATES[d.run_state] !== undefined ? RUN_STATES[d.run_state] : String(d.run_state),
|
||||
frames: self.data.frames + 1
|
||||
})
|
||||
}
|
||||
ble.on('adc', self._onAdc)
|
||||
},
|
||||
|
||||
onHide: function () { this._cleanup() },
|
||||
onUnload: function () { this._cleanup() },
|
||||
_cleanup: function () {
|
||||
if (this._onAdc) { ble.off('adc', this._onAdc); this._onAdc = null }
|
||||
},
|
||||
|
||||
onBack: function () {
|
||||
wx.navigateBack({ fail: function () { wx.switchTab({ url: '/pages/profile/profile' }) } })
|
||||
},
|
||||
|
||||
_log: function (msg) {
|
||||
var logs = this.data.logs
|
||||
logs.unshift(new Date().toTimeString().slice(0, 8) + ' ' + msg)
|
||||
if (logs.length > 20) logs.pop()
|
||||
this.setData({ logs: logs })
|
||||
},
|
||||
|
||||
_send: function (label, options) {
|
||||
var self = this
|
||||
if (!ble.isConnected()) {
|
||||
self.setData({ connected: false })
|
||||
wx.showToast({ title: '设备未连接', icon: 'none' })
|
||||
return
|
||||
}
|
||||
var bytes = protocol.buildVendorCommand(options)
|
||||
self._log(label)
|
||||
self._log('→ ' + hex(bytes))
|
||||
ble.setParams(options).then(function () {
|
||||
self._log('✓ 写入成功')
|
||||
}).catch(function (err) {
|
||||
self._log('✗ 写入失败: ' + ((err && err.msg) || JSON.stringify(err)))
|
||||
})
|
||||
},
|
||||
|
||||
onSelectWave: function (e) {
|
||||
this.setData({ selectedWave: e.currentTarget.dataset.key })
|
||||
},
|
||||
|
||||
_waveCode: function () {
|
||||
var key = this.data.selectedWave
|
||||
for (var i = 0; i < WAVES.length; i++) if (WAVES[i].key === key) return WAVES[i].code
|
||||
return 2
|
||||
},
|
||||
|
||||
// 点脸型图分区:该区 2 秒治疗(验证物理位置对不对)
|
||||
onTapRegion: function (e) {
|
||||
var mask = parseInt(e.currentTarget.dataset.mask)
|
||||
this._send('治疗 2s ' + REGION_LABELS[mask] + ' ' + this.data.selectedWave, {
|
||||
region_mask: mask, wavelength: this._waveCode(), brightness: 204,
|
||||
hold_time: 2, control: 0x02
|
||||
})
|
||||
},
|
||||
|
||||
// 检测指令(与 auto-scan 同参:红光 128 全脸,设备自跑序贯扫描)
|
||||
onScanCmd: function () {
|
||||
this._send('检测指令 control=0x01', {
|
||||
region_mask: 0x1F, wavelength: 2, brightness: 128,
|
||||
hold_time: 0, control: 0x01
|
||||
})
|
||||
},
|
||||
|
||||
// 全脸当前波长 2 秒
|
||||
onTreatAll: function () {
|
||||
this._send('治疗 2s 全脸 ' + this.data.selectedWave, {
|
||||
region_mask: 0x1F, wavelength: this._waveCode(), brightness: 204,
|
||||
hold_time: 2, control: 0x02
|
||||
})
|
||||
},
|
||||
|
||||
// 停止 / IDLE
|
||||
onStop: function () {
|
||||
var self = this
|
||||
self._log('停止 → IDLE 全零帧')
|
||||
ble.stopTreatment().then(function () { self._log('✓ 已停止') })
|
||||
.catch(function (err) { self._log('✗ 停止失败: ' + ((err && err.msg) || '')) })
|
||||
},
|
||||
|
||||
onClearLog: function () { this.setData({ logs: [], frames: 0 }) }
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "调试工具"
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<!-- 临时调试页:真机联调用(区域/波长/极性/分桶验证),上线前移除,不走 i18n -->
|
||||
<view class="page">
|
||||
<view class="page-header page-header-gradient" style="padding-top: {{statusBarHeight + 8}}px;">
|
||||
<view class="nav-back nav-back-light" bindtap="onBack">‹ 返回</view>
|
||||
<view class="page-header-title">调试工具(临时)</view>
|
||||
<view class="page-header-subtitle">{{connected ? '设备已连接' : '设备未连接'}}</view>
|
||||
</view>
|
||||
|
||||
<view class="page-content">
|
||||
<!-- 实时状态 -->
|
||||
<view class="card">
|
||||
<view class="card-title">FFE4 实时状态 <text class="frames">({{frames}} 帧)</text></view>
|
||||
<view class="pd-row">
|
||||
<view class="pd-cell" wx:for="{{pd}}" wx:key="*this">
|
||||
<view class="pd-idx">PD{{index + 1}}</view>
|
||||
<view class="pd-val">{{item}}</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="status-row">
|
||||
<text>VBAT {{vbat}}mV ({{battery}}%)</text>
|
||||
<text>贴合 {{fitting}}</text>
|
||||
<text>光色 {{scanWave}}</text>
|
||||
<text>状态 {{runState}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 波长选择 -->
|
||||
<view class="card">
|
||||
<view class="card-title">波长(点分区/全脸时使用)</view>
|
||||
<view class="wave-row">
|
||||
<view class="wave-chip {{selectedWave === item.key ? 'active' : ''}}" wx:for="{{waves}}" wx:key="key"
|
||||
bindtap="onSelectWave" data-key="{{item.key}}">{{item.label}}</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 脸型图:点区发 2 秒治疗 -->
|
||||
<view class="card">
|
||||
<view class="card-title">点分区 → 该区 2 秒治疗(对物理位置)</view>
|
||||
<view class="face-map">
|
||||
<view class="face-region forehead" bindtap="onTapRegion" data-mask="4">上\nIO3</view>
|
||||
<view class="face-region left-cheek" bindtap="onTapRegion" data-mask="2">左\nIO2</view>
|
||||
<view class="face-region right-cheek" bindtap="onTapRegion" data-mask="1">右\nIO1</view>
|
||||
<view class="face-region nose" bindtap="onTapRegion" data-mask="8">中\nIO4</view>
|
||||
<view class="face-region chin" bindtap="onTapRegion" data-mask="16">下\nIO5</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 指令按钮 -->
|
||||
<view class="card">
|
||||
<view class="card-title">指令</view>
|
||||
<view class="btn-row">
|
||||
<button class="dbg-btn" bindtap="onScanCmd">检测指令</button>
|
||||
<button class="dbg-btn" bindtap="onTreatAll">全脸 2s</button>
|
||||
<button class="dbg-btn dbg-btn-stop" bindtap="onStop">停止</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 日志 -->
|
||||
<view class="card">
|
||||
<view class="card-title">发送日志 <text class="log-clear" bindtap="onClearLog">清空</text></view>
|
||||
<view class="log-line" wx:for="{{logs}}" wx:key="*this">{{item}}</view>
|
||||
<view class="log-empty" wx:if="{{logs.length === 0}}">暂无</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -0,0 +1,39 @@
|
||||
/* 临时调试页样式(上线前随页面一并移除) */
|
||||
.page { min-height: 100vh; background: #f5f6f8; }
|
||||
.page-content { padding: 24rpx; }
|
||||
|
||||
.card { background: #fff; border-radius: 24rpx; padding: 24rpx; margin-bottom: 20rpx; }
|
||||
.card-title { font-size: 28rpx; font-weight: 600; color: #333; margin-bottom: 16rpx; }
|
||||
.frames { font-size: 22rpx; color: #999; font-weight: 400; }
|
||||
|
||||
.pd-row { display: flex; flex-wrap: wrap; gap: 12rpx; }
|
||||
.pd-cell { flex: 0 0 calc((100% - 72rpx) / 7); text-align: center; background: #FAFAFA; border-radius: 12rpx; padding: 10rpx 0; }
|
||||
.pd-idx { font-size: 20rpx; color: #999; }
|
||||
.pd-val { font-size: 24rpx; color: #333; font-weight: 600; }
|
||||
.status-row { display: flex; flex-wrap: wrap; gap: 20rpx; margin-top: 16rpx; font-size: 24rpx; color: #666; }
|
||||
|
||||
.wave-row { display: flex; gap: 16rpx; }
|
||||
.wave-chip { flex: 1; text-align: center; padding: 18rpx 0; border: 4rpx solid #e5e5e5; border-radius: 20rpx; font-size: 26rpx; color: #999; }
|
||||
.wave-chip.active { border-color: #E6508C; background: rgba(230, 80, 140, 0.12); color: #E6508C; }
|
||||
|
||||
.face-map { width: 280rpx; height: 340rpx; margin: 0 auto; position: relative; }
|
||||
.face-region {
|
||||
position: absolute; border: 4rpx solid #d5d5d5; border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center; text-align: center;
|
||||
font-size: 22rpx; color: #666; background: #FAFAFA; white-space: pre-line; line-height: 1.3;
|
||||
}
|
||||
.face-region:active { background: rgba(230, 80, 140, 0.25); border-color: #E6508C; color: #E6508C; }
|
||||
.face-region.forehead { top: 5%; left: 25%; width: 50%; height: 15%; border-radius: 40rpx 40rpx 50% 50%; }
|
||||
.face-region.left-cheek { top: 25%; left: 5%; width: 30%; height: 25%; }
|
||||
.face-region.right-cheek { top: 25%; right: 5%; width: 30%; height: 25%; }
|
||||
.face-region.nose { top: 45%; left: 35%; width: 30%; height: 18%; border-radius: 40%; }
|
||||
.face-region.chin { top: 68%; left: 25%; width: 50%; height: 20%; border-radius: 0 0 50% 50%; }
|
||||
|
||||
.btn-row { display: flex; gap: 16rpx; }
|
||||
.dbg-btn { flex: 1; font-size: 26rpx; padding: 0; line-height: 76rpx; border-radius: 20rpx; background: #4A90D9; color: #fff; }
|
||||
.dbg-btn::after { border: none; }
|
||||
.dbg-btn-stop { background: #E8503A; }
|
||||
|
||||
.log-clear { float: right; font-size: 22rpx; color: #4A90D9; font-weight: 400; }
|
||||
.log-line { font-family: monospace; font-size: 20rpx; color: #555; padding: 4rpx 0; border-bottom: 1rpx solid #f2f2f2; word-break: break-all; }
|
||||
.log-empty { font-size: 22rpx; color: #bbb; }
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
},
|
||||
|
||||
|
||||
@@ -1,41 +1,47 @@
|
||||
<view class="page">
|
||||
<view class="loading-container" wx:if="{{loading}}">
|
||||
<view class="loading-spinner"></view>
|
||||
<text class="loading-text">加载中...</text>
|
||||
<text class="loading-text">{{i18n.common.loading}}</text>
|
||||
</view>
|
||||
|
||||
<view class="page-content" wx:if="{{!loading && !hasDevice}}">
|
||||
<view class="empty-device">
|
||||
<text class="empty-icon">📱</text>
|
||||
<view class="empty-text">还没有绑定设备</view>
|
||||
<button class="btn-primary mt-30" bindtap="onAddDevice">扫码添加设备</button>
|
||||
<view class="empty-text">{{i18n.home.noDevice}}</view>
|
||||
<button class="btn-primary mt-30" bindtap="onAddDevice">{{i18n.home.addDevice}}</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="page-content" wx:if="{{!loading && hasDevice}}">
|
||||
<view class="device-card">
|
||||
<view class="device-card-icon">💆</view>
|
||||
<view class="device-card-name">{{deviceName || '我的设备'}}</view>
|
||||
<view class="device-card-name">{{deviceName || i18n.home.myDevice}}</view>
|
||||
<view class="device-card-status">
|
||||
<text class="status-badge status-badge-green" wx:if="{{connected}}">已连接</text>
|
||||
<text class="status-badge" style="background:#ccc;" wx:else>未连接</text>
|
||||
<text class="status-badge status-badge-green" wx:if="{{connected}}">{{i18n.home.connected}}</text>
|
||||
<text class="status-badge" style="background:#ccc;" wx:else>{{i18n.home.disconnected}}</text>
|
||||
<text class="device-card-battery" wx:if="{{connected}}">🔋 {{battery}}%</text>
|
||||
</view>
|
||||
<button class="btn-reconnect" bindtap="onConnectBle" wx:if="{{!connected}}">重新连接</button>
|
||||
<button class="btn-reconnect" bindtap="onConnectBle" wx:if="{{!connected}}">{{i18n.home.reconnect}}</button>
|
||||
</view>
|
||||
|
||||
<view class="sub-info" bindtap="onViewSubscription">
|
||||
<view class="sub-info" bindtap="onViewSubscription" wx:if="{{!subscription || subscription.plan !== 'free'}}">
|
||||
<view class="sub-info-left">
|
||||
<text class="sub-info-icon">💳</text>
|
||||
<text wx:if="{{subscription && subscription.status === 'active'}}">智能模式 · 剩余{{subRemaining}}天</text>
|
||||
<text wx:else>未订阅智能模式</text>
|
||||
<text wx:if="{{subscription && subscription.status === 'active'}}">{{subRemainText}}</text>
|
||||
<text wx:else>{{i18n.home.notSubscribed}}</text>
|
||||
</view>
|
||||
<text class="sub-info-arrow">›</text>
|
||||
</view>
|
||||
<view class="sub-info" wx:if="{{subscription && subscription.plan === 'free'}}">
|
||||
<view class="sub-info-left">
|
||||
<text class="sub-info-icon">✨</text>
|
||||
<text>{{i18n.home.smartOpen}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="action-buttons">
|
||||
<button class="btn-primary" bindtap="onStartTreatment">开始使用</button>
|
||||
<button class="btn-secondary" bindtap="onManageDevice">设备管理</button>
|
||||
<button class="btn-primary" bindtap="onStartTreatment">{{i18n.home.start}}</button>
|
||||
<button class="btn-secondary" bindtap="onManageDevice">{{i18n.home.deviceManage}}</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
var i18n = require('../../i18n/index')
|
||||
|
||||
Page({
|
||||
data: {
|
||||
statusBarHeight: 44,
|
||||
// Ordered list of lights; strings live in i18n under lightInfo.lights[key],
|
||||
// colors are presentation-only and stay in JS.
|
||||
lights: [
|
||||
{ key: 'red', color: '#E8503A' },
|
||||
{ key: 'uv', color: '#7C4DFF' },
|
||||
{ key: 'yellow', color: '#F5B841' },
|
||||
{ key: 'infrared', color: '#8B2E3C' }
|
||||
],
|
||||
// Parallel to i18n.lightInfo.modes (matched by index).
|
||||
modeColors: ['#E8503A', '#7C4DFF', '#F5B841', '#8B2E3C', '#F08AA8']
|
||||
},
|
||||
|
||||
onLoad: function (options) {
|
||||
var app = getApp()
|
||||
// 报告页分区跳转带 ?wave=red|ir|uv|yellow,高亮对应光卡片('ir' 对应本页 key 'infrared')
|
||||
var focus = (options && options.wave) || ''
|
||||
if (focus === 'ir') focus = 'infrared'
|
||||
this.setData({ statusBarHeight: app.globalData.statusBarHeight, focusWave: focus })
|
||||
},
|
||||
|
||||
onShow: function () {
|
||||
i18n.bind(this)
|
||||
},
|
||||
|
||||
onBack: function () {
|
||||
wx.navigateBack({
|
||||
fail: function () {
|
||||
wx.switchTab({ url: '/pages/profile/profile' })
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "光疗功效介绍"
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<view class="page">
|
||||
<view class="page-header page-header-gradient" style="padding-top: {{statusBarHeight + 24}}px;">
|
||||
<view class="nav-back nav-back-light" bindtap="onBack">‹ {{i18n.common.back}}</view>
|
||||
<view class="page-header-title">{{i18n.lightInfo.navTitle}}</view>
|
||||
<view class="page-header-subtitle">{{i18n.lightInfo.headerSubtitle}}</view>
|
||||
</view>
|
||||
|
||||
<view class="page-content">
|
||||
<!-- 四种光 -->
|
||||
<view class="section-title">{{i18n.lightInfo.lightsSectionTitle}}</view>
|
||||
<view class="light-card {{focusWave === item.key ? 'light-card-focus' : ''}}" wx:for="{{lights}}" wx:key="key">
|
||||
<view class="light-card-top">
|
||||
<view class="swatch" style="background: {{item.color}};"></view>
|
||||
<view class="light-head">
|
||||
<view class="light-name">{{i18n.lightInfo.lights[item.key].name}}</view>
|
||||
<view class="light-wave">{{i18n.lightInfo.lights[item.key].wavelength}}</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="light-effect" style="color: {{item.color}};">{{i18n.lightInfo.lights[item.key].effect}}</view>
|
||||
<view class="light-desc">{{i18n.lightInfo.lights[item.key].desc}}</view>
|
||||
<view class="light-suits">
|
||||
<text class="suits-label">{{i18n.lightInfo.suitsLabel}}</text>
|
||||
<text class="suits-text">{{i18n.lightInfo.lights[item.key].suits}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 护理模式 -->
|
||||
<view class="section-title section-title-gap">{{i18n.lightInfo.modesSectionTitle}}</view>
|
||||
<view class="mode-grid">
|
||||
<view class="mode-card" wx:for="{{i18n.lightInfo.modes}}" wx:for-item="mode" wx:key="name">
|
||||
<view class="mode-dot" style="background: {{modeColors[index]}};"></view>
|
||||
<view class="mode-name">{{mode.name}}</view>
|
||||
<view class="mode-desc">{{mode.desc}}</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 免责声明 -->
|
||||
<view class="disclaimer">{{i18n.lightInfo.disclaimer}}</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -0,0 +1,131 @@
|
||||
.section-title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: #333333;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.section-title-gap {
|
||||
margin-top: 44rpx;
|
||||
}
|
||||
|
||||
/* 光卡片 */
|
||||
.light-card {
|
||||
background: #ffffff;
|
||||
border-radius: 24rpx;
|
||||
padding: 32rpx;
|
||||
margin-bottom: 24rpx;
|
||||
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.light-card-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.swatch {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.light-head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6rpx;
|
||||
}
|
||||
|
||||
.light-name {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.light-wave {
|
||||
font-size: 24rpx;
|
||||
color: #999999;
|
||||
}
|
||||
|
||||
.light-effect {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.light-desc {
|
||||
font-size: 26rpx;
|
||||
color: #666666;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.light-suits {
|
||||
font-size: 24rpx;
|
||||
line-height: 1.5;
|
||||
padding-top: 16rpx;
|
||||
border-top: 2rpx solid #f2f2f2;
|
||||
}
|
||||
|
||||
.suits-label {
|
||||
color: #999999;
|
||||
margin-right: 12rpx;
|
||||
}
|
||||
|
||||
.suits-text {
|
||||
color: #555555;
|
||||
}
|
||||
|
||||
/* 护理模式 */
|
||||
.mode-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.mode-card {
|
||||
width: calc(50% - 10rpx);
|
||||
box-sizing: border-box;
|
||||
background: #ffffff;
|
||||
border-radius: 20rpx;
|
||||
padding: 28rpx 24rpx;
|
||||
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.mode-dot {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
border-radius: 50%;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.mode-name {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #333333;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.mode-desc {
|
||||
font-size: 24rpx;
|
||||
color: #888888;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* 免责声明 */
|
||||
.disclaimer {
|
||||
margin-top: 44rpx;
|
||||
font-size: 22rpx;
|
||||
color: #aaaaaa;
|
||||
line-height: 1.6;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 从扫描报告分区跳转时高亮对应光 */
|
||||
.light-card-focus {
|
||||
border: 2rpx solid rgba(60, 60, 60, 0.35);
|
||||
box-shadow: 0 6rpx 20rpx rgba(0, 0, 0, 0.10);
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
<view class="page">
|
||||
<view class="page-header page-header-pink" style="padding-top: {{statusBarHeight + 24}}px;">
|
||||
<view class="page-header-title">LumiFlow</view>
|
||||
<view class="page-header-subtitle">微信登录</view>
|
||||
<view class="page-header-subtitle">{{i18n.login.wechatLogin}}</view>
|
||||
</view>
|
||||
|
||||
<view class="page-content">
|
||||
<view class="welcome-text">欢迎使用LumiFlow</view>
|
||||
<view class="welcome-text">{{i18n.login.welcome}}</view>
|
||||
|
||||
<view class="flower-area">
|
||||
<text class="flower">💎</text>
|
||||
<view class="desc">登录后即可使用全部功能</view>
|
||||
<view class="desc">{{i18n.login.desc}}</view>
|
||||
</view>
|
||||
|
||||
<button class="login-btn" loading="{{loading}}" bindtap="onLogin" disabled="{{loading}}">
|
||||
微信登录
|
||||
{{i18n.login.wechatLogin}}
|
||||
</button>
|
||||
|
||||
<view class="agreement" bindtap="onAgreement">登录即表示同意《用户协议》和《隐私政策》</view>
|
||||
<view class="agreement" bindtap="onAgreement">{{i18n.login.agreement}}</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
var ble = require('../../services/ble')
|
||||
var i18n = require('../../i18n/index')
|
||||
|
||||
// Manual care page: user directly picks a care function (-> single light
|
||||
// wavelength), regions and duration, then starts — skipping the smart scan.
|
||||
//
|
||||
// Care function -> wavelength (protocol WAVELENGTH: IR=1, R=2, UV=3, Y=4).
|
||||
// SINGLE light per session: buildVendorCommand only lights one wavelength.
|
||||
// FUTURE EXTENSION: a composite/multi-light care mode would require the vendor
|
||||
// command builder to accept several wavelengths at once — not implemented here.
|
||||
Page({
|
||||
data: {
|
||||
statusBarHeight: 44,
|
||||
// 治疗亮度统一 204 = 80%(果果 2026-07-28 定案,与智能推荐一致)。
|
||||
// Do NOT expose a brightness slider (safety); builder 层另有 ≤204 硬上限兜底。
|
||||
brightness: 204,
|
||||
functions: [
|
||||
{ id: 'rejuvenate', wavelength: 2, wl: 'red', color: '#E8503A' },
|
||||
{ id: 'acne', wavelength: 3, wl: 'uv', color: '#7C4DFF' },
|
||||
{ id: 'eventone', wavelength: 4, wl: 'yellow', color: '#F5B841' },
|
||||
{ id: 'revitalize', wavelength: 1, wl: 'infrared', color: '#8B2E3C' }
|
||||
],
|
||||
selectedFunctionId: 'rejuvenate',
|
||||
selectedWavelength: 2,
|
||||
// 掩码 = IO↔物理位置(实测):右=0x01 左=0x02 上=0x04 中=0x08 下=0x10
|
||||
regions: [
|
||||
{ key: 'right', mask: 0x01, checked: true },
|
||||
{ key: 'left', mask: 0x02, checked: true },
|
||||
{ key: 'top', mask: 0x04, checked: true },
|
||||
{ key: 'middle', mask: 0x08, checked: true },
|
||||
{ key: 'bottom', mask: 0x10, checked: true }
|
||||
],
|
||||
// safety: a single session must NOT exceed 10 minutes (hardware caps at 10).
|
||||
durations: [5, 10],
|
||||
selectedMinutes: 10,
|
||||
durationOptions: [],
|
||||
submitting: false,
|
||||
error: ''
|
||||
},
|
||||
|
||||
onLoad: function () {
|
||||
var app = getApp()
|
||||
if (app && app.globalData && app.globalData.statusBarHeight) {
|
||||
this.setData({ statusBarHeight: app.globalData.statusBarHeight })
|
||||
}
|
||||
},
|
||||
|
||||
onShow: function () {
|
||||
i18n.bind(this)
|
||||
this.refreshDurationOptions()
|
||||
this._checkAccess()
|
||||
},
|
||||
|
||||
// 手动选择护理模式为智能模式专属(果果 2026-07-28 定案)。普通模式用户不可用——
|
||||
// 入口链接已按订阅隐藏,此处兜底拦截直达(smart_mode_free 开启时后端返回 active,同样放行)。
|
||||
_checkAccess: function () {
|
||||
var http = require('../../utils/request')
|
||||
http.get('/api/v1/subscription').then(function (sub) {
|
||||
if (sub && sub.status === 'active') return
|
||||
wx.showModal({
|
||||
content: i18n.t('manual.smartOnly'),
|
||||
showCancel: false,
|
||||
complete: function () {
|
||||
wx.navigateBack({ fail: function () { wx.reLaunch({ url: '/pages/index/index' }) } })
|
||||
}
|
||||
})
|
||||
}).catch(function () {}) // 网络失败不拦(避免误伤),实际下发仍有 BLE/后端校验
|
||||
},
|
||||
|
||||
// Duration labels carry a dynamic number, which WXML cannot substitute, so
|
||||
// build the final strings in JS. Re-run on every onShow to follow locale.
|
||||
refreshDurationOptions: function () {
|
||||
var options = this.data.durations.map(function (min) {
|
||||
return { min: min, label: i18n.t('manual.durationUnit', { min: min }) }
|
||||
})
|
||||
this.setData({ durationOptions: options })
|
||||
},
|
||||
|
||||
onBack: function () {
|
||||
wx.navigateBack({
|
||||
fail: function () {
|
||||
wx.reLaunch({ url: '/pages/index/index' })
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
onSelectFunction: function (e) {
|
||||
var id = e.currentTarget.dataset.id
|
||||
var funcs = this.data.functions
|
||||
var wavelength = this.data.selectedWavelength
|
||||
for (var i = 0; i < funcs.length; i++) {
|
||||
if (funcs[i].id === id) {
|
||||
wavelength = funcs[i].wavelength
|
||||
break
|
||||
}
|
||||
}
|
||||
this.setData({ selectedFunctionId: id, selectedWavelength: wavelength })
|
||||
},
|
||||
|
||||
onToggleRegion: function (e) {
|
||||
var idx = e.currentTarget.dataset.idx
|
||||
var regions = this.data.regions
|
||||
regions[idx].checked = !regions[idx].checked
|
||||
this.setData({ regions: regions })
|
||||
},
|
||||
|
||||
onSelectDuration: function (e) {
|
||||
var min = parseInt(e.currentTarget.dataset.min, 10)
|
||||
this.setData({ selectedMinutes: min })
|
||||
},
|
||||
|
||||
onStart: function () {
|
||||
var self = this
|
||||
|
||||
if (!ble.isConnected()) {
|
||||
self.setData({ error: i18n.t('common.deviceNotConnected') })
|
||||
return
|
||||
}
|
||||
|
||||
var mask = 0
|
||||
self.data.regions.forEach(function (r) {
|
||||
if (r.checked) mask |= r.mask
|
||||
})
|
||||
|
||||
if (mask === 0) {
|
||||
wx.showToast({ title: i18n.t('manual.noRegionSelected'), icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
var wavelength = self.data.selectedWavelength
|
||||
var durationMs = self.data.selectedMinutes * 60000
|
||||
|
||||
self.setData({ submitting: true, error: '' })
|
||||
|
||||
ble.setParams({
|
||||
region_mask: mask,
|
||||
wavelength: wavelength,
|
||||
brightness: self.data.brightness,
|
||||
duration_ms: durationMs,
|
||||
mode: 0,
|
||||
control: 0x02
|
||||
}).then(function () {
|
||||
return ble.startTreatment(mask)
|
||||
}).then(function () {
|
||||
self.setData({ submitting: false })
|
||||
wx.redirectTo({
|
||||
url: '/pages/treating/treating?regions=' + mask +
|
||||
'&wavelength=' + wavelength +
|
||||
'&duration=' + durationMs +
|
||||
'&mode=0'
|
||||
})
|
||||
}).catch(function (err) {
|
||||
self.setData({
|
||||
submitting: false,
|
||||
error: (err && err.error_msg) || i18n.t('manual.startFailed')
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "手动护理"
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<view class="page">
|
||||
<view class="page-header page-header-pink" style="padding-top: {{statusBarHeight + 24}}px;">
|
||||
<view class="nav-back" bindtap="onBack">‹ {{i18n.common.back}}</view>
|
||||
<view class="page-header-title">{{i18n.manual.navTitle}}</view>
|
||||
<view class="page-header-subtitle">{{i18n.manual.navSubtitle}}</view>
|
||||
</view>
|
||||
|
||||
<view class="page-content">
|
||||
<view class="page-title">{{i18n.manual.functionTitle}}</view>
|
||||
|
||||
<view class="func-grid">
|
||||
<view
|
||||
wx:for="{{functions}}"
|
||||
wx:key="id"
|
||||
class="func-card {{selectedFunctionId === item.id ? 'active' : ''}}"
|
||||
style="{{selectedFunctionId === item.id ? 'border-color:' + item.color + ';' : ''}}"
|
||||
bindtap="onSelectFunction"
|
||||
data-id="{{item.id}}">
|
||||
<view class="func-dot" style="background: {{item.color}};"></view>
|
||||
<view class="func-name">{{i18n.manual.functions[item.id].name}}</view>
|
||||
<view class="func-wl" style="color: {{item.color}};">{{i18n.common.wavelengths[item.wl]}}</view>
|
||||
<view class="func-benefit">{{i18n.manual.functions[item.id].benefit}}</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="page-title section-title">{{i18n.manual.regionTitle}}</view>
|
||||
<!-- 脸型图布局(与「使用设置」一致);位置↔idx(实测):0=右 1=左 2=上 3=中 4=下 -->
|
||||
<view class="face-map">
|
||||
<view class="face-region forehead {{regions[2].checked ? 'active' : ''}}" bindtap="onToggleRegion" data-idx="2">{{i18n.common.regions.top}}</view>
|
||||
<view class="face-region left-cheek {{regions[1].checked ? 'active' : ''}}" bindtap="onToggleRegion" data-idx="1">{{i18n.common.regions.left}}</view>
|
||||
<view class="face-region right-cheek {{regions[0].checked ? 'active' : ''}}" bindtap="onToggleRegion" data-idx="0">{{i18n.common.regions.right}}</view>
|
||||
<view class="face-region nose {{regions[3].checked ? 'active' : ''}}" bindtap="onToggleRegion" data-idx="3">{{i18n.common.regions.middle}}</view>
|
||||
<view class="face-region chin {{regions[4].checked ? 'active' : ''}}" bindtap="onToggleRegion" data-idx="4">{{i18n.common.regions.bottom}}</view>
|
||||
</view>
|
||||
|
||||
<view class="page-title section-title">{{i18n.manual.durationTitle}}</view>
|
||||
<view class="segmented">
|
||||
<view
|
||||
wx:for="{{durationOptions}}"
|
||||
wx:key="min"
|
||||
class="segment {{selectedMinutes === item.min ? 'active' : ''}}"
|
||||
bindtap="onSelectDuration"
|
||||
data-min="{{item.min}}">
|
||||
{{item.label}}
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="safety-tip">{{i18n.manual.safetyTip}}</view>
|
||||
|
||||
<button class="btn-primary" bindtap="onStart" disabled="{{submitting}}" loading="{{submitting}}">
|
||||
{{submitting ? i18n.manual.starting : i18n.manual.start}}
|
||||
</button>
|
||||
|
||||
<view class="error-text" wx:if="{{error}}">{{error}}</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -0,0 +1,129 @@
|
||||
.nav-back {
|
||||
position: absolute;
|
||||
left: 32rpx;
|
||||
font-size: 30rpx;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 28rpx;
|
||||
margin-top: 40rpx;
|
||||
}
|
||||
|
||||
/* care function cards */
|
||||
.func-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.func-card {
|
||||
width: calc(50% - 10rpx);
|
||||
box-sizing: border-box;
|
||||
padding: 28rpx 24rpx;
|
||||
background: #ffffff;
|
||||
border: 4rpx solid #e5e5e5;
|
||||
border-radius: 24rpx;
|
||||
}
|
||||
|
||||
.func-card.active {
|
||||
background: #fdf2f8;
|
||||
}
|
||||
|
||||
.func-dot {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
border-radius: 50%;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.func-name {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: #333333;
|
||||
margin-bottom: 6rpx;
|
||||
}
|
||||
|
||||
.func-wl {
|
||||
font-size: 24rpx;
|
||||
margin-bottom: 10rpx;
|
||||
}
|
||||
|
||||
.func-benefit {
|
||||
font-size: 22rpx;
|
||||
color: #999999;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* region checkbox grid */
|
||||
/* face-map 脸型图(与 treatment-setup 同款) */
|
||||
.face-map {
|
||||
width: 240rpx;
|
||||
height: 280rpx;
|
||||
margin: 0 auto 24rpx;
|
||||
position: relative;
|
||||
}
|
||||
.face-region {
|
||||
position: absolute;
|
||||
border: 4rpx solid #e5e5e5;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 22rpx;
|
||||
color: #999999;
|
||||
}
|
||||
.face-region.active {
|
||||
border-color: #E6508C;
|
||||
background: rgba(230, 80, 140, 0.2);
|
||||
color: #E6508C;
|
||||
}
|
||||
.face-region.forehead { top: 5%; left: 25%; width: 50%; height: 15%; border-radius: 40rpx 40rpx 50% 50%; }
|
||||
.face-region.left-cheek { top: 25%; left: 5%; width: 30%; height: 25%; }
|
||||
.face-region.right-cheek { top: 25%; right: 5%; width: 30%; height: 25%; }
|
||||
.face-region.nose { top: 45%; left: 35%; width: 30%; height: 18%; border-radius: 40%; }
|
||||
.face-region.chin { top: 68%; left: 25%; width: 50%; height: 20%; border-radius: 0 0 50% 50%; }
|
||||
|
||||
/* duration segmented control */
|
||||
.segmented {
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.segment {
|
||||
flex: 1;
|
||||
padding: 24rpx 0;
|
||||
text-align: center;
|
||||
border: 4rpx solid #e5e5e5;
|
||||
border-radius: 16rpx;
|
||||
font-size: 28rpx;
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
.segment.active {
|
||||
border-color: #E6508C;
|
||||
background: #fdf2f8;
|
||||
color: #E6508C;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.safety-tip {
|
||||
margin: 32rpx 0 24rpx;
|
||||
padding: 20rpx 24rpx;
|
||||
background: #fff7e6;
|
||||
border-radius: 16rpx;
|
||||
font-size: 24rpx;
|
||||
color: #ad6800;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
text-align: center;
|
||||
color: #ff4d4f;
|
||||
font-size: 26rpx;
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
|
||||
.btn-primary::after {
|
||||
border: none;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
var api = require('../../utils/api')
|
||||
var config = require('../../config/env')
|
||||
var i18n = require('../../i18n/index')
|
||||
var app = getApp()
|
||||
|
||||
Page({
|
||||
@@ -16,7 +17,35 @@ Page({
|
||||
},
|
||||
|
||||
onShow: function () {
|
||||
i18n.bind(this)
|
||||
this.loadProfile()
|
||||
// 临时调试入口:仅设备已连接时显示(上线前随 ble-debug 页一并移除)
|
||||
var ble = require('../../services/ble')
|
||||
this.setData({ debugVisible: ble.isConnected() })
|
||||
},
|
||||
|
||||
onOpenDebug: function () {
|
||||
wx.navigateTo({ url: '/pages/ble-debug/ble-debug' })
|
||||
},
|
||||
|
||||
onLightInfo: function () {
|
||||
wx.navigateTo({ url: '/pages/light-info/light-info' })
|
||||
},
|
||||
|
||||
onSwitchLanguage: function () {
|
||||
var self = this
|
||||
var supported = i18n.SUPPORTED
|
||||
var names = supported.map(function (l) { return i18n.LOCALE_NAMES[l] || l })
|
||||
wx.showActionSheet({
|
||||
itemList: names,
|
||||
success: function (res) {
|
||||
var target = supported[res.tapIndex]
|
||||
if (target && target !== i18n.getLocale()) {
|
||||
i18n.setLocale(target)
|
||||
i18n.bind(self)
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
loadProfile: function () {
|
||||
@@ -33,9 +62,11 @@ Page({
|
||||
})
|
||||
|
||||
var p2 = api.getSubscription().then(function (sub) {
|
||||
var remaining = sub.remaining_days || 0
|
||||
self.setData({
|
||||
subscription: sub,
|
||||
subRemaining: sub.remaining_days || 0
|
||||
subRemaining: remaining,
|
||||
subRemainingText: i18n.t('profile.remainDays', { days: remaining })
|
||||
})
|
||||
}).catch(function (err) {
|
||||
console.error('getSubscription failed', err)
|
||||
@@ -50,19 +81,19 @@ Page({
|
||||
var self = this
|
||||
if (this.data.deviceCount > 0) {
|
||||
wx.showActionSheet({
|
||||
itemList: ['解绑当前设备'],
|
||||
itemList: [i18n.t('profile.unbindAction')],
|
||||
success: function (res) {
|
||||
if (res.tapIndex === 0) {
|
||||
wx.showModal({
|
||||
title: '确认解绑',
|
||||
content: '解绑后将无法使用该设备,确定要解绑吗?',
|
||||
title: i18n.t('profile.unbindConfirmTitle'),
|
||||
content: i18n.t('profile.unbindConfirmText'),
|
||||
success: function (modalRes) {
|
||||
if (modalRes.confirm) {
|
||||
api.unbindDevice().then(function () {
|
||||
wx.showToast({ title: '已解绑', icon: 'success' })
|
||||
wx.showToast({ title: i18n.t('profile.unbindDone'), icon: 'success' })
|
||||
self.loadProfile()
|
||||
}).catch(function (err) {
|
||||
wx.showToast({ title: err.message || '解绑失败', icon: 'none' })
|
||||
wx.showToast({ title: err.message || i18n.t('profile.unbindFailed'), icon: 'none' })
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -151,7 +182,7 @@ Page({
|
||||
var self = this
|
||||
var nickname = (self.data.editNickname || '').trim()
|
||||
if (!nickname) {
|
||||
wx.showToast({ title: '昵称不能为空', icon: 'none' })
|
||||
wx.showToast({ title: i18n.t('profile.nicknameRequired'), icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -170,18 +201,18 @@ Page({
|
||||
})
|
||||
}).then(function () {
|
||||
self.setData({ saving: false, editing: false })
|
||||
wx.showToast({ title: '保存成功', icon: 'success' })
|
||||
wx.showToast({ title: i18n.t('common.saveSuccess'), icon: 'success' })
|
||||
self.loadProfile()
|
||||
}).catch(function (err) {
|
||||
self.setData({ saving: false })
|
||||
wx.showToast({ title: err.message || '保存失败', icon: 'none' })
|
||||
wx.showToast({ title: err.message || i18n.t('common.saveFailed'), icon: 'none' })
|
||||
})
|
||||
},
|
||||
|
||||
onLogout: function () {
|
||||
wx.showModal({
|
||||
title: '退出登录',
|
||||
content: '确定要退出登录吗?',
|
||||
title: i18n.t('profile.logoutConfirmTitle'),
|
||||
content: i18n.t('profile.logoutConfirmText'),
|
||||
success: function (res) {
|
||||
if (res.confirm) {
|
||||
wx.removeStorageSync('token')
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<view class="page">
|
||||
<view class="loading-container" wx:if="{{loading}}">
|
||||
<view class="loading-spinner"></view>
|
||||
<text class="loading-text">加载中...</text>
|
||||
<text class="loading-text">{{i18n.common.loading}}</text>
|
||||
</view>
|
||||
|
||||
<view class="page-content" wx:if="{{!loading}}">
|
||||
@@ -9,78 +9,102 @@
|
||||
<image class="user-avatar-img" wx:if="{{userInfo.avatar}}" src="{{userInfo.avatar}}" mode="aspectFill"></image>
|
||||
<view class="user-avatar" wx:else>👤</view>
|
||||
<view class="user-info">
|
||||
<view class="user-name">{{userInfo.nickname || '未登录'}}</view>
|
||||
<view class="user-name">{{userInfo.nickname || i18n.profile.notLoggedIn}}</view>
|
||||
<view class="user-phone">{{userInfo.phone || ''}}</view>
|
||||
</view>
|
||||
<view class="edit-profile-btn" bindtap="onEditProfile">编辑资料</view>
|
||||
<view class="edit-profile-btn" bindtap="onEditProfile">{{i18n.profile.editProfile}}</view>
|
||||
</view>
|
||||
|
||||
<view class="sub-card" wx:if="{{subscription && subscription.status === 'active' && subRemaining > 0}}">
|
||||
<view class="sub-card" wx:if="{{subscription && subscription.plan === 'free'}}">
|
||||
<view class="sub-left">
|
||||
<view class="sub-name">✨ 智能模式</view>
|
||||
<view class="sub-remain">剩余 {{subRemaining}}天</view>
|
||||
<view class="sub-name">✨ {{i18n.profile.smartMode}}</view>
|
||||
<view class="sub-remain">{{i18n.profile.smartModeOpen}}</view>
|
||||
</view>
|
||||
<text class="status-badge status-badge-gold">使用中</text>
|
||||
<text class="status-badge status-badge-gold">{{i18n.profile.free}}</text>
|
||||
</view>
|
||||
<view class="sub-card" wx:elif="{{subscription && subscription.status === 'active' && subRemaining > 0}}">
|
||||
<view class="sub-left">
|
||||
<view class="sub-name">✨ {{i18n.profile.smartMode}}</view>
|
||||
<view class="sub-remain">{{subRemainingText}}</view>
|
||||
</view>
|
||||
<text class="status-badge status-badge-gold">{{i18n.profile.inUse}}</text>
|
||||
</view>
|
||||
<view class="sub-card sub-card-inactive" wx:else bindtap="onViewSubscription">
|
||||
<view class="sub-left">
|
||||
<view class="sub-name">智能模式</view>
|
||||
<view class="sub-remain">未订阅</view>
|
||||
<view class="sub-name">{{i18n.profile.smartMode}}</view>
|
||||
<view class="sub-remain">{{i18n.profile.notSubscribed}}</view>
|
||||
</view>
|
||||
<text class="status-badge" style="background:#eee;color:#999;">去订阅 ›</text>
|
||||
<text class="status-badge" style="background:#eee;color:#999;">{{i18n.profile.goSubscribe}}</text>
|
||||
</view>
|
||||
|
||||
<view class="menu-list">
|
||||
<view class="menu-item" bindtap="onManageDevice">
|
||||
<view class="menu-icon">📱</view>
|
||||
<view class="menu-text">我的设备</view>
|
||||
<text class="menu-device-status" wx:if="{{deviceCount > 0}}">已绑定</text>
|
||||
<text class="menu-device-status menu-device-unbound" wx:else>未绑定</text>
|
||||
<view class="menu-text">{{i18n.profile.menuDevice}}</view>
|
||||
<text class="menu-device-status" wx:if="{{deviceCount > 0}}">{{i18n.profile.bound}}</text>
|
||||
<text class="menu-device-status menu-device-unbound" wx:else>{{i18n.profile.unbound}}</text>
|
||||
<text class="menu-arrow">›</text>
|
||||
</view>
|
||||
<view class="menu-item" bindtap="onViewHistory">
|
||||
<view class="menu-icon">📋</view>
|
||||
<view class="menu-text">使用记录</view>
|
||||
<view class="menu-text">{{i18n.profile.menuHistory}}</view>
|
||||
<text class="menu-arrow">›</text>
|
||||
</view>
|
||||
<view class="menu-item" bindtap="onViewSubscription">
|
||||
<!-- 临时调试入口:仅设备已连接时显示,上线前移除 -->
|
||||
<view class="menu-item" wx:if="{{debugVisible}}" bindtap="onOpenDebug">
|
||||
<view class="menu-icon">🔧</view>
|
||||
<view class="menu-text">调试工具(临时)</view>
|
||||
<text class="menu-arrow">›</text>
|
||||
</view>
|
||||
<view class="menu-item" bindtap="onViewSubscription" wx:if="{{!subscription || subscription.plan !== 'free'}}">
|
||||
<view class="menu-icon">💳</view>
|
||||
<view class="menu-text">订阅管理</view>
|
||||
<view class="menu-text">{{i18n.profile.menuSubscription}}</view>
|
||||
<text class="menu-arrow">›</text>
|
||||
</view>
|
||||
<view class="menu-item" bindtap="onLightInfo">
|
||||
<view class="menu-icon">💡</view>
|
||||
<view class="menu-text">{{i18n.profile.menuLightInfo}}</view>
|
||||
<text class="menu-arrow">›</text>
|
||||
</view>
|
||||
<view class="menu-item" bindtap="onHelp">
|
||||
<view class="menu-icon">❓</view>
|
||||
<view class="menu-text">使用帮助</view>
|
||||
<view class="menu-text">{{i18n.profile.menuHelp}}</view>
|
||||
<text class="menu-arrow">›</text>
|
||||
</view>
|
||||
<view class="menu-item" bindtap="onContact">
|
||||
<view class="menu-icon">📞</view>
|
||||
<view class="menu-text">联系我们</view>
|
||||
<view class="menu-text">{{i18n.profile.menuContact}}</view>
|
||||
<text class="menu-arrow">›</text>
|
||||
</view>
|
||||
<view class="menu-item" bindtap="onSwitchLanguage">
|
||||
<view class="menu-icon">🌐</view>
|
||||
<view class="menu-text">{{i18n.profile.language}}</view>
|
||||
<text class="menu-device-status">{{locale === 'en' ? 'English' : '中文'}}</text>
|
||||
<text class="menu-arrow">›</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="logout-btn" bindtap="onLogout">退出登录</view>
|
||||
<view class="logout-btn" bindtap="onLogout">{{i18n.profile.logout}}</view>
|
||||
|
||||
<view class="version-text">v0.1.7</view>
|
||||
<view class="version-text">v0.2.1</view>
|
||||
</view>
|
||||
|
||||
<!-- Edit profile modal -->
|
||||
<view class="edit-mask" wx:if="{{editing}}" bindtap="onCancelEdit"></view>
|
||||
<view class="edit-modal" wx:if="{{editing}}">
|
||||
<view class="edit-modal-title">编辑资料</view>
|
||||
<view class="edit-modal-title">{{i18n.profile.editProfileTitle}}</view>
|
||||
<view class="edit-avatar-row" bindtap="onPickAvatar">
|
||||
<image class="edit-avatar-img" wx:if="{{editAvatarUrl}}" src="{{editAvatarUrl}}" mode="aspectFill"></image>
|
||||
<view class="edit-avatar-placeholder" wx:else>👤</view>
|
||||
<text class="edit-avatar-hint">点击更换头像</text>
|
||||
<text class="edit-avatar-hint">{{i18n.profile.changeAvatar}}</text>
|
||||
</view>
|
||||
<view class="edit-field">
|
||||
<text class="edit-label">昵称</text>
|
||||
<input class="edit-input" value="{{editNickname}}" bindinput="onEditNicknameInput" placeholder="请输入昵称" maxlength="20" />
|
||||
<text class="edit-label">{{i18n.profile.nickname}}</text>
|
||||
<input class="edit-input" value="{{editNickname}}" bindinput="onEditNicknameInput" placeholder="{{i18n.profile.nicknamePlaceholder}}" maxlength="20" />
|
||||
</view>
|
||||
<view class="edit-actions">
|
||||
<button class="btn-secondary edit-btn" bindtap="onCancelEdit">取消</button>
|
||||
<button class="btn-primary edit-btn" bindtap="onSaveProfile" disabled="{{saving}}" loading="{{saving}}">保存</button>
|
||||
<button class="btn-secondary edit-btn" bindtap="onCancelEdit">{{i18n.common.cancel}}</button>
|
||||
<button class="btn-primary edit-btn" bindtap="onSaveProfile" disabled="{{saving}}" loading="{{saving}}">{{i18n.common.save}}</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
<view class="page">
|
||||
<view class="page-header page-header-pink" style="padding-top: {{statusBarHeight + 24}}px;">
|
||||
<view class="page-header-title">LumiFlow</view>
|
||||
<view class="page-header-subtitle">完善个人资料</view>
|
||||
<view class="page-header-subtitle">{{i18n.register.subtitle}}</view>
|
||||
</view>
|
||||
|
||||
<view class="page-content">
|
||||
<view class="section-title">设置头像</view>
|
||||
<view class="section-title">{{i18n.register.avatarSection}}</view>
|
||||
<view class="avatar-area">
|
||||
<view class="avatar-wrap" bindtap="onPickAvatar">
|
||||
<image class="avatar-img" src="{{avatarUrl}}" mode="aspectFill"></image>
|
||||
<view class="avatar-edit-hint">点击更换</view>
|
||||
<view class="avatar-edit-hint">{{i18n.register.changeAvatar}}</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section-title">设置昵称</view>
|
||||
<view class="section-title">{{i18n.register.nicknameSection}}</view>
|
||||
<view class="input-wrap">
|
||||
<input type="nickname" class="weui-input nickname-input" value="{{nickname}}" bindinput="onNicknameInput" bindblur="onNicknameBlur" placeholder="请输入昵称" />
|
||||
<input type="nickname" class="weui-input nickname-input" value="{{nickname}}" bindinput="onNicknameInput" bindblur="onNicknameBlur" placeholder="{{i18n.register.nicknamePlaceholder}}" />
|
||||
</view>
|
||||
|
||||
<view class="section-title">授权手机号</view>
|
||||
<view class="section-title">{{i18n.register.authorizePhone}}</view>
|
||||
<view class="phone-area">
|
||||
<block wx:if="{{phone}}">
|
||||
<view class="phone-done">
|
||||
@@ -28,16 +28,16 @@
|
||||
</block>
|
||||
<block wx:else>
|
||||
<button class="phone-btn" open-type="getPhoneNumber" bindgetphonenumber="onGetPhoneNumber" disabled="{{loading}}">
|
||||
授权手机号
|
||||
{{i18n.register.authorizePhone}}
|
||||
</button>
|
||||
<view class="phone-hint">手机号用于接收服务通知,必须授权</view>
|
||||
<view class="phone-hint">{{i18n.register.phoneHint}}</view>
|
||||
</block>
|
||||
</view>
|
||||
|
||||
<button class="submit-btn" bindtap="onSubmit" loading="{{loading}}" disabled="{{loading}}">
|
||||
完成注册
|
||||
{{i18n.register.submit}}
|
||||
</button>
|
||||
|
||||
<view class="agreement" bindtap="onAgreement">注册即表示同意《用户协议》和《隐私政策》</view>
|
||||
<view class="agreement" bindtap="onAgreement">{{i18n.register.agreement}}</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
var api = require('../../utils/api')
|
||||
var ble = require('../../services/ble')
|
||||
var diagnosis = require('../../services/diagnosis')
|
||||
var i18n = require('../../i18n/index')
|
||||
|
||||
// diagnosis 的 wave key ('ir') 与 common.wavelengths 的 key ('infrared') 不同名,需映射
|
||||
var WAVE_COMMON_KEY = { red: 'red', ir: 'infrared', uv: 'uv', yellow: 'yellow' }
|
||||
|
||||
function pad2(n) { return (n < 10 ? '0' : '') + n }
|
||||
|
||||
function formatTime(ms) {
|
||||
if (!ms) ms = Date.now()
|
||||
var d = new Date(ms)
|
||||
return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate()) +
|
||||
' ' + pad2(d.getHours()) + ':' + pad2(d.getMinutes())
|
||||
}
|
||||
|
||||
Page({
|
||||
data: {
|
||||
statusBarHeight: 44,
|
||||
loading: true,
|
||||
calibrated: true,
|
||||
hasProblem: false,
|
||||
scannedAtText: '',
|
||||
overallList: [],
|
||||
regionList: [],
|
||||
recommendList: [],
|
||||
faceZones: [],
|
||||
starting: false,
|
||||
error: ''
|
||||
},
|
||||
|
||||
onLoad: function (options) {
|
||||
var app = getApp()
|
||||
this.setData({ statusBarHeight: app.globalData.statusBarHeight })
|
||||
this._options = options || {}
|
||||
},
|
||||
|
||||
onShow: function () {
|
||||
i18n.bind(this)
|
||||
this._loadReport()
|
||||
},
|
||||
|
||||
onBack: function () {
|
||||
wx.navigateBack({ fail: function () { wx.reLaunch({ url: '/pages/index/index' }) } })
|
||||
},
|
||||
|
||||
_loadReport: function () {
|
||||
var self = this
|
||||
var app = getApp()
|
||||
var opts = self._options || {}
|
||||
|
||||
if (opts.report_id) {
|
||||
self.setData({ loading: true })
|
||||
api.getReport(opts.report_id).then(function (rep) {
|
||||
self._render(self._normalize(rep))
|
||||
}).catch(function () { self._fallback() })
|
||||
return
|
||||
}
|
||||
if (app.globalData.lastScanReport) {
|
||||
self._render(app.globalData.lastScanReport)
|
||||
return
|
||||
}
|
||||
self.setData({ loading: true })
|
||||
api.getLatestReport().then(function (rep) {
|
||||
// Backend ok() coerces null -> {}, so treat an empty payload as "no report".
|
||||
if (self._hasContent(rep)) self._render(self._normalize(rep))
|
||||
else self._fallback()
|
||||
}).catch(function () { self._fallback() })
|
||||
},
|
||||
|
||||
_hasContent: function (rep) {
|
||||
if (!rep) return false
|
||||
return !!((rep.regions && rep.regions.length) ||
|
||||
(rep.overall && rep.overall.length) ||
|
||||
(rep.recommend_plan && rep.recommend_plan.length))
|
||||
},
|
||||
|
||||
_fallback: function () {
|
||||
// Preview/demo when no device data is available yet.
|
||||
this._render(diagnosis.mockReport())
|
||||
},
|
||||
|
||||
_normalize: function (rep) {
|
||||
if (!rep) return diagnosis.mockReport()
|
||||
var scannedAt = 0
|
||||
if (typeof rep.scanned_at === 'number') scannedAt = rep.scanned_at
|
||||
else if (rep.scanned_at) scannedAt = Date.parse(rep.scanned_at) || 0
|
||||
return {
|
||||
device_id: rep.device_id || null,
|
||||
scanned_at: scannedAt,
|
||||
regions: rep.regions || [],
|
||||
overall: rep.overall || [],
|
||||
recommend_mask: rep.recommend_mask || 0,
|
||||
recommend_plan: rep.recommend_plan || [],
|
||||
raw_pd: rep.raw_pd || {},
|
||||
calibrated: !!rep.calibrated
|
||||
}
|
||||
},
|
||||
|
||||
_render: function (report) {
|
||||
var d = i18n.getDict().report
|
||||
var common = i18n.getDict().common
|
||||
|
||||
function levelLabel(l) {
|
||||
if (l >= 3) return d.level.high
|
||||
if (l === 2) return d.level.mid
|
||||
if (l === 1) return d.level.low
|
||||
return d.noProblem
|
||||
}
|
||||
|
||||
var overallList = (report.overall || []).map(function (o) {
|
||||
return { label: d.problems[o.type] || o.type, level: o.level, levelLabel: levelLabel(o.level) }
|
||||
})
|
||||
|
||||
function regionName(key) {
|
||||
return (common.regions && common.regions[key]) || key
|
||||
}
|
||||
function modeName(waveKey) {
|
||||
return (d.modeNames && d.modeNames[waveKey]) || ''
|
||||
}
|
||||
|
||||
var regionList = (report.regions || []).map(function (r) {
|
||||
return {
|
||||
name: regionName(r.region),
|
||||
level: r.level,
|
||||
levelLabel: levelLabel(r.level),
|
||||
problemLabel: r.top_problem ? (d.problems[r.top_problem] || r.top_problem) : d.noProblem,
|
||||
hasProblem: !!r.top_problem
|
||||
}
|
||||
})
|
||||
|
||||
// 推荐护理:现在 recommend_plan 每区一条(可不同色),一条 BLE 命令可全部下发
|
||||
var recommendList = (report.recommend_plan || []).map(function (p) {
|
||||
return {
|
||||
modeLabel: modeName(p.wave),
|
||||
wavelengthLabel: common.wavelengths[WAVE_COMMON_KEY[p.wave]] || '',
|
||||
regionsLabel: regionName(p.region),
|
||||
levelLabel: levelLabel(p.level),
|
||||
mask: p.mask
|
||||
}
|
||||
})
|
||||
|
||||
// 面罩示意图数据(固定 5 区,未扫描/无问题的区置灰)
|
||||
var byKey = {}
|
||||
;(report.regions || []).forEach(function (r) { byKey[r.region] = r })
|
||||
var faceZones = diagnosis.REGION_DEFS.map(function (def) {
|
||||
var r = byKey[def.key]
|
||||
var hasProblem = !!(r && r.top_problem && r.wave)
|
||||
return {
|
||||
key: def.key,
|
||||
waveKey: hasProblem ? r.wave : null,
|
||||
name: regionName(def.key),
|
||||
hasProblem: hasProblem,
|
||||
modeLabel: hasProblem ? modeName(r.wave) : '',
|
||||
levelLabel: hasProblem ? levelLabel(r.level) : '',
|
||||
colorClass: hasProblem ? 'zone-' + r.wave : 'zone-none'
|
||||
}
|
||||
})
|
||||
|
||||
this._report = report
|
||||
this.setData({
|
||||
loading: false,
|
||||
calibrated: !!report.calibrated,
|
||||
hasProblem: overallList.length > 0,
|
||||
scannedAtText: formatTime(report.scanned_at),
|
||||
overallList: overallList,
|
||||
regionList: regionList,
|
||||
recommendList: recommendList,
|
||||
faceZones: faceZones,
|
||||
error: ''
|
||||
})
|
||||
},
|
||||
|
||||
onTapZone: function (e) {
|
||||
var wave = e.currentTarget.dataset.wave
|
||||
wx.navigateTo({ url: '/pages/light-info/light-info' + (wave ? '?wave=' + wave : '') })
|
||||
},
|
||||
|
||||
onStartRecommend: function () {
|
||||
var self = this
|
||||
var report = self._report || {}
|
||||
var plan = report.recommend_plan || []
|
||||
if (!ble.isConnected()) {
|
||||
self.setData({ error: i18n.t('common.deviceNotConnected') })
|
||||
return
|
||||
}
|
||||
if (!plan.length) return
|
||||
|
||||
// 一条 33B 命令携带全部分区差异化方案(每区各自的光色,亮度来自 diagnosis config,默认 204=80%)
|
||||
var mask = report.recommend_mask || 0
|
||||
var wl = plan[0].wavelength || 2 // treating 页展示用(多色场景显示首个)
|
||||
|
||||
self.setData({ starting: true, error: '' })
|
||||
ble.setParams({
|
||||
plan: plan.map(function (p) {
|
||||
return { mask: p.mask, wave: p.wave, brightness: p.brightness }
|
||||
}),
|
||||
duration_ms: 600000,
|
||||
control: 0x02
|
||||
}).then(function () {
|
||||
return ble.startTreatment(mask)
|
||||
}).then(function () {
|
||||
wx.redirectTo({
|
||||
url: '/pages/treating/treating?regions=' + mask +
|
||||
'&wavelength=' + wl + '&duration=600000&mode=1'
|
||||
})
|
||||
}).catch(function (err) {
|
||||
self.setData({ starting: false, error: (err && err.error_msg) || i18n.t('common.networkError') })
|
||||
})
|
||||
},
|
||||
|
||||
onManual: function () {
|
||||
wx.redirectTo({ url: '/pages/manual-treatment/manual-treatment' })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "扫描报告"
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
<view class="page">
|
||||
<view class="page-header page-header-gradient" style="padding-top:{{statusBarHeight + 8}}px;">
|
||||
<view class="nav-back nav-back-light" bindtap="onBack">‹ {{i18n.common.back}}</view>
|
||||
<view class="page-header-title">{{i18n.report.title}}</view>
|
||||
<view class="page-header-subtitle">{{i18n.report.subtitle}}</view>
|
||||
</view>
|
||||
|
||||
<view class="loading-container" wx:if="{{loading}}">
|
||||
<view class="loading-spinner"></view>
|
||||
<text class="loading-text">{{i18n.common.loading}}</text>
|
||||
</view>
|
||||
|
||||
<view class="page-content" wx:if="{{!loading}}">
|
||||
<view class="concept-banner" wx:if="{{!calibrated}}">
|
||||
<text class="concept-icon">ⓘ</text>
|
||||
<text class="concept-text">{{i18n.report.conceptNotice}}</text>
|
||||
</view>
|
||||
|
||||
<view class="scan-time">{{i18n.report.scannedAt}}:{{scannedAtText}}</view>
|
||||
|
||||
<!-- Overall -->
|
||||
<view class="card">
|
||||
<view class="card-title">{{i18n.report.overallTitle}}</view>
|
||||
<view class="chip-row" wx:if="{{hasProblem}}">
|
||||
<view class="chip chip-lv{{item.level}}" wx:for="{{overallList}}" wx:key="label">
|
||||
<text class="chip-name">{{item.label}}</text>
|
||||
<text class="chip-lv">{{item.levelLabel}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="empty-hint" wx:else>{{i18n.report.noProblem}}</view>
|
||||
</view>
|
||||
|
||||
<!-- Face zones — 脸型图布局(与「使用设置」区域选择一致,果果 2026-07-28 定稿) -->
|
||||
<view class="card">
|
||||
<view class="card-title">{{i18n.report.faceTitle}}</view>
|
||||
<!-- 脸型图(与「使用设置」同款布局);faceZones 由 REGION_DEFS 定序:0=右 1=左 2=上 3=中 4=下 -->
|
||||
<view class="face-map">
|
||||
<view class="face-region forehead {{faceZones[2].colorClass}}" bindtap="onTapZone" data-wave="{{faceZones[2].waveKey}}">
|
||||
<view class="fz-name">{{faceZones[2].name}}</view>
|
||||
<view class="fz-mode" wx:if="{{faceZones[2].hasProblem}}">{{faceZones[2].modeLabel}}</view>
|
||||
</view>
|
||||
<view class="face-region left-cheek {{faceZones[1].colorClass}}" bindtap="onTapZone" data-wave="{{faceZones[1].waveKey}}">
|
||||
<view class="fz-name">{{faceZones[1].name}}</view>
|
||||
<view class="fz-mode" wx:if="{{faceZones[1].hasProblem}}">{{faceZones[1].modeLabel}}</view>
|
||||
</view>
|
||||
<view class="face-region right-cheek {{faceZones[0].colorClass}}" bindtap="onTapZone" data-wave="{{faceZones[0].waveKey}}">
|
||||
<view class="fz-name">{{faceZones[0].name}}</view>
|
||||
<view class="fz-mode" wx:if="{{faceZones[0].hasProblem}}">{{faceZones[0].modeLabel}}</view>
|
||||
</view>
|
||||
<view class="face-region nose {{faceZones[3].colorClass}}" bindtap="onTapZone" data-wave="{{faceZones[3].waveKey}}">
|
||||
<view class="fz-name">{{faceZones[3].name}}</view>
|
||||
<view class="fz-mode" wx:if="{{faceZones[3].hasProblem}}">{{faceZones[3].modeLabel}}</view>
|
||||
</view>
|
||||
<view class="face-region chin {{faceZones[4].colorClass}}" bindtap="onTapZone" data-wave="{{faceZones[4].waveKey}}">
|
||||
<view class="fz-name">{{faceZones[4].name}}</view>
|
||||
<view class="fz-mode" wx:if="{{faceZones[4].hasProblem}}">{{faceZones[4].modeLabel}}</view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 底部图例:各光简要功效(果果指定保留) -->
|
||||
<view class="face-legend">
|
||||
<view class="legend-item"><view class="legend-dot dot-red"></view><text class="legend-label">{{i18n.report.modeNames.red}}</text></view>
|
||||
<view class="legend-item"><view class="legend-dot dot-ir"></view><text class="legend-label">{{i18n.report.modeNames.ir}}</text></view>
|
||||
<view class="legend-item"><view class="legend-dot dot-uv"></view><text class="legend-label">{{i18n.report.modeNames.uv}}</text></view>
|
||||
<view class="legend-item"><view class="legend-dot dot-yellow"></view><text class="legend-label">{{i18n.report.modeNames.yellow}}</text></view>
|
||||
</view>
|
||||
<view class="face-tap-hint">{{i18n.report.faceTapHint}}</view>
|
||||
<view class="face-all-normal-hint" wx:if="{{!hasProblem}}">{{i18n.report.allNormalHint}}</view>
|
||||
</view>
|
||||
|
||||
<!-- Per-region -->
|
||||
<view class="card">
|
||||
<view class="card-title">{{i18n.report.regionTitle}}</view>
|
||||
<view class="region-row" wx:for="{{regionList}}" wx:key="name">
|
||||
<text class="region-name">{{item.name}}</text>
|
||||
<view class="region-right">
|
||||
<text class="region-problem {{item.hasProblem ? '' : 'region-problem-none'}}">{{item.problemLabel}}</text>
|
||||
<text class="region-lv region-lv{{item.level}}" wx:if="{{item.hasProblem}}">{{item.levelLabel}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Recommendation -->
|
||||
<view class="card" wx:if="{{recommendList.length > 0}}">
|
||||
<view class="card-title">{{i18n.report.recommendTitle}}</view>
|
||||
<view class="rec-row" wx:for="{{recommendList}}" wx:key="mask">
|
||||
<view class="rec-dot"></view>
|
||||
<view class="rec-body">
|
||||
<text class="rec-label">{{item.modeLabel}}</text>
|
||||
<text class="rec-meta">{{item.wavelengthLabel}} · {{item.regionsLabel}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="error-text" wx:if="{{error}}">{{error}}</view>
|
||||
|
||||
<view class="actions">
|
||||
<button class="btn-primary" bindtap="onStartRecommend" disabled="{{starting}}" loading="{{starting}}" wx:if="{{recommendList.length > 0}}">{{i18n.report.startTreatment}}</button>
|
||||
<button class="btn-secondary" bindtap="onManual">{{i18n.report.manualInstead}}</button>
|
||||
</view>
|
||||
|
||||
<view class="disclaimer">{{i18n.report.disclaimer}}</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -0,0 +1,147 @@
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: 120rpx;
|
||||
}
|
||||
.loading-spinner {
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
border: 6rpx solid #eee;
|
||||
border-top-color: #E8503A;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
.loading-text { margin-top: 20rpx; color: #999; font-size: 26rpx; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.concept-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: #FFF7E6;
|
||||
border: 1rpx solid #FFE1A8;
|
||||
border-radius: 14rpx;
|
||||
padding: 18rpx 22rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
.concept-icon { color: #E8971A; margin-right: 12rpx; font-size: 30rpx; }
|
||||
.concept-text { color: #A66908; font-size: 24rpx; line-height: 1.4; flex: 1; }
|
||||
|
||||
.scan-time { color: #999; font-size: 24rpx; margin: 0 6rpx 20rpx; }
|
||||
|
||||
.card {
|
||||
background: #fff;
|
||||
border-radius: 18rpx;
|
||||
padding: 28rpx 26rpx;
|
||||
margin-bottom: 22rpx;
|
||||
box-shadow: 0 4rpx 18rpx rgba(0,0,0,0.04);
|
||||
}
|
||||
.card-title { font-size: 30rpx; font-weight: 600; color: #333; margin-bottom: 20rpx; }
|
||||
|
||||
.chip-row { display: flex; flex-wrap: wrap; }
|
||||
.chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: 999rpx;
|
||||
padding: 10rpx 22rpx;
|
||||
margin: 0 16rpx 16rpx 0;
|
||||
background: #F3F4F6;
|
||||
}
|
||||
.chip-name { font-size: 26rpx; color: #333; }
|
||||
.chip-lv { font-size: 22rpx; color: #fff; margin-left: 12rpx; padding: 2rpx 14rpx; border-radius: 999rpx; background: #bbb; }
|
||||
.chip-lv1 .chip-lv { background: #6BBF59; }
|
||||
.chip-lv2 .chip-lv { background: #F5B841; }
|
||||
.chip-lv3 .chip-lv { background: #E8503A; }
|
||||
|
||||
.empty-hint { color: #999; font-size: 26rpx; }
|
||||
|
||||
/* 脸型图 — 与「使用设置」同款布局,放大以容纳模式名 */
|
||||
.face-map {
|
||||
width: 320rpx;
|
||||
height: 400rpx;
|
||||
margin: 0 auto 16rpx;
|
||||
position: relative;
|
||||
}
|
||||
.face-region {
|
||||
position: absolute;
|
||||
border: 3rpx solid #e5e5e5;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #FAFAFA;
|
||||
color: #999;
|
||||
}
|
||||
.face-region:active { transform: scale(0.96); }
|
||||
.fz-name { font-size: 22rpx; font-weight: 600; }
|
||||
.fz-mode { font-size: 18rpx; margin-top: 2rpx; }
|
||||
.face-region.forehead { top: 5%; left: 25%; width: 50%; height: 15%; border-radius: 40rpx 40rpx 50% 50%; }
|
||||
.face-region.left-cheek { top: 25%; left: 5%; width: 30%; height: 25%; }
|
||||
.face-region.right-cheek { top: 25%; right: 5%; width: 30%; height: 25%; }
|
||||
.face-region.nose { top: 45%; left: 35%; width: 30%; height: 18%; border-radius: 40%; }
|
||||
.face-region.chin { top: 68%; left: 25%; width: 50%; height: 20%; border-radius: 0 0 50% 50%; }
|
||||
|
||||
/* 波长配色(置于 .face-region 之后以覆盖默认色) */
|
||||
.zone-red { background: rgba(232, 93, 93, 0.14); border-color: rgba(232, 93, 93, 0.55); color: #B23C3C; }
|
||||
.zone-ir { background: rgba(192, 107, 74, 0.14); border-color: rgba(192, 107, 74, 0.55); color: #8C4A2E; }
|
||||
.zone-uv { background: rgba(142, 107, 216, 0.14); border-color: rgba(142, 107, 216, 0.55); color: #5F3FAE; }
|
||||
.zone-yellow { background: rgba(232, 185, 61, 0.16); border-color: rgba(232, 185, 61, 0.6); color: #8A6A10; }
|
||||
.zone-none { background: #FAFAFA; border-color: #e5e5e5; color: #999; }
|
||||
|
||||
/* 底部图例:各光简要功效 */
|
||||
.face-legend { display: flex; flex-wrap: wrap; justify-content: center; margin-top: 8rpx; }
|
||||
.legend-item { display: flex; align-items: center; margin: 0 16rpx 10rpx 0; }
|
||||
.legend-dot { width: 16rpx; height: 16rpx; border-radius: 50%; margin-right: 8rpx; }
|
||||
.dot-red { background: #E85D5D; }
|
||||
.dot-ir { background: #C06B4A; }
|
||||
.dot-uv { background: #8E6BD8; }
|
||||
.dot-yellow { background: #E8B93D; }
|
||||
.legend-label { font-size: 22rpx; color: #888; }
|
||||
|
||||
.face-tap-hint { font-size: 22rpx; color: #bbb; margin-top: 14rpx; text-align: center; }
|
||||
.face-all-normal-hint { font-size: 24rpx; color: #6BBF59; text-align: center; margin-top: 12rpx; }
|
||||
|
||||
.region-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20rpx 0;
|
||||
border-bottom: 1rpx solid #f0f0f0;
|
||||
}
|
||||
.region-row:last-child { border-bottom: none; }
|
||||
.region-name { font-size: 28rpx; color: #333; }
|
||||
.region-right { display: flex; align-items: center; }
|
||||
.region-problem { font-size: 26rpx; color: #555; margin-right: 14rpx; }
|
||||
.region-problem-none { color: #aaa; }
|
||||
.region-lv { font-size: 22rpx; color: #fff; padding: 2rpx 14rpx; border-radius: 999rpx; background: #bbb; }
|
||||
.region-lv1 { background: #6BBF59; }
|
||||
.region-lv2 { background: #F5B841; }
|
||||
.region-lv3 { background: #E8503A; }
|
||||
|
||||
.rec-row { display: flex; align-items: flex-start; padding: 16rpx 0; }
|
||||
.rec-dot {
|
||||
width: 14rpx; height: 14rpx; border-radius: 50%;
|
||||
background: #E8503A; margin-top: 12rpx; margin-right: 18rpx; flex-shrink: 0;
|
||||
}
|
||||
.rec-body { display: flex; flex-direction: column; }
|
||||
.rec-label { font-size: 28rpx; color: #333; font-weight: 500; }
|
||||
.rec-meta { font-size: 24rpx; color: #999; margin-top: 6rpx; }
|
||||
|
||||
.error-text { color: #E8503A; font-size: 26rpx; text-align: center; margin: 10rpx 0; }
|
||||
|
||||
.actions { margin-top: 12rpx; }
|
||||
.actions .btn-primary, .actions .btn-secondary { margin-bottom: 20rpx; }
|
||||
|
||||
.disclaimer {
|
||||
color: #b0b0b0;
|
||||
font-size: 22rpx;
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
margin: 20rpx 10rpx 40rpx;
|
||||
}
|
||||
@@ -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' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
var ble = require('../../services/ble')
|
||||
var api = require('../../utils/api')
|
||||
var commandSync = require('../../services/command-sync')
|
||||
var i18n = require('../../i18n/index')
|
||||
Page({
|
||||
data: {
|
||||
statusBarHeight: 44,
|
||||
@@ -16,26 +17,37 @@ Page({
|
||||
paused: false,
|
||||
completed: false,
|
||||
startedAt: 0,
|
||||
regionText: '全区域',
|
||||
regionText: '',
|
||||
fittingLost: false,
|
||||
deviceFault: false
|
||||
},
|
||||
|
||||
_getRegionText: function (regionMask) {
|
||||
var REGION_MAP = {1:'右区', 2:'左区', 4:'上区', 8:'中区', 16:'下区', 32:'左上区', 64:'右上区'}
|
||||
if (regionMask === 0x7F) return '全区域'
|
||||
// 掩码 = IO↔物理位置(实测):0x01右 0x02左 0x04上 0x08中 0x10下
|
||||
var REGION_MAP = {
|
||||
1: 'common.regions.right',
|
||||
2: 'common.regions.left',
|
||||
4: 'common.regions.top',
|
||||
8: 'common.regions.middle',
|
||||
16: 'common.regions.bottom'
|
||||
}
|
||||
if (regionMask === 0x1F) return i18n.t('treating.allRegions')
|
||||
var names = []
|
||||
for (var bit in REGION_MAP) {
|
||||
if (regionMask & parseInt(bit)) names.push(REGION_MAP[bit])
|
||||
if (regionMask & parseInt(bit)) names.push(i18n.t(REGION_MAP[bit]))
|
||||
}
|
||||
return names.length > 0 ? names.join('+') : '全区域'
|
||||
return names.length > 0 ? names.join('+') : i18n.t('treating.allRegions')
|
||||
},
|
||||
|
||||
onShow: function () {
|
||||
i18n.bind(this)
|
||||
},
|
||||
|
||||
onLoad: function (options) {
|
||||
var app = getApp()
|
||||
this.setData({ statusBarHeight: app.globalData.statusBarHeight })
|
||||
this._deviceId = ble.getDeviceId() || (app.globalData.currentDevice && app.globalData.currentDevice.device_id) || ''
|
||||
var regions = parseInt(options.regions) || 0x7F
|
||||
var regions = parseInt(options.regions) || 0x1F
|
||||
this.setData({
|
||||
regions: regions,
|
||||
wavelength: parseInt(options.wavelength) || 2,
|
||||
@@ -87,17 +99,19 @@ Page({
|
||||
},
|
||||
|
||||
onFitting: function (data) {
|
||||
if (this.data.mode === 0) return
|
||||
if (this.data.completed) return
|
||||
if (data.fitting === 0 && !this.data.fittingLost) {
|
||||
this.setData({ fittingLost: true, paused: true })
|
||||
wx.showToast({ title: '检测到离肤,请重新佩戴设备', icon: 'none', duration: 3000 })
|
||||
wx.showToast({ title: i18n.t('treating.fittingLost'), icon: 'none', duration: 3000 })
|
||||
} else if (data.fitting === 1 && this.data.fittingLost) {
|
||||
this.setData({ fittingLost: false, paused: false })
|
||||
wx.showToast({ title: '已重新贴合,继续使用', icon: 'success', duration: 2000 })
|
||||
wx.showToast({ title: i18n.t('treating.fittingRestored'), icon: 'success', duration: 2000 })
|
||||
}
|
||||
},
|
||||
|
||||
onRunState: function (data) {
|
||||
if (this.data.mode === 0) return
|
||||
if (this._finishing) return
|
||||
console.log('[TREAT] run_state:', data.run_state)
|
||||
|
||||
@@ -110,8 +124,8 @@ Page({
|
||||
this.setData({ deviceFault: true })
|
||||
var self = this
|
||||
wx.showModal({
|
||||
title: '设备故障',
|
||||
content: '设备上报故障,已自动停止。',
|
||||
title: i18n.t('treating.faultTitle'),
|
||||
content: i18n.t('treating.faultContent'),
|
||||
showCancel: false,
|
||||
complete: function () { self.finishAsComplete() }
|
||||
})
|
||||
@@ -134,8 +148,8 @@ Page({
|
||||
if (self.data.battery > 0 && self.data.battery <= 5) {
|
||||
clearInterval(timer)
|
||||
wx.showModal({
|
||||
title: '电量过低',
|
||||
content: '设备电量不足5%,已自动停止。请及时充电。',
|
||||
title: i18n.t('treating.lowBatteryTitle'),
|
||||
content: i18n.t('treating.lowBatteryContent'),
|
||||
showCancel: false,
|
||||
complete: function () { self.finishAsComplete() }
|
||||
})
|
||||
@@ -144,7 +158,7 @@ Page({
|
||||
|
||||
if (self.data.battery > 0 && self.data.battery <= 10 && !self._lowBatteryWarned) {
|
||||
self._lowBatteryWarned = true
|
||||
wx.showToast({ title: '电量低,请及时充电', icon: 'none', duration: 3000 })
|
||||
wx.showToast({ title: i18n.t('treating.lowBatteryWarn'), icon: 'none', duration: 3000 })
|
||||
}
|
||||
|
||||
if (remaining <= 0) {
|
||||
@@ -260,8 +274,8 @@ Page({
|
||||
|
||||
onException: function (err) {
|
||||
wx.showModal({
|
||||
title: '设备异常',
|
||||
content: err.error_msg || '使用过程中出现异常',
|
||||
title: i18n.t('treating.exceptionTitle'),
|
||||
content: err.error_msg || i18n.t('treating.exceptionContent'),
|
||||
showCancel: false,
|
||||
complete: function () {
|
||||
wx.navigateBack()
|
||||
@@ -272,7 +286,7 @@ Page({
|
||||
onDisconnect: function () {
|
||||
this.setData({ paused: true })
|
||||
wx.showToast({
|
||||
title: '设备连接断开,正在重连...',
|
||||
title: i18n.t('treating.disconnected'),
|
||||
icon: 'none',
|
||||
duration: 3000
|
||||
})
|
||||
@@ -281,7 +295,7 @@ Page({
|
||||
onReconnect: function () {
|
||||
this.setData({ paused: false })
|
||||
wx.showToast({
|
||||
title: '设备已重新连接',
|
||||
title: i18n.t('treating.reconnected'),
|
||||
icon: 'success',
|
||||
duration: 2000
|
||||
})
|
||||
@@ -294,8 +308,8 @@ Page({
|
||||
this._localTimer = null
|
||||
}
|
||||
wx.showModal({
|
||||
title: '连接丢失',
|
||||
content: '设备蓝牙连接已断开,无法恢复。本次使用数据将保存。',
|
||||
title: i18n.t('treating.reconnectFailedTitle'),
|
||||
content: i18n.t('treating.reconnectFailedContent'),
|
||||
showCancel: false,
|
||||
complete: function () {
|
||||
self.finishAsComplete()
|
||||
@@ -310,8 +324,8 @@ Page({
|
||||
onStop: function () {
|
||||
var self = this
|
||||
wx.showModal({
|
||||
title: '结束使用',
|
||||
content: '确定要提前结束本次使用吗?',
|
||||
title: i18n.t('treating.stopTitle'),
|
||||
content: i18n.t('treating.stopContent'),
|
||||
success: function (res) {
|
||||
if (res.confirm) {
|
||||
ble.stopTreatment().then(function (res) {
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<view class="page">
|
||||
<view class="page-header page-header-blue" style="padding-top: {{statusBarHeight + 24}}px;">
|
||||
<view class="nav-back nav-back-light" bindtap="onBack">‹ 返回</view>
|
||||
<view class="page-header-title">使用中</view>
|
||||
<view class="page-header-subtitle">正在使用...</view>
|
||||
<view class="nav-back nav-back-light" bindtap="onBack">‹ {{i18n.common.back}}</view>
|
||||
<view class="page-header-title">{{i18n.treating.title}}</view>
|
||||
<view class="page-header-subtitle">{{i18n.treating.subtitle}}</view>
|
||||
</view>
|
||||
|
||||
<view class="page-content">
|
||||
<view class="countdown-area">
|
||||
<text class="countdown-time">{{remainingText}}</text>
|
||||
<view class="countdown-label">剩余时间</view>
|
||||
<view class="countdown-label">{{i18n.treating.remainingLabel}}</view>
|
||||
</view>
|
||||
|
||||
<view class="progress-bar">
|
||||
@@ -16,17 +16,17 @@
|
||||
</view>
|
||||
|
||||
<view class="mode-tag-area">
|
||||
<text class="mode-tag">{{mode === 1 ? '✨ 智能模式' : '🔄 普通模式'}} · {{regionText}}</text>
|
||||
<text class="mode-tag">{{mode === 1 ? i18n.treating.smartMode : i18n.treating.normalMode}} · {{regionText}}</text>
|
||||
</view>
|
||||
|
||||
<view class="relax-card">
|
||||
<text class="relax-icon">💆</text>
|
||||
<view class="relax-text">
|
||||
<text>请放松心情</text>
|
||||
<text>享受美好时光</text>
|
||||
<text>{{i18n.treating.relax1}}</text>
|
||||
<text>{{i18n.treating.relax2}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<button class="btn-primary btn-primary-red" bindtap="onStop">停止使用</button>
|
||||
<button class="btn-primary btn-primary-red" bindtap="onStop">{{i18n.treating.stopBtn}}</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
var api = require('../../utils/api')
|
||||
var ble = require('../../services/ble')
|
||||
var i18n = require('../../i18n/index')
|
||||
|
||||
Page({
|
||||
data: {
|
||||
@@ -15,6 +16,10 @@ Page({
|
||||
synced: false
|
||||
},
|
||||
|
||||
onShow: function () {
|
||||
i18n.bind(this)
|
||||
},
|
||||
|
||||
onBack: function () {
|
||||
wx.reLaunch({ url: '/pages/index/index' })
|
||||
},
|
||||
@@ -33,7 +38,7 @@ Page({
|
||||
regions: parseInt(options.regions) || 0,
|
||||
duration: durationMs,
|
||||
avgPd: options.avg_pd || 0,
|
||||
durationText: mins > 0 ? mins + '分钟' : secs + '秒',
|
||||
durationText: mins > 0 ? i18n.t('treatmentDone.durationMin', { min: mins }) : i18n.t('treatmentDone.durationSec', { sec: secs }),
|
||||
mode: parseInt(options.mode) || 0,
|
||||
regionNames: ble.getRegionName(parseInt(options.regions) || 0)
|
||||
})
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
<view class="page">
|
||||
<view class="page-header page-header-green" style="padding-top: {{statusBarHeight + 24}}px;">
|
||||
<view class="nav-back" bindtap="onBack">‹ 返回</view>
|
||||
<view class="page-header-title">使用完成</view>
|
||||
<view class="page-header-subtitle">本次使用已结束</view>
|
||||
<view class="nav-back" bindtap="onBack">‹ {{i18n.common.back}}</view>
|
||||
<view class="page-header-title">{{i18n.treatmentDone.title}}</view>
|
||||
<view class="page-header-subtitle">{{i18n.treatmentDone.headerSub}}</view>
|
||||
</view>
|
||||
|
||||
<view class="page-content">
|
||||
<view class="result-icon">✓</view>
|
||||
<view class="result-title">使用完成!</view>
|
||||
<view class="result-title">{{i18n.treatmentDone.resultTitle}}</view>
|
||||
|
||||
<view class="result-data">
|
||||
<view class="result-item">
|
||||
<view class="result-value">{{durationText}}</view>
|
||||
<view class="result-label">使用时长</view>
|
||||
<view class="result-label">{{i18n.treatmentDone.durationLabel}}</view>
|
||||
</view>
|
||||
<view class="result-item">
|
||||
<view class="result-value">{{regionNames.length}}</view>
|
||||
<view class="result-label">使用区域</view>
|
||||
<view class="result-label">{{i18n.treatmentDone.regionLabel}}</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="result-tip">
|
||||
<text class="result-tip-icon">✨</text>
|
||||
<text class="result-tip-text">本次使用已记录</text>
|
||||
<text class="result-tip-text">{{i18n.treatmentDone.recorded}}</text>
|
||||
</view>
|
||||
|
||||
<button class="btn-primary" bindtap="onBackHome">返回首页</button>
|
||||
<button class="btn-primary" bindtap="onBackHome">{{i18n.treatmentDone.backHome}}</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
var ble = require('../../services/ble')
|
||||
var i18n = require('../../i18n/index')
|
||||
Page({
|
||||
data: {
|
||||
statusBarHeight: 44,
|
||||
regions: [
|
||||
{ name: '右区', mask: 0x01, checked: true },
|
||||
{ name: '左区', mask: 0x02, checked: true },
|
||||
{ name: '上区', mask: 0x04, checked: true },
|
||||
{ name: '中区', mask: 0x08, checked: true },
|
||||
{ name: '下区', mask: 0x10, checked: true }
|
||||
{ name: '', mask: 0x01, checked: true },
|
||||
{ name: '', mask: 0x02, checked: true },
|
||||
{ name: '', mask: 0x04, checked: true },
|
||||
{ name: '', mask: 0x08, checked: true },
|
||||
{ name: '', mask: 0x10, checked: true }
|
||||
],
|
||||
selectedMode: 0,
|
||||
subExpired: false,
|
||||
@@ -25,9 +26,24 @@ Page({
|
||||
})
|
||||
},
|
||||
|
||||
onManual: function () {
|
||||
wx.navigateTo({ url: '/pages/manual-treatment/manual-treatment' })
|
||||
},
|
||||
|
||||
onLoad: function () {
|
||||
var app = getApp()
|
||||
this.setData({ statusBarHeight: app.globalData.statusBarHeight })
|
||||
i18n.bind(this)
|
||||
// 顺序对应 data.regions 掩码 0x01..0x10 = IO1..IO5 = 右/左/上/中/下(实测)
|
||||
var regionKeys = ['right', 'left', 'top', 'middle', 'bottom']
|
||||
var regions = this.data.regions
|
||||
regions.forEach(function (r, i) {
|
||||
r.name = i18n.t('common.regions.' + regionKeys[i])
|
||||
})
|
||||
this.setData({
|
||||
regions: regions,
|
||||
subDaysText: i18n.t('setup.remainDays', { days: this.data.subDays })
|
||||
})
|
||||
this.checkSubscription()
|
||||
},
|
||||
|
||||
@@ -37,7 +53,8 @@ Page({
|
||||
http.get('/api/v1/subscription').then(function (sub) {
|
||||
self.setData({
|
||||
subExpired: sub.status !== 'active',
|
||||
subDays: sub.remaining_days || 0
|
||||
subDays: sub.remaining_days || 0,
|
||||
subDaysText: i18n.t('setup.remainDays', { days: sub.remaining_days || 0 })
|
||||
})
|
||||
}).catch(function () {})
|
||||
},
|
||||
@@ -73,7 +90,7 @@ Page({
|
||||
})
|
||||
|
||||
if (mask === 0) {
|
||||
wx.showToast({ title: '请至少选择一个区域', icon: 'none' })
|
||||
wx.showToast({ title: i18n.t('setup.noRegion'), icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -88,7 +105,7 @@ Page({
|
||||
ble.setParams({
|
||||
region_mask: mask,
|
||||
wavelength: 2,
|
||||
brightness: 200,
|
||||
brightness: 204, // 治疗亮度统一 80%(果果 2026-07-28 定案)
|
||||
duration_ms: self.data.fixedDurationMs,
|
||||
mode: self.data.selectedMode,
|
||||
control: 0x02
|
||||
@@ -103,7 +120,7 @@ Page({
|
||||
'&mode=' + self.data.selectedMode
|
||||
})
|
||||
}).catch(function (err) {
|
||||
self.setData({ submitting: false, error: err.error_msg || '启动失败' })
|
||||
self.setData({ submitting: false, error: err.error_msg || i18n.t('setup.startFailed') })
|
||||
})
|
||||
},
|
||||
|
||||
|
||||
@@ -1,42 +1,45 @@
|
||||
<view class="page">
|
||||
<view class="page-header page-header-pink" style="padding-top: {{statusBarHeight + 24}}px;">
|
||||
<view class="nav-back" bindtap="onBack">‹ 返回</view>
|
||||
<view class="page-header-title">使用设置</view>
|
||||
<view class="page-header-subtitle">选择模式和区域</view>
|
||||
<view class="nav-back" bindtap="onBack">‹ {{i18n.common.back}}</view>
|
||||
<view class="page-header-title">{{i18n.setup.title}}</view>
|
||||
<view class="page-header-subtitle">{{i18n.setup.subtitle}}</view>
|
||||
</view>
|
||||
|
||||
<view class="page-content">
|
||||
<view class="page-title">选择模式</view>
|
||||
<view class="page-title">{{i18n.setup.selectMode}}</view>
|
||||
|
||||
<view class="mode-selector">
|
||||
<view class="mode-option {{selectedMode === 0 ? 'active' : ''}}" bindtap="onSelectMode" data-value="0">
|
||||
<text class="mode-icon">🔄</text>
|
||||
<view class="mode-label">普通模式</view>
|
||||
<view class="mode-sub">6色循环</view>
|
||||
<view class="mode-label">{{i18n.setup.normalMode}}</view>
|
||||
<view class="mode-sub">{{i18n.setup.normalModeSub}}</view>
|
||||
</view>
|
||||
<view class="mode-option {{selectedMode === 1 ? 'active' : ''}} {{subExpired ? 'locked' : ''}}" bindtap="onSelectMode" data-value="1">
|
||||
<text class="mode-icon">✨</text>
|
||||
<view class="mode-label">智能模式</view>
|
||||
<view class="mode-sub" wx:if="{{!subExpired}}">剩余{{subDays}}天</view>
|
||||
<view class="mode-sub" wx:else>订阅解锁</view>
|
||||
<view class="mode-label">{{i18n.setup.smartMode}}</view>
|
||||
<view class="mode-sub" wx:if="{{!subExpired}}">{{subDaysText}}</view>
|
||||
<view class="mode-sub" wx:else>{{i18n.setup.subscribeUnlock}}</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="page-title" style="font-size:28rpx;">选择区域</view>
|
||||
<view class="mode-tip" wx:if="{{selectedMode === 0}}">普通模式固定 10 分钟,使用区域默认全区域,不可调整</view>
|
||||
<view class="page-title" style="font-size:28rpx;">{{i18n.setup.selectRegion}}</view>
|
||||
<view class="mode-tip" wx:if="{{selectedMode === 0}}">{{i18n.setup.normalTip}}</view>
|
||||
|
||||
<!-- 位置↔idx(实测 IO↔物理位置):idx0=右(0x01) idx1=左(0x02) idx2=上(0x04) idx3=中(0x08) idx4=下(0x10) -->
|
||||
<view class="face-map">
|
||||
<view class="face-region forehead {{regions[2].checked ? 'active' : ''}} {{selectedMode === 0 ? 'disabled' : ''}}" bindtap="onToggleRegion" data-idx="2">上区</view>
|
||||
<view class="face-region left-cheek {{regions[1].checked ? 'active' : ''}} {{selectedMode === 0 ? 'disabled' : ''}}" bindtap="onToggleRegion" data-idx="1">左区</view>
|
||||
<view class="face-region right-cheek {{regions[0].checked ? 'active' : ''}} {{selectedMode === 0 ? 'disabled' : ''}}" bindtap="onToggleRegion" data-idx="0">右区</view>
|
||||
<view class="face-region nose {{regions[3].checked ? 'active' : ''}} {{selectedMode === 0 ? 'disabled' : ''}}" bindtap="onToggleRegion" data-idx="3">中区</view>
|
||||
<view class="face-region chin {{regions[4].checked ? 'active' : ''}} {{selectedMode === 0 ? 'disabled' : ''}}" bindtap="onToggleRegion" data-idx="4">下区</view>
|
||||
<view class="face-region forehead {{regions[2].checked ? 'active' : ''}} {{selectedMode === 0 ? 'disabled' : ''}}" bindtap="onToggleRegion" data-idx="2">{{i18n.common.regions.top}}</view>
|
||||
<view class="face-region left-cheek {{regions[1].checked ? 'active' : ''}} {{selectedMode === 0 ? 'disabled' : ''}}" bindtap="onToggleRegion" data-idx="1">{{i18n.common.regions.left}}</view>
|
||||
<view class="face-region right-cheek {{regions[0].checked ? 'active' : ''}} {{selectedMode === 0 ? 'disabled' : ''}}" bindtap="onToggleRegion" data-idx="0">{{i18n.common.regions.right}}</view>
|
||||
<view class="face-region nose {{regions[3].checked ? 'active' : ''}} {{selectedMode === 0 ? 'disabled' : ''}}" bindtap="onToggleRegion" data-idx="3">{{i18n.common.regions.middle}}</view>
|
||||
<view class="face-region chin {{regions[4].checked ? 'active' : ''}} {{selectedMode === 0 ? 'disabled' : ''}}" bindtap="onToggleRegion" data-idx="4">{{i18n.common.regions.bottom}}</view>
|
||||
</view>
|
||||
|
||||
<button class="btn-primary" bindtap="onStart" disabled="{{submitting}}" loading="{{submitting}}">
|
||||
开始使用
|
||||
{{i18n.setup.startBtn}}
|
||||
</button>
|
||||
|
||||
<view class="manual-entry" wx:if="{{selectedMode === 1}}" bindtap="onManual" style="text-align:center;color:#E8503A;font-size:26rpx;margin-top:28rpx;">{{i18n.manual.entryLink}}</view>
|
||||
|
||||
<view class="error-text" wx:if="{{error}}">{{error}}</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -99,7 +99,7 @@ function handleValueChange(res) {
|
||||
console.warn('[BLE] ADC checksum mismatch, dropping')
|
||||
return
|
||||
}
|
||||
emit('adc', { pd: parsed.pd, vbat: vbatMv, battery: batteryPct, fitting: parsed.fitting, run_state: parsed.run_state })
|
||||
emit('adc', { pd: parsed.pd, vbat: vbatMv, battery: batteryPct, fitting: parsed.fitting, scan_wave: parsed.scan_wave, run_state: parsed.run_state, is_fault: parsed.is_fault })
|
||||
emit('battery', { battery: batteryPct, vbat: vbatMv })
|
||||
emit('fitting', { fitting: parsed.fitting })
|
||||
emit('run_state', { run_state: parsed.run_state })
|
||||
|
||||
@@ -57,18 +57,23 @@ var TREAT_MODE = {
|
||||
SMART: 1
|
||||
}
|
||||
|
||||
// 区域 = IO↔物理位置(果果 2026-07-28 真机实测):右=IO1(0x01) 左=IO2(0x02) 上=IO3(0x04)
|
||||
// 中=IO4(0x08) 下=IO5(0x10)。此前口头给的"纵列左IO1/中上IO2…"实测不符,以本表为准。
|
||||
var REGION = {
|
||||
LEFT_CHEEK: 0x01,
|
||||
RIGHT_CHEEK: 0x02,
|
||||
FOREHEAD: 0x04,
|
||||
CHIN: 0x08,
|
||||
NOSE: 0x10,
|
||||
LEFT_EYE: 0x20,
|
||||
RIGHT_EYE: 0x40,
|
||||
FULL_FACE: 0x7F
|
||||
RIGHT: 0x01,
|
||||
LEFT: 0x02,
|
||||
TOP: 0x04,
|
||||
MIDDLE: 0x08,
|
||||
BOTTOM: 0x10,
|
||||
FULL_FACE: 0x1F
|
||||
}
|
||||
|
||||
var REGION_NAMES = ['left_cheek', 'right_cheek', 'forehead', 'chin', 'nose', 'left_eye', 'right_eye']
|
||||
var REGION_NAMES = ['right', 'left', 'top', 'middle', 'bottom']
|
||||
|
||||
// FFE4 Byte17 高三位 = 扫描时光色指示(协议简述-添加扫描时光色.docx 2026-07):
|
||||
// 001=红光 010=红外 011=紫外(蓝) 100=暖黄。注意与下发命令的 WAVELENGTH 编码
|
||||
// (IR=1,R=2,UV=3,Y=4) 不同——1/2 正好是红与红外互换,绝不能复用那套常量。
|
||||
var SCAN_WAVE_KEY = { 1: 'red', 2: 'ir', 3: 'uv', 4: 'yellow' }
|
||||
|
||||
var DEVICE_ERR = {
|
||||
0x00: 'SUCCESS',
|
||||
@@ -167,37 +172,66 @@ var CONTROL = {
|
||||
TREAT: 0x02
|
||||
}
|
||||
|
||||
// 每 IO 6 字节,颜色格顺序 [红][红外][紫外][暖黄](厂商原表,新版协议简述再次确认)。
|
||||
// 两种调用方式:
|
||||
// 单色广播(旧):{ region_mask, wavelength(编码), brightness, ... }
|
||||
// 分区差异化(新):{ plan: [{ mask, wave:'red'|'ir'|'uv'|'yellow', brightness }], hold_time, control }
|
||||
// —— 同一条命令里不同区可用不同颜色不同亮度(协议本身就支持 5 IO × 4 色独立)。
|
||||
var WAVE_BYTE_OFFSET = { red: 0, ir: 1, uv: 2, yellow: 3 }
|
||||
|
||||
// 安全包线(builder 层硬上限,纵深防御——即使服务端 diagnosis_config 误配也不可突破):
|
||||
// 治疗亮度 ≤204(80%),单次时长 ≤600s(10min)。固件侧安全机制大多未实现,此处是最后防线。
|
||||
var MAX_TREAT_BRIGHTNESS = 204
|
||||
var MAX_HOLD_SECONDS = 600
|
||||
|
||||
function buildVendorCommand(options) {
|
||||
options = options || {}
|
||||
var regionMask = options.region_mask || REGION.FULL_FACE
|
||||
var wavelength = options.wavelength || WAVELENGTH.R
|
||||
var brightness = clampByte(options.brightness === undefined ? 200 : options.brightness)
|
||||
var gain = clampUInt16(options.current_gain === undefined ? 0 : options.current_gain)
|
||||
var durationMs = options.duration_ms || 600000
|
||||
var holdSeconds = clampUInt16(options.hold_time === undefined ? Math.round(durationMs / 1000) : options.hold_time)
|
||||
var control = options.control !== undefined ? options.control : CONTROL.TREAT
|
||||
var regions = [
|
||||
REGION.LEFT_CHEEK,
|
||||
REGION.RIGHT_CHEEK,
|
||||
REGION.FOREHEAD,
|
||||
REGION.CHIN,
|
||||
REGION.NOSE
|
||||
]
|
||||
if (holdSeconds > MAX_HOLD_SECONDS) holdSeconds = MAX_HOLD_SECONDS
|
||||
var isTreat = control === CONTROL.TREAT
|
||||
function clampTreat(b) {
|
||||
b = clampByte(b)
|
||||
return isTreat && b > MAX_TREAT_BRIGHTNESS ? MAX_TREAT_BRIGHTNESS : b
|
||||
}
|
||||
// IO1..IO5 物理槽位 = bit 升序(Byte n*6 对应 mask 1<<n)。直接用字面量,不依赖
|
||||
// REGION 常量名——区域↔mask 语义调整时这里必须始终是 0x01,0x02,0x04,0x08,0x10。
|
||||
var ioMasks = [0x01, 0x02, 0x04, 0x08, 0x10] // IO1=右 IO2=左 IO3=上 IO4=中 IO5=下
|
||||
var bytes = []
|
||||
var i, j
|
||||
|
||||
for (var i = 0; i < regions.length; i++) {
|
||||
var enabled = (regionMask & regions[i]) !== 0
|
||||
var red = 0
|
||||
var infrared = 0
|
||||
var uv = 0
|
||||
var warmYellow = 0
|
||||
if (enabled) {
|
||||
if (wavelength === WAVELENGTH.R) red = brightness
|
||||
if (wavelength === WAVELENGTH.IR) infrared = brightness
|
||||
if (wavelength === WAVELENGTH.UV) uv = brightness
|
||||
if (wavelength === WAVELENGTH.Y) warmYellow = brightness
|
||||
if (options.plan && options.plan.length) {
|
||||
for (i = 0; i < ioMasks.length; i++) {
|
||||
var slot = [0, 0, 0, 0]
|
||||
for (j = 0; j < options.plan.length; j++) {
|
||||
var p = options.plan[j]
|
||||
if ((p.mask & ioMasks[i]) === 0) continue
|
||||
var off = WAVE_BYTE_OFFSET[p.wave]
|
||||
if (off === undefined) continue
|
||||
slot[off] = clampTreat(p.brightness === undefined ? 200 : p.brightness)
|
||||
}
|
||||
bytes.push(slot[0], slot[1], slot[2], slot[3], (gain >> 8) & 0xFF, gain & 0xFF)
|
||||
}
|
||||
} else {
|
||||
var regionMask = options.region_mask || REGION.FULL_FACE
|
||||
var wavelength = options.wavelength || WAVELENGTH.R
|
||||
var brightness = clampTreat(options.brightness === undefined ? 200 : options.brightness)
|
||||
for (i = 0; i < ioMasks.length; i++) {
|
||||
var enabled = (regionMask & ioMasks[i]) !== 0
|
||||
var red = 0
|
||||
var infrared = 0
|
||||
var uv = 0
|
||||
var warmYellow = 0
|
||||
if (enabled) {
|
||||
if (wavelength === WAVELENGTH.R) red = brightness
|
||||
if (wavelength === WAVELENGTH.IR) infrared = brightness
|
||||
if (wavelength === WAVELENGTH.UV) uv = brightness
|
||||
if (wavelength === WAVELENGTH.Y) warmYellow = brightness
|
||||
}
|
||||
bytes.push(red, infrared, uv, warmYellow, (gain >> 8) & 0xFF, gain & 0xFF)
|
||||
}
|
||||
bytes.push(red, infrared, uv, warmYellow, (gain >> 8) & 0xFF, gain & 0xFF)
|
||||
}
|
||||
|
||||
bytes.push((holdSeconds >> 8) & 0xFF, holdSeconds & 0xFF)
|
||||
@@ -217,13 +251,20 @@ function parseVendorStatus(buffer) {
|
||||
for (var p = 0; p < 7; p++) {
|
||||
pds19.push(bytes[p * 2] | (bytes[p * 2 + 1] << 8))
|
||||
}
|
||||
// Byte17 复用:固件约定「非 0 即贴合通过」;高三位另表扫描光色(001红/010红外/011紫外/100暖黄)。
|
||||
// 扫描态 Byte17 = 0x20/0x40/0x60/0x80(有光色→非0→贴合);检测启动但未贴合时 = 0。
|
||||
// 注意:贴合与光色共用一字节,固件当前无独立贴合位(已反馈,待其拆分)。
|
||||
// Byte18 运行状态:0=IDLE 1=检测 2=治疗 0xFF=故障。
|
||||
var scanWaveCode = (bytes[16] >> 5) & 0x07
|
||||
return {
|
||||
type: 'adc',
|
||||
is_heartbeat: false,
|
||||
pd: pds19,
|
||||
vbat: bytes[14] | (bytes[15] << 8),
|
||||
fitting: bytes[16],
|
||||
fitting: bytes[16] !== 0 ? 1 : 0,
|
||||
scan_wave: SCAN_WAVE_KEY[scanWaveCode] || null,
|
||||
run_state: bytes[17],
|
||||
is_fault: bytes[17] === 0xFF,
|
||||
checksum_ok: checksum19 === bytes[18],
|
||||
raw_hex: bytesToHex(bytes)
|
||||
}
|
||||
@@ -326,11 +367,13 @@ function parseException(payload) {
|
||||
|
||||
// --- display helpers ---
|
||||
|
||||
// 掩码 = IO↔物理位置(与 REGION 常量一致)。注意:此处为硬编码中文(历史遗留,
|
||||
// history/treatment-done 在用);新页面请改用 i18n common.regions.* 取标签。
|
||||
function getRegionName(mask) {
|
||||
var names = []
|
||||
var bits = [
|
||||
[0x01, '右区'], [0x02, '左区'], [0x04, '上区'],
|
||||
[0x08, '中区'], [0x10, '下区'], [0x20, '左上区'], [0x40, '右上区']
|
||||
[0x08, '中区'], [0x10, '下区']
|
||||
]
|
||||
for (var i = 0; i < bits.length; i++) {
|
||||
if (mask & bits[i][0]) names.push(bits[i][1])
|
||||
@@ -362,6 +405,7 @@ module.exports = {
|
||||
TREAT_MODE: TREAT_MODE,
|
||||
REGION: REGION,
|
||||
REGION_NAMES: REGION_NAMES,
|
||||
SCAN_WAVE_KEY: SCAN_WAVE_KEY,
|
||||
CONTROL: CONTROL,
|
||||
DEVICE_ERR: DEVICE_ERR,
|
||||
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
// Skin-tendency diagnosis — 按《扫描+护理完整流程.docx》7.1/7.2/7.3 +
|
||||
// 《协议简述-添加扫描时光色.docx》+ 果果 2026-07 定案实现。
|
||||
//
|
||||
// 核心链路:28 组 PD(4 波长×7 路) → 按 PD_REGION_MAP 聚合成 5 区 →
|
||||
// 每波长算 5 区平均当基准 → 吸收度 NR = 本区/基准 − 1 →
|
||||
// 每区取 4 个 NR 的最大者(argmax),超过阈值 TH 才判定问题 → 该波长即该区治疗光色。
|
||||
//
|
||||
// 极性(已由固件批注版文档坐实,新版协议简述再次确认"0x0FFF表示最低光照"):
|
||||
// PD ADC 值越大 = 反射光越弱 = 皮肤吸收越多。故 NR = zone/avg − 1(吸收多→NR 高)。
|
||||
// config.polarity = 'direct' 可切回"值大=光强"的旧假设(备用开关,标定时救急)。
|
||||
//
|
||||
// PD↔区域映射(果果转达):左=PD1 上=PD2 中=PD3/6/7 下=PD4 右=PD5(方位与实测 IO 表一致)。
|
||||
// 仍待定:3 路 PD 区的聚合——文档 7.1 自相矛盾(几何平均 vs 取最小),按更细的几何平均+MAD剔异常实现。
|
||||
// 所有阈值/亮度/映射均可被服务端 diagnosis_config 覆盖,标定后免发版调参。
|
||||
|
||||
// 问题类型 -> 治疗波长编码(下发命令用:IR=1, R=2, UV=3, Y=4)。
|
||||
var PROBLEM_WAVELENGTH = { aging: 2, acne: 3, pigment: 4, deep: 1 }
|
||||
// 波长 key -> 揭示的问题类型。
|
||||
var WAVE_PROBLEM = { red: 'aging', uv: 'acne', yellow: 'pigment', ir: 'deep' }
|
||||
// 波长 key -> 下发命令编码。
|
||||
var WAVE_KEY_CODE = { red: 2, uv: 3, yellow: 4, ir: 1 }
|
||||
|
||||
// 5 区 = IO↔物理位置(果果 2026-07-28 实测):右=IO1(0x01) 左=IO2(0x02) 上=IO3(0x04) 中=IO4(0x08) 下=IO5(0x10)。
|
||||
var REGION_DEFS = [
|
||||
{ key: 'right', mask: 0x01 },
|
||||
{ key: 'left', mask: 0x02 },
|
||||
{ key: 'top', mask: 0x04 },
|
||||
{ key: 'middle', mask: 0x08 },
|
||||
{ key: 'bottom', mask: 0x10 }
|
||||
]
|
||||
|
||||
var DEFAULT_CONFIG = {
|
||||
calibrated: false,
|
||||
polarity: 'inverted', // inverted=值大吸收多(已确认) | direct=旧假设备用
|
||||
th: 0.10, // 判定阈值(文档建议初始值,需临床标定)
|
||||
severity: { low: 0.05, mid: 0.15, high: 0.30 }, // 轻/中/重分级,仅报告展示
|
||||
brightness: 204, // 治疗强度 80%(果果定案),仅用于推荐方案下发
|
||||
// PD↔区域映射(果果 2026-07-28 确认):左=PD1 上=PD2 中=PD3/6/7 下=PD4 右=PD5(下标 0 基)。
|
||||
pd_region_map: { right: [4], left: [0], top: [1], middle: [2, 5, 6], bottom: [3] },
|
||||
problem_wavelength: PROBLEM_WAVELENGTH
|
||||
}
|
||||
|
||||
function _median(arr) {
|
||||
var s = arr.slice().sort(function (a, b) { return a - b })
|
||||
var m = Math.floor(s.length / 2)
|
||||
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2
|
||||
}
|
||||
|
||||
// 区域有效值:1 路直接用;≥3 路按 7.1 节"几何平均 + 剔奇异值"(防黑痣/毛发单点失真)。
|
||||
// 剔异常用 MAD(中位数绝对偏差)而非文档字面的 3σ:n=3 时单个离群点会把标准差同步撑大,
|
||||
// 3σ 判据永不触发(审计实证 [900,9000,905] 不剔);MAD 阈值 4.45×MAD ≈ 3σ 等效(3×1.4826)。
|
||||
function _aggregate(vals) {
|
||||
if (!vals.length) return 0
|
||||
if (vals.length === 1) return vals[0]
|
||||
var kept = vals
|
||||
if (vals.length >= 3) {
|
||||
var med = _median(vals)
|
||||
var devs = vals.map(function (v) { return Math.abs(v - med) })
|
||||
var mad = _median(devs)
|
||||
var thr = 4.45 * mad
|
||||
var filtered = vals.filter(function (v) { return Math.abs(v - med) <= thr })
|
||||
if (filtered.length >= 2) kept = filtered
|
||||
}
|
||||
var logSum = 0
|
||||
for (var i = 0; i < kept.length; i++) {
|
||||
if (kept[i] <= 0) return 0 // 有无效 0 值时该区数据不可信
|
||||
logSum += Math.log(kept[i])
|
||||
}
|
||||
return Math.exp(logSum / kept.length)
|
||||
}
|
||||
|
||||
function _severityLevel(nr, severity) {
|
||||
if (nr > severity.high) return 3
|
||||
if (nr > severity.mid) return 2
|
||||
if (nr > severity.low) return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
// scanData: {
|
||||
// device_id, scanned_at,
|
||||
// region_mask, // 本次扫描选中的区域(默认 0x1F)
|
||||
// waves: { red:[7], ir:[7], uv:[7], yellow:[7] } // 任意子集;每项 = 7 路 PD ADC
|
||||
// }
|
||||
function analyze(scanData, config) {
|
||||
config = config || DEFAULT_CONFIG
|
||||
var th = config.th !== undefined ? config.th : DEFAULT_CONFIG.th
|
||||
var severity = config.severity || DEFAULT_CONFIG.severity
|
||||
var brightness = config.brightness || DEFAULT_CONFIG.brightness
|
||||
var pdMap = config.pd_region_map || DEFAULT_CONFIG.pd_region_map
|
||||
var problemWave = config.problem_wavelength || PROBLEM_WAVELENGTH
|
||||
var inverted = config.polarity !== 'direct'
|
||||
scanData = scanData || {}
|
||||
var waves = scanData.waves || {}
|
||||
var scannedMask = scanData.region_mask || 0x1F
|
||||
|
||||
// 1) 聚合:每波长 × 每选中区 → 区域有效值
|
||||
var zone = {} // zone[waveKey][regionKey] = 有效值
|
||||
var included = [] // 参与本次扫描的区域定义
|
||||
var r, def
|
||||
for (r = 0; r < REGION_DEFS.length; r++) {
|
||||
if ((scannedMask & REGION_DEFS[r].mask) !== 0) included.push(REGION_DEFS[r])
|
||||
}
|
||||
for (var wk in waves) {
|
||||
if (!waves.hasOwnProperty(wk) || !WAVE_PROBLEM[wk]) continue
|
||||
var pdArr = waves[wk]
|
||||
if (!pdArr || !pdArr.length) continue
|
||||
zone[wk] = {}
|
||||
for (r = 0; r < included.length; r++) {
|
||||
def = included[r]
|
||||
var idxs = pdMap[def.key] || []
|
||||
var vals = []
|
||||
for (var k = 0; k < idxs.length; k++) {
|
||||
var v = pdArr[idxs[k]]
|
||||
if (v !== undefined && v !== null && v > 0) vals.push(v)
|
||||
}
|
||||
if (vals.length) zone[wk][def.key] = _aggregate(vals)
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 每波长的基准 = 该波长下各区有效值的平均(7.1:Avg_PD_X_AllZones)
|
||||
var avg = {}
|
||||
for (wk in zone) {
|
||||
if (!zone.hasOwnProperty(wk)) continue
|
||||
var sum = 0
|
||||
var n = 0
|
||||
for (r = 0; r < included.length; r++) {
|
||||
var zv = zone[wk][included[r].key]
|
||||
if (zv !== undefined) { sum += zv; n++ }
|
||||
}
|
||||
if (n > 0) avg[wk] = sum / n
|
||||
}
|
||||
|
||||
// 3) 吸收度 NR + 每区 argmax 判定
|
||||
var regions = []
|
||||
var overallAcc = {}
|
||||
for (r = 0; r < included.length; r++) {
|
||||
def = included[r]
|
||||
var nrs = [] // 该区所有可算波长的 {wave, nr}
|
||||
for (wk in zone) {
|
||||
if (!zone.hasOwnProperty(wk)) continue
|
||||
if (zone[wk][def.key] === undefined || !avg[wk]) continue
|
||||
var ratio = zone[wk][def.key] / avg[wk]
|
||||
var nr = inverted ? (ratio - 1) : (1 - ratio)
|
||||
nrs.push({ wave: wk, nr: Math.round(nr * 1000) / 1000 })
|
||||
}
|
||||
nrs.sort(function (a, b) { return b.nr - a.nr })
|
||||
var top = nrs.length ? nrs[0] : null
|
||||
var hasProblem = !!(top && top.nr > th)
|
||||
var problemType = hasProblem ? WAVE_PROBLEM[top.wave] : null
|
||||
var level = hasProblem ? Math.max(1, _severityLevel(top.nr, severity)) : 0
|
||||
if (problemType && (!overallAcc[problemType] || level > overallAcc[problemType])) {
|
||||
overallAcc[problemType] = level
|
||||
}
|
||||
regions.push({
|
||||
region: def.key,
|
||||
mask: def.mask,
|
||||
top_problem: problemType,
|
||||
wave: hasProblem ? top.wave : null,
|
||||
level: level,
|
||||
score: top ? top.nr : 0,
|
||||
nrs: nrs // 全量吸收度,供标定期回看
|
||||
})
|
||||
}
|
||||
|
||||
var overall = []
|
||||
for (var pt in overallAcc) {
|
||||
if (overallAcc.hasOwnProperty(pt)) overall.push({ type: pt, level: overallAcc[pt] })
|
||||
}
|
||||
overall.sort(function (a, b) { return b.level - a.level })
|
||||
|
||||
// 4) 推荐方案:每个问题区一条(区独立、可不同色),一条 BLE 命令即可全部下发。
|
||||
var recommendPlan = []
|
||||
var recommendMask = 0
|
||||
for (r = 0; r < regions.length; r++) {
|
||||
var reg = regions[r]
|
||||
if (!reg.top_problem || !reg.wave) continue
|
||||
recommendPlan.push({
|
||||
region: reg.region,
|
||||
mask: reg.mask,
|
||||
wave: reg.wave,
|
||||
wavelength: WAVE_KEY_CODE[reg.wave],
|
||||
label_key: reg.top_problem,
|
||||
level: reg.level,
|
||||
brightness: brightness
|
||||
})
|
||||
recommendMask |= reg.mask
|
||||
}
|
||||
|
||||
return {
|
||||
device_id: scanData.device_id || null,
|
||||
scanned_at: scanData.scanned_at || 0,
|
||||
regions: regions,
|
||||
overall: overall,
|
||||
recommend_mask: recommendMask,
|
||||
recommend_plan: recommendPlan,
|
||||
raw_pd: waves,
|
||||
calibrated: !!config.calibrated
|
||||
}
|
||||
}
|
||||
|
||||
// 把 auto-scan 按波长分桶后的平均值组装成 scanData。
|
||||
// buckets: { red:[7], ir:[7], uv:[7], yellow:[7] } 任意子集(旧固件无波长标识时只有 red)。
|
||||
function buildScanData(buckets, regionMask, deviceId, scannedAt) {
|
||||
return {
|
||||
device_id: deviceId || null,
|
||||
scanned_at: scannedAt || 0,
|
||||
region_mask: regionMask || 0x1F,
|
||||
waves: buckets || {}
|
||||
}
|
||||
}
|
||||
|
||||
// 无设备数据时的演示报告(开发者工具 mock)。
|
||||
// 数值按已确认极性编造:值大=吸收多 → 左区红光偏高(光老化)、中区紫外偏高(痘痘油脂)。
|
||||
function mockReport() {
|
||||
return analyze({
|
||||
device_id: 'MOCK',
|
||||
scanned_at: 0,
|
||||
region_mask: 0x1F,
|
||||
waves: {
|
||||
// 下标: 0=PD1左 1=PD2上 2=PD3中 3=PD4下 4=PD5右 5=PD6中 6=PD7中
|
||||
red: [1350, 920, 900, 910, 905, 890, 940],
|
||||
uv: [2050, 2020, 2600, 2010, 2080, 2560, 2620],
|
||||
yellow: [1500, 1520, 1490, 1505, 1495, 1530, 1510],
|
||||
ir: [1800, 1790, 1805, 1795, 1800, 1815, 1790]
|
||||
}
|
||||
}, DEFAULT_CONFIG)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_CONFIG: DEFAULT_CONFIG,
|
||||
PROBLEM_WAVELENGTH: PROBLEM_WAVELENGTH,
|
||||
WAVE_PROBLEM: WAVE_PROBLEM,
|
||||
WAVE_KEY_CODE: WAVE_KEY_CODE,
|
||||
REGION_DEFS: REGION_DEFS,
|
||||
analyze: analyze,
|
||||
buildScanData: buildScanData,
|
||||
mockReport: mockReport
|
||||
}
|
||||
@@ -30,6 +30,12 @@ module.exports = {
|
||||
syncTreatment: function (data) { return http.post('/api/v1/treatment/sync', data) },
|
||||
getRecordDetail: function (id) { return http.get('/api/v1/treatment/' + id) },
|
||||
|
||||
// Scan report + diagnosis config (backend contract; see server scan-report routes)
|
||||
saveReport: function (data) { return http.post('/api/v1/treatment/report', data) },
|
||||
getLatestReport: function (deviceId) { return http.get('/api/v1/treatment/report/latest', deviceId ? { device_id: deviceId } : {}) },
|
||||
getReport: function (id) { return http.get('/api/v1/treatment/report/' + id) },
|
||||
getDiagnosisConfig: function () { return http.get('/api/v1/treatment/diagnosis-config') },
|
||||
|
||||
// Device commands
|
||||
getPendingCommands: function (deviceId) { return http.get('/api/v1/device/command/pending', { device_id: deviceId }) },
|
||||
reportCommandResult: function (data) { return http.post('/api/v1/device/command/result', data) }
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
-- 迁移:扫描报告存储 + 诊断配置种子(对应小程序 commit 6b91ebe 的诊断链路上线)
|
||||
-- 幂等:可重复执行。只做两件事,不碰其他表/账号。
|
||||
-- 执行方式见 WorkLog: products/jw-beauty/2026-07-28-生产DB迁移指引.md
|
||||
|
||||
-- 1) 扫描报告表(Phase 1 已定义,生产一直未建)
|
||||
CREATE TABLE IF NOT EXISTS scan_reports (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
user_id BIGINT UNSIGNED NOT NULL,
|
||||
device_id VARCHAR(32) NULL,
|
||||
scanned_at DATETIME NULL,
|
||||
regions JSON NULL COMMENT 'array of per-region analysis',
|
||||
overall JSON NULL COMMENT 'array of overall tendencies',
|
||||
recommend_mask INT NOT NULL DEFAULT 0 COMMENT 'bitmask of regions recommended for care',
|
||||
recommend_plan JSON NULL COMMENT 'array of {region, mask, wave, wavelength, label_key, level, brightness}',
|
||||
raw_pd JSON NULL COMMENT 'raw PD samples for the calibration dataset (may be partial)',
|
||||
calibrated TINYINT NOT NULL DEFAULT 0 COMMENT 'whether thresholds were calibrated',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_scan_reports_user_created (user_id, created_at),
|
||||
KEY idx_scan_reports_device_created (device_id, created_at),
|
||||
CONSTRAINT fk_scan_reports_user FOREIGN KEY (user_id) REFERENCES users (user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- 2) 诊断配置种子(2026-07-28 新结构:极性/阈值/亮度/PD映射全部热调)
|
||||
-- pd_region_map 已由固件确认(2026-07-28):左=PD1 中上=PD2 中=PD3/6/7 中下=PD4 右=PD5(0基下标)
|
||||
-- ON DUPLICATE KEY UPDATE 会覆盖已有的 diagnosis_config——当前生产从未配置过该键,
|
||||
-- 若执行前发现已有人工调过的值(见指引文档的核查步骤),先备份再执行。
|
||||
INSERT INTO system_settings (setting_key, setting_value) VALUES
|
||||
('diagnosis_config', '{"calibrated": false, "note": "阈值为待标定初始值(文档建议值);polarity=inverted 与 pd_region_map(左PD1/中上PD2/中PD3,6,7/中下PD4/右PD5) 均已由固件确认(2026-07-28)", "polarity": "inverted", "th": 0.10, "severity": {"low": 0.05, "mid": 0.15, "high": 0.30}, "brightness": 204, "pd_region_map": {"left": [0], "top": [1], "middle": [2, 5, 6], "bottom": [3], "right": [4]}, "problem_wavelength": {"aging": 2, "acne": 3, "pigment": 4, "deep": 1}}')
|
||||
ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value);
|
||||
+20
-1
@@ -84,6 +84,24 @@ CREATE TABLE IF NOT EXISTS treatment_records (
|
||||
CONSTRAINT fk_treatment_user FOREIGN KEY (user_id) REFERENCES users (user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS scan_reports (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
user_id BIGINT UNSIGNED NOT NULL,
|
||||
device_id VARCHAR(32) NULL,
|
||||
scanned_at DATETIME NULL,
|
||||
regions JSON NULL COMMENT 'array of per-region analysis',
|
||||
overall JSON NULL COMMENT 'array of overall tendencies',
|
||||
recommend_mask INT NOT NULL DEFAULT 0 COMMENT 'bitmask of regions recommended for care',
|
||||
recommend_plan JSON NULL COMMENT 'array of {wavelength, mask, label_key}',
|
||||
raw_pd JSON NULL COMMENT 'raw PD samples for the calibration dataset (may be partial)',
|
||||
calibrated TINYINT NOT NULL DEFAULT 0 COMMENT 'whether thresholds were calibrated',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_scan_reports_user_created (user_id, created_at),
|
||||
KEY idx_scan_reports_device_created (device_id, created_at),
|
||||
CONSTRAINT fk_scan_reports_user FOREIGN KEY (user_id) REFERENCES users (user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS device_events (
|
||||
event_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
device_id VARCHAR(32) NOT NULL,
|
||||
@@ -185,4 +203,5 @@ INSERT IGNORE INTO system_settings (setting_key, setting_value) VALUES
|
||||
('enable_register', 'true'),
|
||||
('enable_binding', 'true'),
|
||||
('enable_free_mode', 'true'),
|
||||
('maintenance_mode', 'false');
|
||||
('maintenance_mode', 'false'),
|
||||
('diagnosis_config', '{"calibrated": false, "note": "阈值为待标定初始值(文档建议值);polarity=inverted 与 pd_region_map(左PD1/中上PD2/中PD3,6,7/中下PD4/右PD5) 均已由固件确认(2026-07-28)", "polarity": "inverted", "th": 0.10, "severity": {"low": 0.05, "mid": 0.15, "high": 0.30}, "brightness": 204, "pd_region_map": {"left": [0], "top": [1], "middle": [2, 5, 6], "bottom": [3], "right": [4]}, "problem_wavelength": {"aging": 2, "acne": 3, "pigment": 4, "deep": 1}}');
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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])
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
@@ -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'))
|
||||
|
||||
在新工单中引用
屏蔽一个用户